Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -83,6 +83,24 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Local URL: http://localhost:8069
|
||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||
|
||||
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
||||
|
||||
The drop-in replacement is the new helper on `ir.attachment`:
|
||||
```python
|
||||
return att.action_fusion_preview(title='My Doc')
|
||||
# vs. the old pattern:
|
||||
# return {'type': 'ir.actions.act_url',
|
||||
# 'url': '/web/content/%s?download=true' % att.id,
|
||||
# 'target': 'new'}
|
||||
```
|
||||
|
||||
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
||||
|
||||
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
||||
|
||||
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
||||
|
||||
## Supabase Knowledge Base
|
||||
Before starting unfamiliar work, check Supabase for context:
|
||||
```bash
|
||||
|
||||
@@ -252,10 +252,23 @@ class FusionCpShipment(models.Model):
|
||||
}
|
||||
|
||||
def _action_open_attachment(self, attachment):
|
||||
"""Open an attachment PDF in the browser viewer (new tab)."""
|
||||
"""Open an attachment for the operator.
|
||||
|
||||
Delegates to ir.attachment.action_fusion_preview when
|
||||
fusion_pdf_preview is installed — PDFs render in the preview
|
||||
dialog, anything else downloads. Falls back to the legacy
|
||||
new-tab URL when the helper isn't available. See CLAUDE.md
|
||||
"PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not attachment:
|
||||
return False
|
||||
if hasattr(attachment, 'action_fusion_preview'):
|
||||
return attachment.action_fusion_preview(
|
||||
title=attachment.name or 'Shipping Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=false' % attachment.id,
|
||||
|
||||
1
fusion_claims/.gitignore
vendored
Normal file
1
fusion_claims/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.superpowers/
|
||||
3106
fusion_claims/CLAUDE.md
Normal file
3106
fusion_claims/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
- `1–3 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.
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
63
fusion_claims/static/src/js/fc_posting_countdown.js
Normal file
63
fusion_claims/static/src/js/fc_posting_countdown.js
Normal 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,
|
||||
});
|
||||
81
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss
Normal file
81
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss
Normal 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;
|
||||
}
|
||||
282
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
282
fusion_claims/static/src/scss/fc_dashboard.scss
Normal 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; }
|
||||
}
|
||||
7
fusion_claims/static/src/xml/fc_posting_countdown.xml
Normal file
7
fusion_claims/static/src/xml/fc_posting_countdown.xml
Normal 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>
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
from . import test_signed_pages_gate
|
||||
from . import test_application_received_wizard
|
||||
from . import test_dashboard
|
||||
|
||||
367
fusion_claims/tests/test_dashboard.py
Normal file
367
fusion_claims/tests/test_dashboard.py
Normal 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")
|
||||
@@ -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 > 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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion PDF Preview",
|
||||
"version": "19.0.2.0.0",
|
||||
"version": "19.0.2.1.0",
|
||||
"depends": ["web"],
|
||||
"author": "Nexa Systems Inc",
|
||||
"category": "web",
|
||||
@@ -41,6 +41,7 @@ Key Features:
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"fusion_pdf_preview/static/src/js/pdf_preview.js",
|
||||
"fusion_pdf_preview/static/src/js/open_attachment_action.js",
|
||||
"fusion_pdf_preview/static/src/js/user_menu.js",
|
||||
"fusion_pdf_preview/static/src/xml/pdf_viewer_dialog.xml",
|
||||
],
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
from . import res_users
|
||||
from . import ir_http
|
||||
from . import ir_actions_report
|
||||
from . import ir_attachment
|
||||
from . import res_config_settings
|
||||
from . import preview_log
|
||||
|
||||
48
fusion_pdf_preview/models/ir_attachment.py
Normal file
48
fusion_pdf_preview/models/ir_attachment.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = "ir.attachment"
|
||||
|
||||
def action_fusion_preview(self, title=None, model_name=None, record_ids=None):
|
||||
"""Return the right action to "view" this attachment.
|
||||
|
||||
- PDF attachments → fusion_pdf_preview's client dialog (preview
|
||||
+ print + download all in one place, with audit log).
|
||||
- Anything else (ZPL, CSV, XML, images, etc.) → legacy
|
||||
new-tab/download URL since the PDF viewer can't render them.
|
||||
|
||||
Drop-in replacement for the common pattern:
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % att.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
Use as: return att.action_fusion_preview(title='My Doc')
|
||||
|
||||
See CLAUDE.md "PDF Preview" for the full contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
is_pdf = (
|
||||
(self.mimetype or '').lower() == 'application/pdf'
|
||||
or (self.name or '').lower().endswith('.pdf')
|
||||
)
|
||||
if is_pdf:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_pdf_preview.open_attachment',
|
||||
'params': {
|
||||
'attachment_id': self.id,
|
||||
'title': title or self.name or 'Document',
|
||||
'model_name': model_name or '',
|
||||
'record_ids': str(record_ids) if record_ids else '',
|
||||
},
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % self.id,
|
||||
'target': 'new',
|
||||
}
|
||||
42
fusion_pdf_preview/static/src/js/open_attachment_action.js
Normal file
42
fusion_pdf_preview/static/src/js/open_attachment_action.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { openPDFViewer } from "./pdf_preview";
|
||||
|
||||
/**
|
||||
* Client action: open an ir.attachment in the PDF preview dialog.
|
||||
*
|
||||
* Python callers return:
|
||||
* {
|
||||
* 'type': 'ir.actions.client',
|
||||
* 'tag': 'fusion_pdf_preview.open_attachment',
|
||||
* 'params': {
|
||||
* 'attachment_id': <int>,
|
||||
* 'title': <str>, // optional, defaults to "Document"
|
||||
* 'model_name': <str>, // optional, for audit log
|
||||
* 'record_ids': <str>, // optional, comma-sep for audit log
|
||||
* 'report_name': <str>, // optional, for audit log
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* Non-PDF attachments fall back to opening in a new browser tab — the
|
||||
* preview dialog only renders PDF. ZPL labels (text/plain) should NOT
|
||||
* use this action; route those through the direct download act_url.
|
||||
*/
|
||||
registry.category("actions").add(
|
||||
"fusion_pdf_preview.open_attachment",
|
||||
async (env, action) => {
|
||||
const params = action.params || {};
|
||||
const attachmentId = params.attachment_id;
|
||||
if (!attachmentId) {
|
||||
return;
|
||||
}
|
||||
const title = params.title || "Document";
|
||||
const url = `/web/content/${attachmentId}`;
|
||||
openPDFViewer(env, url, title, {
|
||||
modelName: params.model_name || "",
|
||||
recordIds: params.record_ids || "",
|
||||
reportName: params.report_name || "",
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -27,11 +27,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 `'` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records, write a one-shot post-migration or update via `odoo shell`. | any code scripting `mail.template.body_html` |
|
||||
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
|
||||
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
|
||||
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
|
||||
| **Per-shipment service override via `fp_service_type_override` context key** | Operator picks a FedEx service tier on `fp.receiving.x_fc_outbound_service_type` (Priority Overnight, 2Day, Ground, etc.). `action_generate_outbound_label` passes the chosen code through to `carrier.send_shipping` via `with_context(fp_service_type_override=…)`. `fusion_shipping.fusion_fedex_rest_send_shipping` reads the context key and overrides `srm.service_type` for that call only — carrier default is untouched. Empty/blank override falls back to `carrier.fedex_rest_service_type`. Only FedEx is wired up right now; mirroring this for Canada Post / UPS is a separate task. | `fusion_plating_receiving/models/fp_receiving.py` → `fusion_shipping/models/delivery_carrier.py` |
|
||||
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `'` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records: temporarily flip the wrapper to `<odoo noupdate="0">`, redeploy and `-u`, then revert (and `UPDATE ir_model_data SET noupdate=true ...` to restore protection). Alternatively, post-migration script or odoo shell write. (4) **`mail.template.report_name` was removed in Odoo 19** — the dynamic PDF-filename field now lives on `ir.actions.report.print_report_name` instead. Old `<field name="report_name">` entries in mail-template data files silently survive while protected by noupdate=1, but the moment you force-reload they error with `Invalid field 'report_name' in 'mail.template'`. Strip them or move the expression to the report action. | any code scripting `mail.template.body_html` |
|
||||
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
|
||||
| **OWL `t-out` escapes plain JS strings — wrap with `markup()`** | The JS-side analogue of the `message_post` markup gotcha. `t-out="state.html"` only renders unescaped HTML when the value is a `markup()`-tagged string from `@odoo/owl`; a plain string (e.g. straight off an RPC response) gets HTML-escaped and the user sees literal `<p>foo</p>` text. Caught here because `fp_record_inputs_dialog.js` was assigning `this.state.instructionsHtml = data.instructions_html` raw — recipe author's `<p>...</p>` rendered as visible tags in the operator dialog. **Fix:** `import { markup } from "@odoo/owl"` and wrap RPC-returned HTML: `this.state.html = markup(data.html || "")`. Same rule for any OWL component that ingests HTML from the server and pushes it through `t-out`. | any OWL component rendering server-returned HTML via `t-out` |
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
| **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
|
||||
|
||||
|
||||
@@ -95,10 +95,20 @@ class FpFair(models.Model):
|
||||
}
|
||||
|
||||
def action_view_signed_document(self):
|
||||
"""Open the signed PDF attachment in a new browser tab."""
|
||||
"""Open the signed PDF in the fusion_pdf_preview dialog.
|
||||
|
||||
Falls back to a new-tab URL when the helper isn't installed.
|
||||
See CLAUDE.md "PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_signed_pdf_id:
|
||||
return False
|
||||
if hasattr(self.x_fc_signed_pdf_id, 'action_fusion_preview'):
|
||||
return self.x_fc_signed_pdf_id.action_fusion_preview(
|
||||
title=self.x_fc_signed_pdf_id.name or 'Signed FAIR',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % self.x_fc_signed_pdf_id.id,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.7.0.0',
|
||||
'version': '19.0.7.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -106,6 +106,81 @@ class FpCertificate(models.Model):
|
||||
string='Fischerscope PDF filename',
|
||||
)
|
||||
|
||||
# Non-PDF Fischerscope uploads (.doc / .docx / .xlsx / images) — the
|
||||
# Issue Certs wizard stashes them here so the thickness-required gate
|
||||
# can still pass. Unlike `x_fc_local_thickness_pdf`, this attachment
|
||||
# is NOT merged into the CoC PDF as page 2 (we can't rasterize .doc
|
||||
# server-side without LibreOffice). It rides along as a separate
|
||||
# evidence attachment on the cert and on any email/portal delivery.
|
||||
x_fc_local_thickness_evidence_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Fischerscope Evidence (non-PDF)',
|
||||
copy=False,
|
||||
help='Original Fischerscope/XRF upload when not a PDF. Counts '
|
||||
'as valid thickness evidence for the cert-issue gate but '
|
||||
'is delivered as a separate attachment, not merged into '
|
||||
'the CoC PDF.',
|
||||
)
|
||||
|
||||
# Report-level Fischerscope metadata — populated by the Issue Certs
|
||||
# wizard when parsing an RTF/.docx upload. Rendered on the CoC so
|
||||
# the printed cert shows the same context an auditor would see on
|
||||
# the original XDAL 600 export (equipment, operator, calibration,
|
||||
# product/application, measuring time, date/time). Per-reading
|
||||
# values (mils, Ni%, P%) live on fp.thickness.reading.
|
||||
x_fc_thickness_equipment = fields.Char(
|
||||
string='Thickness Equipment',
|
||||
help='XRF/thickness gauge model (e.g. "Fischerscope XDAL 600").',
|
||||
)
|
||||
x_fc_thickness_operator = fields.Char(
|
||||
string='Thickness Operator',
|
||||
help='Operator initials/name as recorded by the gauge.',
|
||||
)
|
||||
x_fc_thickness_datetime = fields.Datetime(
|
||||
string='Thickness Reading Date/Time',
|
||||
help='When the readings were taken on the gauge.',
|
||||
)
|
||||
x_fc_thickness_product = fields.Char(
|
||||
string='Thickness Product Profile',
|
||||
help='XDAL 600 product line + part-family reference '
|
||||
'(e.g. "2805031 / NiP/Al-alloys 2805030").',
|
||||
)
|
||||
x_fc_thickness_application = fields.Char(
|
||||
string='Thickness Application',
|
||||
help='XDAL 600 application profile '
|
||||
'(e.g. "16 / NiP/Al-alloys").',
|
||||
)
|
||||
x_fc_thickness_directory = fields.Char(
|
||||
string='Thickness Directory',
|
||||
help='XDAL 600 directory the measurements were saved into.',
|
||||
)
|
||||
x_fc_thickness_measuring_time_sec = fields.Integer(
|
||||
string='Thickness Measuring Time (sec)',
|
||||
help='Per-reading measuring time configured on the gauge.',
|
||||
)
|
||||
x_fc_thickness_source_filename = fields.Char(
|
||||
string='Thickness Source File',
|
||||
help='Filename of the Fischerscope upload the readings were '
|
||||
'parsed from.',
|
||||
)
|
||||
# Two paths populate this field, with operator upload winning:
|
||||
# 1. RTF auto-extraction — Issue Certs wizard runs libwmf
|
||||
# (wmf2svg) on the embedded WMF blocks and picks the
|
||||
# largest raster (header banners filtered by area threshold).
|
||||
# 2. Manual PNG/JPEG upload via the wizard's "Measurement
|
||||
# Image" field — operator override path when the
|
||||
# auto-extracted image is wrong, missing, or low-quality.
|
||||
# See _apply_to_cert and _apply_image_to_cert in the wizard.
|
||||
x_fc_thickness_image_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Thickness Microscope Image',
|
||||
copy=False,
|
||||
help='Microscope photo of the measurement site. Auto-extracted '
|
||||
'from the Fischerscope RTF export when libwmf can parse '
|
||||
'the embedded WMF; operator can also upload a PNG/JPEG '
|
||||
'directly via the Issue Certs wizard to override.',
|
||||
)
|
||||
|
||||
# ---- Material traceability (T2.3) ----
|
||||
batch_ids = fields.Many2many(
|
||||
'fusion.plating.batch', compute='_compute_batch_ids',
|
||||
@@ -456,7 +531,11 @@ class FpCertificate(models.Model):
|
||||
if 'x_fc_thickness_pdf_id' in rec._fields else False
|
||||
)
|
||||
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
|
||||
if not (has_readings or has_qc_fischer_pdf or has_local_pdf):
|
||||
has_local_evidence = bool(
|
||||
rec.x_fc_local_thickness_evidence_id
|
||||
)
|
||||
if not (has_readings or has_qc_fischer_pdf
|
||||
or has_local_pdf or has_local_evidence):
|
||||
type_label = (
|
||||
_('Thickness Report')
|
||||
if rec.certificate_type == 'thickness_report'
|
||||
@@ -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':
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* onSave → /fp/record_inputs/commit → advance step (optional)
|
||||
*/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { Component, markup, onWillStart, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
@@ -106,7 +106,10 @@ export class FpRecordInputsDialog extends Component {
|
||||
this.state.jobName = data.job.name;
|
||||
this.state.recipeRootId = data.recipe_root_id || false;
|
||||
this.state.userInitials = data.user_initials || "";
|
||||
this.state.instructionsHtml = data.instructions_html || "";
|
||||
// `t-out` only renders unescaped HTML when the value is a
|
||||
// `markup()`-tagged string — otherwise it shows literal tags
|
||||
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
|
||||
this.state.instructionsHtml = markup(data.instructions_html || "");
|
||||
this.state.instructionImages = data.instruction_images || [];
|
||||
const nowDt = this._fpNowForDatetimeLocal();
|
||||
this.state.rows = data.prompts.map((p) => {
|
||||
|
||||
@@ -76,12 +76,21 @@
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- 3. Add a Thickness Report tab right next to the -->
|
||||
<!-- Certificate PDF tab so operator can preview the -->
|
||||
<!-- Fischerscope file before merging into the cert. -->
|
||||
<!-- 3. Thickness Report tab — single place to see/edit
|
||||
every Fischerscope-related field on the cert.
|
||||
Reorganized 2026-05-21:
|
||||
* Status + linked QC at the top (read-only context)
|
||||
* XDAL 600 metadata (operator/product/etc.) editable
|
||||
so manager can correct OCR mistakes
|
||||
* Microscope image preview (auto-extracted from RTF
|
||||
or manually uploaded — either way editable here)
|
||||
* Source files (PDF / non-PDF evidence / source name)
|
||||
* Upload wizard button + help text -->
|
||||
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||
<page string="Thickness Report (Fischerscope)"
|
||||
name="thickness_pdf">
|
||||
|
||||
<!-- Status + QC link (read-only context) -->
|
||||
<group>
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
readonly="1"
|
||||
@@ -90,12 +99,33 @@
|
||||
decoration-success="x_fc_thickness_status == 'merged'"/>
|
||||
<field name="x_fc_thickness_qc_id" readonly="1"
|
||||
invisible="not x_fc_thickness_qc_id"/>
|
||||
<field name="x_fc_thickness_pdf_id" readonly="1"
|
||||
widget="many2one_binary"
|
||||
invisible="not x_fc_thickness_pdf_id"/>
|
||||
</group>
|
||||
<separator string="Upload Fischerscope Report"/>
|
||||
<div class="oe_button_box">
|
||||
|
||||
<!-- Hints rotate by state -->
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness data has been
|
||||
uploaded yet. Click <strong>Upload Thickness
|
||||
Report</strong> below to drop a `.doc` / `.docx`
|
||||
/ `.rtf` / `.pdf` file straight from the
|
||||
XDAL 600. The wizard parses readings +
|
||||
metadata and fills out the fields on this tab.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
<p>
|
||||
<i class="fa fa-arrow-up"/>
|
||||
Click <strong>Issue</strong> in the header to
|
||||
merge the Fischerscope PDF as page 2 of
|
||||
the CoC. Readings will render inline in the
|
||||
body of the cert either way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload wizard CTA -->
|
||||
<div style="margin: 8px 0;">
|
||||
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
|
||||
type="action"
|
||||
class="btn-primary"
|
||||
@@ -103,44 +133,65 @@
|
||||
context="{'default_certificate_id': id}"
|
||||
invisible="state != 'draft'"/>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
<p>
|
||||
Drop the <code>.docx</code> or <code>.pdf</code>
|
||||
file straight from the Fischerscope XDAL 600.
|
||||
The wizard reads the readings, calibration set,
|
||||
and operator info, lets you review them, and
|
||||
attaches the original file to this certificate.
|
||||
</p>
|
||||
</div>
|
||||
<separator string="Attached File"
|
||||
invisible="not x_fc_local_thickness_pdf"/>
|
||||
<group invisible="not x_fc_local_thickness_pdf">
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"
|
||||
readonly="1"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
|
||||
<separator string="XDAL 600 Measurement Context"/>
|
||||
<p class="text-muted small">
|
||||
These values are pulled from the uploaded file
|
||||
and printed on the CoC's thickness section. Edit
|
||||
any field here to override what the parser saw.
|
||||
</p>
|
||||
<group>
|
||||
<group>
|
||||
<field name="x_fc_thickness_equipment"
|
||||
placeholder="Fischerscope XDAL 600"/>
|
||||
<field name="x_fc_thickness_operator"
|
||||
placeholder="Operator initials / name"/>
|
||||
<field name="x_fc_thickness_datetime"/>
|
||||
<field name="x_fc_thickness_measuring_time_sec"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_thickness_product"
|
||||
placeholder="e.g. 2805031 / NiP/Al-alloys 2805030"/>
|
||||
<field name="x_fc_thickness_application"
|
||||
placeholder="e.g. 16 / NiP/Al-alloys"/>
|
||||
<field name="x_fc_thickness_directory"
|
||||
placeholder="XDAL save directory"/>
|
||||
<field name="x_fc_thickness_source_filename"
|
||||
readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Microscope Image"/>
|
||||
<p class="text-muted small">
|
||||
Auto-extracted from RTF uploads (via libwmf) or
|
||||
manually uploaded via the wizard. Drop a new
|
||||
PNG/JPEG here to override.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_thickness_image_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
|
||||
<separator string="Source Files"/>
|
||||
<group>
|
||||
<group string="Fischerscope PDF"
|
||||
invisible="not x_fc_local_thickness_pdf">
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<group string="Non-PDF Evidence (RTF/DOCX)"
|
||||
invisible="not x_fc_local_thickness_evidence_id">
|
||||
<field name="x_fc_local_thickness_evidence_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<group string="QC-side Fischerscope PDF"
|
||||
invisible="not x_fc_thickness_pdf_id">
|
||||
<field name="x_fc_thickness_pdf_id" readonly="1"
|
||||
widget="many2one_binary"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness PDF has been
|
||||
uploaded yet. The CoC will be issued without
|
||||
an appended thickness report. Either drop the
|
||||
PDF into the upload field above, OR upload it
|
||||
on the linked QC check and re-open this cert.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
<p>
|
||||
<i class="fa fa-arrow-up" title="Action"
|
||||
aria-label="Action"/>
|
||||
Click <strong>Issue</strong> in the header
|
||||
and the Fischerscope PDF will be merged into
|
||||
page 2 of the CoC.
|
||||
</p>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -21,13 +21,26 @@ Issue button on the cert form, which stays as the fallback path.
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum pixel-area for an extracted RTF image to be treated as the
|
||||
# "microscope photo" candidate. Filters out narrow header banners
|
||||
# (~790x203 = 160k pixels) while keeping standard XDAL exports
|
||||
# (~1024x768 = 786k). See CLAUDE.md "entech apt is broken" for the
|
||||
# libwmf install path that makes this possible.
|
||||
_FP_RTF_IMAGE_MIN_AREA = 200_000
|
||||
|
||||
|
||||
# Fischerscope XDAL 600 reading line, e.g.
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
@@ -38,10 +51,206 @@ _FISCHER_READING_RE = re.compile(
|
||||
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
|
||||
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
|
||||
# document order. The hex blob can be interspersed with whitespace
|
||||
# (RTF wraps to 80 cols) — the consumer strips it.
|
||||
_RTF_PICT_WMF_RE = re.compile(
|
||||
r'\{\\pict'
|
||||
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
|
||||
r'\\wmetafile8'
|
||||
r'(?:\\[a-zA-Z]+-?\d*\s?)*'
|
||||
r'\s*([0-9a-fA-F\s]+?)'
|
||||
r'\}',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _fp_extract_rtf_images(raw_bytes):
|
||||
"""Pull all WMF picture blocks out of an RTF, unpack to PNG via
|
||||
libwmf, and return the list of PNG bytes in document order.
|
||||
|
||||
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
|
||||
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
|
||||
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) — it
|
||||
writes a thin SVG and a side-file `*-N.png` per raster block. We
|
||||
keep the PNGs, drop the SVG/WMF temp files.
|
||||
|
||||
Returns [] (not raise) on any tooling/parse failure; the cert
|
||||
issue keeps working even when image extraction can't run.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return []
|
||||
try:
|
||||
text = raw_bytes.decode('latin-1', errors='replace')
|
||||
except Exception:
|
||||
return []
|
||||
blobs = []
|
||||
for m in _RTF_PICT_WMF_RE.finditer(text):
|
||||
hex_blob = re.sub(r'\s+', '', m.group(1))
|
||||
try:
|
||||
blobs.append(bytes.fromhex(hex_blob))
|
||||
except ValueError:
|
||||
continue
|
||||
if not blobs:
|
||||
return []
|
||||
tmpdir = tempfile.mkdtemp(prefix='fp_rtf_wmf_')
|
||||
pngs = []
|
||||
try:
|
||||
for i, wmf in enumerate(blobs):
|
||||
wmf_path = os.path.join(tmpdir, 'pict%d.wmf' % i)
|
||||
svg_path = os.path.join(tmpdir, 'pict%d.svg' % i)
|
||||
with open(wmf_path, 'wb') as fh:
|
||||
fh.write(wmf)
|
||||
try:
|
||||
subprocess.run(
|
||||
['wmf2svg', '-o', svg_path, wmf_path],
|
||||
capture_output=True, timeout=20, check=False,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||
_logger.warning(
|
||||
'wmf2svg unavailable or timed out (%s) — skipping '
|
||||
'RTF image extraction.', e,
|
||||
)
|
||||
return []
|
||||
# wmf2svg writes <basename>-N.png next to the SVG.
|
||||
for fn in sorted(os.listdir(tmpdir)):
|
||||
if fn.startswith('pict%d-' % i) and fn.endswith('.png'):
|
||||
full = os.path.join(tmpdir, fn)
|
||||
with open(full, 'rb') as fh:
|
||||
pngs.append(fh.read())
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return pngs
|
||||
|
||||
|
||||
def _fp_pick_microscope_image(png_bytes_list):
|
||||
"""Pick the largest-area PNG (by pixel count, not file size) from
|
||||
the list — that's almost always the microscope photo. Header
|
||||
banners are wide-but-thin so their pixel area falls below the
|
||||
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
|
||||
when no PNG meets the threshold.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
# Pillow ships with Odoo; this is defensive.
|
||||
return (png_bytes_list[0] if png_bytes_list else None, 0, 0)
|
||||
best = None
|
||||
best_area = 0
|
||||
for png in png_bytes_list:
|
||||
try:
|
||||
with Image.open(io.BytesIO(png)) as im:
|
||||
area = im.width * im.height
|
||||
if area > best_area and area >= _FP_RTF_IMAGE_MIN_AREA:
|
||||
best = (png, im.width, im.height)
|
||||
best_area = area
|
||||
except Exception:
|
||||
continue
|
||||
return best or (None, 0, 0)
|
||||
|
||||
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
|
||||
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
||||
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
|
||||
# XDAL 600 header lines — only present on full RTF reports (not on
|
||||
# the .docx body the upstream parser already handled).
|
||||
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_APPLICATION_RE = re.compile(r'Application:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_MTIME_RE = re.compile(r'Measuring\s+time\s+(\d+)\s*sec', re.IGNORECASE)
|
||||
_FISCHER_EQUIPMENT_RE = re.compile(r'(Fischerscope[^\r\n]*XDAL\s*\d+)', re.IGNORECASE)
|
||||
|
||||
|
||||
def _fp_strip_rtf(raw_bytes):
|
||||
"""Best-effort RTF → plain text. RTF is text-based with control
|
||||
words prefixed by `\\` and groups wrapped in `{}`. We need to strip
|
||||
all of those plus the hex-encoded image data so the Fischerscope
|
||||
reading regex hits clean text.
|
||||
|
||||
Not a full parser — meant for the narrow case of XRF/XDAL reports
|
||||
that have a simple body wrapped around an embedded WMF image.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return ''
|
||||
# RTF is ASCII-safe; latin-1 round-trips every byte.
|
||||
text = raw_bytes.decode('latin-1', errors='replace')
|
||||
# Drop destination groups entirely — these are the image data,
|
||||
# font tables, color tables, etc. The pattern `{\* ...}` and other
|
||||
# nested destinations carry binary-ish hex strings we never want.
|
||||
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
|
||||
text = re.sub(r'\{\\fonttbl[^{}]*\}', ' ', text)
|
||||
text = re.sub(r'\{\\colortbl[^{}]*\}', ' ', text)
|
||||
# Pictures: {\pict ...} contains hex image data. The body is the
|
||||
# part between `\pict...goal\d+` and the closing brace of the group.
|
||||
# Easier: nuke anything matching the picture marker through the
|
||||
# next closing brace at the same depth (single-level approximation
|
||||
# — works for FedEx/XRF docs that have one image per pict block).
|
||||
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
|
||||
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
|
||||
# \tx2840, etc. (`\` + letters + optional digits + optional space)
|
||||
text = re.sub(r'\\[A-Za-z]+-?\d*\s?', ' ', text)
|
||||
# Hex escapes (e.g. \'ae for special chars)
|
||||
text = re.sub(r"\\'[0-9a-fA-F]{2}", ' ', text)
|
||||
# Other backslash escapes (`\\`, `\{`, `\}`)
|
||||
text = re.sub(r'\\[^A-Za-z\s]', ' ', text)
|
||||
# Strip remaining braces
|
||||
text = text.replace('{', ' ').replace('}', ' ')
|
||||
# Collapse runs of whitespace so the Fischerscope regex doesn't
|
||||
# have to deal with weird spacing artefacts from the strip pass.
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
return text
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_rtf(raw_bytes):
|
||||
"""Fischerscope XDAL 600 RTF export → same dict shape as the
|
||||
.docx parser. RTF detection is by magic bytes (`{\\rtf`) — the
|
||||
XRF software names the file `.doc` for legacy reasons, but the
|
||||
contents are RTF.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '',
|
||||
'product': '', 'directory': '', 'application': '',
|
||||
'measuring_time_sec': 0, 'equipment': '',
|
||||
'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
text = _fp_strip_rtf(raw_bytes)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)),
|
||||
float(m.group(3)),
|
||||
float(m.group(4)),
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
def _grab(rx):
|
||||
m = rx.search(text)
|
||||
return m.group(1).strip() if m else ''
|
||||
mtime = 0
|
||||
m = _FISCHER_MTIME_RE.search(text)
|
||||
if m:
|
||||
try:
|
||||
mtime = int(m.group(1))
|
||||
except ValueError:
|
||||
mtime = 0
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': _grab(_FISCHER_CALIB_RE),
|
||||
'operator': _grab(_FISCHER_OPERATOR_RE),
|
||||
'date_str': _grab(_FISCHER_DATE_RE),
|
||||
'time_str': _grab(_FISCHER_TIME_RE),
|
||||
'product': _grab(_FISCHER_PRODUCT_RE),
|
||||
'directory': _grab(_FISCHER_DIRECTORY_RE),
|
||||
'application': _grab(_FISCHER_APPLICATION_RE),
|
||||
'measuring_time_sec': mtime,
|
||||
'equipment': _grab(_FISCHER_EQUIPMENT_RE),
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_docx(raw_bytes):
|
||||
@@ -227,6 +436,14 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
)
|
||||
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
|
||||
fischer_filename = fields.Char(string='Filename')
|
||||
# Optional: microscope/coupon image exported separately from the
|
||||
# XDAL 600. The RTF carries an embedded WMF that the entech host
|
||||
# can't rasterize (no imagemagick/libwmf — see CLAUDE.md "entech
|
||||
# apt is in a broken-deps state"), so the operator exports a PNG
|
||||
# from the XDAL software and uploads it here. Rendered inline on
|
||||
# the CoC's thickness section when present.
|
||||
fischer_image_file = fields.Binary(string='Measurement Image (PNG/JPEG)')
|
||||
fischer_image_filename = fields.Char(string='Image Filename')
|
||||
parsed_summary = fields.Text(
|
||||
string='Parsed Summary', readonly=True,
|
||||
help='Output of the .docx parser. Populated when you attach a '
|
||||
@@ -274,22 +491,29 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
|
||||
@api.onchange('fischer_file', 'fischer_filename')
|
||||
def _onchange_fischer_file(self):
|
||||
"""Try to parse .docx on upload; prefill the readings + summary."""
|
||||
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
|
||||
`.doc` — detected by magic bytes; see CLAUDE.md "Fischerscope
|
||||
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
|
||||
+ summary so the operator can verify before issuing."""
|
||||
if not self.fischer_file:
|
||||
return
|
||||
name = (self.fischer_filename or '').lower()
|
||||
if not name.endswith('.docx'):
|
||||
self.parsed_summary = _(
|
||||
'Non-.docx upload (%s) — file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
except Exception:
|
||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||
return
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
name = (self.fischer_filename or '').lower()
|
||||
is_rtf = raw[:5] == b'{\\rtf' or name.endswith('.rtf')
|
||||
if is_rtf:
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
elif name.endswith('.docx'):
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
else:
|
||||
self.parsed_summary = _(
|
||||
'Non-parseable upload (%s) — file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
readings = parsed.get('readings') or []
|
||||
if readings:
|
||||
self.reading_line_ids = [(5, 0, 0)] + [
|
||||
@@ -312,15 +536,70 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
't': parsed.get('time_str') or '',
|
||||
}
|
||||
|
||||
def _write_thickness_metadata_to_cert(self, cert, parsed):
|
||||
"""Persist the Fischerscope header block (operator, product,
|
||||
application, equipment, measuring time, date/time, source
|
||||
filename) onto the cert so the CoC report can render a full
|
||||
report block instead of a bare readings table.
|
||||
"""
|
||||
vals = {}
|
||||
field_map = (
|
||||
('x_fc_thickness_operator', parsed.get('operator')),
|
||||
('x_fc_thickness_product', parsed.get('product')),
|
||||
('x_fc_thickness_directory', parsed.get('directory')),
|
||||
('x_fc_thickness_application', parsed.get('application')),
|
||||
('x_fc_thickness_measuring_time_sec',
|
||||
parsed.get('measuring_time_sec') or 0),
|
||||
('x_fc_thickness_equipment',
|
||||
parsed.get('equipment') or 'Fischerscope XDAL 600'),
|
||||
('x_fc_thickness_source_filename',
|
||||
self.fischer_filename or ''),
|
||||
)
|
||||
for fname, fval in field_map:
|
||||
if fname in cert._fields and fval:
|
||||
vals[fname] = fval
|
||||
# Combine the gauge's date+time and parse to Datetime — try a
|
||||
# few formats since XDAL exports vary (12h vs 24h, with/without
|
||||
# seconds). Best-effort: leave the field blank if no format
|
||||
# matches rather than crashing the cert issue.
|
||||
date_str = (parsed.get('date_str') or '').strip()
|
||||
time_str = (parsed.get('time_str') or '').strip()
|
||||
if date_str and 'x_fc_thickness_datetime' in cert._fields:
|
||||
from datetime import datetime
|
||||
combined = ('%s %s' % (date_str, time_str)).strip()
|
||||
for fmt in (
|
||||
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
|
||||
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
|
||||
'%m/%d/%Y',
|
||||
):
|
||||
try:
|
||||
vals['x_fc_thickness_datetime'] = datetime.strptime(
|
||||
combined, fmt,
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if vals:
|
||||
cert.write(vals)
|
||||
|
||||
def _apply_to_cert(self):
|
||||
"""Write this line's data into the cert."""
|
||||
"""Write this line's data into the cert.
|
||||
|
||||
Order matters: operator-uploaded PNG must run LAST so it wins
|
||||
over any image the RTF auto-extraction picked. Reverse order
|
||||
(PNG first, then RTF) lets the WMF blow away the explicit
|
||||
operator choice — exactly the bug we just hit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
cert = self.cert_id.sudo()
|
||||
if not self.fischer_file:
|
||||
# Just push manual readings, if any.
|
||||
self._push_readings_to_cert()
|
||||
# PNG-only path: still attach the operator's image upload.
|
||||
self._apply_image_to_cert(cert)
|
||||
return
|
||||
name = (self.fischer_filename or 'fischerscope').lower()
|
||||
calibration = '' # backfilled below if the parser hits
|
||||
if name.endswith('.pdf'):
|
||||
# Drop the PDF into the cert-local field — merges into page 2.
|
||||
cert.write({
|
||||
@@ -328,23 +607,107 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
||||
})
|
||||
else:
|
||||
# .doc / .docx / anything else — attach as evidence.
|
||||
self.env['ir.attachment'].sudo().create({
|
||||
# .doc / .docx / anything else — attach as evidence AND
|
||||
# link the attachment to the cert's evidence slot so the
|
||||
# thickness-required gate recognises it. Without the link,
|
||||
# the gate would still raise (it checks specific fields,
|
||||
# not stray attachments) and rolling back the transaction
|
||||
# would orphan the upload.
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_filename or 'fischerscope-report',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
if 'x_fc_local_thickness_evidence_id' in cert._fields:
|
||||
cert.write({'x_fc_local_thickness_evidence_id': att.id})
|
||||
# Re-parse the file at apply time so the report-header
|
||||
# metadata (operator, product, application, etc.) makes it
|
||||
# onto the cert. Onchange populates reading_line_ids but
|
||||
# not the cert-level fields. Best-effort: any parse hiccup
|
||||
# is logged and we still complete the attachment + readings.
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
is_rtf = raw[:5] == b'{\\rtf'
|
||||
if is_rtf:
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
elif name.endswith('.docx'):
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
else:
|
||||
parsed = None
|
||||
if parsed:
|
||||
self._write_thickness_metadata_to_cert(cert, parsed)
|
||||
calibration = parsed.get('calibration') or ''
|
||||
# WMF image extraction is RTF-only (the .docx path
|
||||
# uses python-docx which already gives PIL-readable
|
||||
# bitmaps; that flow can be added later if needed).
|
||||
if is_rtf and 'x_fc_thickness_image_id' in cert._fields:
|
||||
pngs = _fp_extract_rtf_images(raw)
|
||||
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
|
||||
if img_bytes:
|
||||
img_att = self.env['ir.attachment'].sudo().create({
|
||||
'name': '%s-microscope.png' % (
|
||||
(self.fischer_filename or 'fischerscope')
|
||||
.rsplit('.', 1)[0]
|
||||
),
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(img_bytes),
|
||||
'mimetype': 'image/png',
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({
|
||||
'x_fc_thickness_image_id': img_att.id,
|
||||
})
|
||||
_logger.info(
|
||||
'Cert %s: attached microscope image '
|
||||
'(%dx%d, %d bytes)',
|
||||
cert.name, img_w, img_h, len(img_bytes),
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Cert %s: Fischerscope metadata extraction failed: %s',
|
||||
cert.name, exc,
|
||||
)
|
||||
cert.message_post(body=Markup(_(
|
||||
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||
) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert()
|
||||
)) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert(calibration=calibration)
|
||||
# Operator's PNG upload wins over auto-extracted WMF — runs
|
||||
# last so it overwrites x_fc_thickness_image_id if both paths
|
||||
# supplied an image.
|
||||
self._apply_image_to_cert(cert)
|
||||
|
||||
def _push_readings_to_cert(self):
|
||||
def _apply_image_to_cert(self, cert):
|
||||
"""Attach the operator-uploaded PNG/JPEG and link it to the
|
||||
cert's image slot so the CoC report can render it inline.
|
||||
No-op when nothing was uploaded. Mirrors the evidence-file
|
||||
pattern: file is attached as a regular ir.attachment AND
|
||||
linked to the dedicated field so the report template can
|
||||
find it predictably.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.fischer_image_file or \
|
||||
'x_fc_thickness_image_id' not in cert._fields:
|
||||
return
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_image_filename or 'thickness-image.png',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_image_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({'x_fc_thickness_image_id': att.id})
|
||||
|
||||
def _push_readings_to_cert(self, calibration=''):
|
||||
"""Create fp.thickness.reading rows on the cert from wizard rows.
|
||||
Skips when no rows. Does not deduplicate against existing
|
||||
readings — the manager has just told us this is the new data."""
|
||||
readings — the manager has just told us this is the new data.
|
||||
Per-reading calibration_std_ref is stamped from the optional
|
||||
`calibration` arg so the printed CoC's calibration line stays
|
||||
accurate even when readings are re-pushed from a fresh upload.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Reading = self.env.get('fp.thickness.reading')
|
||||
if Reading is None or not self.reading_line_ids:
|
||||
@@ -358,6 +721,8 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
}
|
||||
if 'reading_number' in Reading._fields:
|
||||
vals['reading_number'] = r.sequence
|
||||
if calibration and 'calibration_std_ref' in Reading._fields:
|
||||
vals['calibration_std_ref'] = calibration
|
||||
Reading.sudo().create(vals)
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,23 @@
|
||||
<field name="fischer_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<group string="Measurement Image (Optional)"
|
||||
invisible="not needs_thickness">
|
||||
<field name="fischer_image_file"
|
||||
filename="fischer_image_filename"
|
||||
widget="image"
|
||||
options="{'size': [200, 200]}"/>
|
||||
<field name="fischer_image_filename"
|
||||
invisible="1"/>
|
||||
<div colspan="2" class="text-muted small">
|
||||
Drop a PNG/JPEG of the coupon
|
||||
under the XRF probe (export
|
||||
from the XDAL 600 software's
|
||||
Image menu). Rendered inline on
|
||||
the printed CoC so the customer
|
||||
sees the actual measurement.
|
||||
</div>
|
||||
</group>
|
||||
<div class="alert alert-info"
|
||||
role="alert"
|
||||
invisible="not needs_thickness or not parsed_summary">
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<record id="fp_mail_template_quote_sent" model="mail.template">
|
||||
<field name="name">FP: Quotation Sent</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="subject">Quotation {{ object.name }} — EN Technologies</field>
|
||||
<field name="subject">Quotation {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Quotation Ready</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -54,16 +54,15 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
||||
<field name="report_name">Quotation_{{ (object.name or '').replace('/','_') }}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
@@ -80,7 +79,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Order Confirmed</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -114,16 +113,15 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
||||
<field name="report_name">SalesOrder_{{ (object.name or '').replace('/','_') }}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
@@ -140,7 +138,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Parts Received</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -170,10 +168,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
@@ -201,7 +199,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -239,10 +237,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
@@ -262,7 +260,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Parts Have Shipped</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -296,10 +294,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
@@ -311,7 +309,7 @@
|
||||
<record id="fp_mail_template_invoice_posted" model="mail.template">
|
||||
<field name="name">FP: Invoice Notification</field>
|
||||
<field name="model_id" ref="account.model_account_move"/>
|
||||
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
|
||||
<field name="subject">Invoice {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
@@ -319,7 +317,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Invoice Ready</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -357,16 +355,15 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
|
||||
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
@@ -383,7 +380,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Payment Received — Thank You</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -417,10 +414,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.20.0',
|
||||
'version': '19.0.3.25.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
@@ -46,7 +46,20 @@ Provides:
|
||||
'views/fp_receiving_menu.xml',
|
||||
'views/fusion_shipment_inherit_views.xml',
|
||||
'wizards/fp_label_manual_wizard_views.xml',
|
||||
'wizards/fp_label_generate_wizard_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
# Theme-aware shipping-quote callout. Registered in BOTH
|
||||
# bundles so the dark-mode compile picks up the @if branch
|
||||
# (see CLAUDE.md "Dark Mode" — no runtime DOM toggle in
|
||||
# Odoo 19).
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import requests
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
@@ -12,6 +15,13 @@ from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# labelary.com — free ZPL→PDF rasterization service. 8dpmm = 203dpi
|
||||
# (ZD450 default), label size 4x6 in. No API key required for the
|
||||
# typical low-volume use case (limit: ~5 req/s anonymous). PDF output
|
||||
# is requested via the Accept header.
|
||||
_LABELARY_URL = 'https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/'
|
||||
_LABELARY_TIMEOUT = 10
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
"""Parts receiving record.
|
||||
@@ -101,6 +111,65 @@ class FpReceiving(models.Model):
|
||||
help='Who picks up the parts when work is done. Used to generate '
|
||||
'the return shipping label on the linked Outbound Shipment.',
|
||||
)
|
||||
x_fc_outbound_service_type = fields.Selection(
|
||||
selection='_fp_get_service_type_selection',
|
||||
string='Service Type',
|
||||
tracking=True,
|
||||
help='Override the carrier default for this shipment. Leave '
|
||||
'blank to use the carrier-level default (typically Ground '
|
||||
'or Priority depending on configuration). Pick a faster '
|
||||
'tier (e.g. Priority Overnight) when the customer is '
|
||||
'paying for expedited delivery.',
|
||||
)
|
||||
|
||||
# Curated FedEx services for a Canadian B2B plating shop. The
|
||||
# carrier-level selection (~38 options) is overwhelming and mostly
|
||||
# noise — frieght tiers want 150+ lb, regional services don't apply
|
||||
# to CA-origin shipments, distribution-program services need extra
|
||||
# account config. Sweep against the live sandbox (see
|
||||
# scripts/fp_fedex_service_matrix.py) confirmed these 12 are the
|
||||
# only ones realistically usable for parts shipments. If a future
|
||||
# contract enables more, append here.
|
||||
_FP_USABLE_FEDEX_SERVICES = (
|
||||
# CA domestic
|
||||
'FEDEX_GROUND', # Cheapest, 1-7 days
|
||||
'FEDEX_EXPRESS_SAVER', # 3-day economy
|
||||
'FEDEX_2_DAY', # 2 business days
|
||||
'FEDEX_2_DAY_AM', # 2 business days, 10:30am
|
||||
'STANDARD_OVERNIGHT', # Next day, end of day
|
||||
'PRIORITY_OVERNIGHT', # Next day, 10:30am
|
||||
'FIRST_OVERNIGHT', # Next day, 8:00am
|
||||
# International (CA -> US / EU / APAC)
|
||||
'FEDEX_INTERNATIONAL_CONNECT_PLUS', # Mid-tier intl
|
||||
'INTERNATIONAL_ECONOMY', # 4-5 day intl economy
|
||||
'FEDEX_INTERNATIONAL_PRIORITY', # 1-3 day intl
|
||||
'FEDEX_INTERNATIONAL_PRIORITY_EXPRESS', # 1-3 day intl premium
|
||||
'INTERNATIONAL_FIRST', # Earliest intl available
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _fp_get_service_type_selection(self):
|
||||
"""Curated FedEx service selection for the receiving form.
|
||||
|
||||
Pulls labels from the carrier's full selection (so they match
|
||||
whatever fusion_shipping ships) but filters to the codes in
|
||||
_FP_USABLE_FEDEX_SERVICES. Order in the dropdown follows the
|
||||
tuple — cheapest CA-domestic first, premium international last.
|
||||
|
||||
Empty list when fusion_shipping isn't installed.
|
||||
"""
|
||||
Carrier = self.env.get('delivery.carrier')
|
||||
if Carrier is None:
|
||||
return []
|
||||
field = Carrier._fields.get('fedex_rest_service_type')
|
||||
if not field:
|
||||
return []
|
||||
labels = dict(field.selection)
|
||||
return [
|
||||
(code, labels.get(code, code))
|
||||
for code in self._FP_USABLE_FEDEX_SERVICES
|
||||
if code in labels
|
||||
]
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
@@ -117,13 +186,29 @@ class FpReceiving(models.Model):
|
||||
help='True when the linked outbound shipment has a label PDF '
|
||||
'attached. Drives the Print Label smart-button visibility.',
|
||||
)
|
||||
x_fc_has_label_zpl = fields.Boolean(
|
||||
compute='_compute_x_fc_has_label',
|
||||
help='True when the linked outbound shipment has a ZPL label '
|
||||
'attached. Drives the Print ZPL smart-button visibility.',
|
||||
)
|
||||
x_fc_shipping_quote_html = fields.Html(
|
||||
string='Shipping Quote',
|
||||
readonly=True, copy=False, sanitize=False,
|
||||
help='Estimated cost + delivery date from the carrier API. '
|
||||
'Click "Refresh Quote" to fetch the latest. Reflects the '
|
||||
'currently-selected Service Type, weight, and dimensions.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id.label_attachment_id')
|
||||
@api.depends(
|
||||
'x_fc_outbound_shipment_id.label_attachment_id',
|
||||
'x_fc_outbound_shipment_id.x_fc_label_zpl_attachment_id',
|
||||
)
|
||||
def _compute_x_fc_has_label(self):
|
||||
for rec in self:
|
||||
rec.x_fc_has_label = bool(
|
||||
rec.x_fc_outbound_shipment_id
|
||||
and rec.x_fc_outbound_shipment_id.label_attachment_id
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
rec.x_fc_has_label = bool(ship and ship.label_attachment_id)
|
||||
rec.x_fc_has_label_zpl = bool(
|
||||
ship and ship.x_fc_label_zpl_attachment_id
|
||||
)
|
||||
|
||||
# ---- Phase C — Outbound packaging fields -----------------------------
|
||||
@@ -293,15 +378,54 @@ class FpReceiving(models.Model):
|
||||
|
||||
# ---- Phase C — Generate Outbound Label -------------------------------
|
||||
def action_generate_outbound_label(self):
|
||||
"""One-button label generation.
|
||||
"""Open the confirmation wizard before the actual API call.
|
||||
|
||||
Branches on carrier.delivery_type:
|
||||
- 'fixed' (no API integration): opens manual entry wizard.
|
||||
- 'fusion_*' (API integration): synthesizes a stock.picking,
|
||||
calls the existing carrier.<provider>_send_shipping method,
|
||||
copies the result back to the linked fusion.shipment.
|
||||
- On API exception: falls back to the manual wizard with the
|
||||
error message in the note field.
|
||||
Two guards live here so the user can't accidentally bill
|
||||
themselves for duplicate shipments:
|
||||
1. If a label is already attached to the linked shipment,
|
||||
refuse to regenerate — operator must void the shipment
|
||||
first.
|
||||
2. Otherwise pop fp.label.generate.wizard so the operator
|
||||
confirms carrier + service tier + weight before any API
|
||||
call. The wizard's action_confirm calls
|
||||
_fp_actually_generate_outbound_label.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs()
|
||||
if self.x_fc_outbound_shipment_id \
|
||||
and self.x_fc_outbound_shipment_id.label_attachment_id:
|
||||
raise UserError(_(
|
||||
'A shipping label already exists for this receiving '
|
||||
'(shipment %s). Void that shipment first if you need '
|
||||
'to regenerate — otherwise every click would create a '
|
||||
'new billable FedEx shipment with its own tracking '
|
||||
'number.'
|
||||
) % self.x_fc_outbound_shipment_id.name)
|
||||
carrier = self.x_fc_carrier_id
|
||||
if carrier.delivery_type == 'fixed':
|
||||
return self._fp_open_manual_label_wizard(note=_(
|
||||
'Carrier "%s" has no API integration configured. Enter '
|
||||
'the label PDF and tracking number below to record the '
|
||||
'shipment manually.'
|
||||
) % carrier.name)
|
||||
Wizard = self.env['fp.label.generate.wizard']
|
||||
wiz = Wizard.create(Wizard._fp_default_from_receiving(self.env, self))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Generate Label — %s') % self.name,
|
||||
'res_model': 'fp.label.generate.wizard',
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _fp_actually_generate_outbound_label(self):
|
||||
"""Make the actual carrier API call to create the shipping
|
||||
label. Called by fp.label.generate.wizard.action_confirm after
|
||||
the operator has confirmed service + weight in the wizard.
|
||||
|
||||
Same fall-through behaviour as before: API failure drops to
|
||||
the manual-label wizard with the error pre-filled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs()
|
||||
@@ -320,7 +444,17 @@ class FpReceiving(models.Model):
|
||||
self._fp_sync_packaging_to_shipment()
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping(picking)
|
||||
# Per-shipment service override (e.g. Priority Overnight)
|
||||
# rides through to the carrier API via context. Empty
|
||||
# falls back to the carrier default. See
|
||||
# fusion_shipping.fusion_fedex_rest_send_shipping for the
|
||||
# consumer.
|
||||
ship_carrier = carrier
|
||||
if self.x_fc_outbound_service_type:
|
||||
ship_carrier = carrier.with_context(
|
||||
fp_service_type_override=self.x_fc_outbound_service_type,
|
||||
)
|
||||
shipping_data = ship_carrier.send_shipping(picking)
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except UserError:
|
||||
raise
|
||||
@@ -413,8 +547,24 @@ class FpReceiving(models.Model):
|
||||
"""Synthesize a stock.picking just to carry the data needed by
|
||||
carrier.send_shipping. The picking is auto-validated to 'done'
|
||||
state so it doesn't sit as draft in operator views.
|
||||
|
||||
Idempotent: if a prior call left a non-validated picking on
|
||||
x_fc_shipping_picking_id (e.g. the API call crashed before
|
||||
reaching button_validate), cancel it before building a fresh
|
||||
one. Without this guard, every retry of "Generate Outbound
|
||||
Label" leaks another WH/OUT picking into the Ready queue.
|
||||
"""
|
||||
self.ensure_one()
|
||||
prior = self.x_fc_shipping_picking_id
|
||||
if prior and prior.state not in ('done', 'cancel'):
|
||||
try:
|
||||
prior.sudo().action_cancel()
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
'Receiving %s: could not cancel stale shipping '
|
||||
'picking %s (state=%s); leaving it in place.',
|
||||
self.name, prior.name, prior.state,
|
||||
)
|
||||
Picking = self.env['stock.picking'].sudo()
|
||||
warehouse = self.env['stock.warehouse'].sudo().search(
|
||||
[('company_id', '=', self.env.company.id)], limit=1,
|
||||
@@ -525,11 +675,6 @@ class FpReceiving(models.Model):
|
||||
'location_dest_id': Move.location_dest_id.id,
|
||||
'result_package_id': pkg.id,
|
||||
})
|
||||
# Stash packages on the picking via a transient attr so
|
||||
# _fp_apply_shipping_result can walk them in the same order
|
||||
# the API processes them (FedEx returns labels in the
|
||||
# order packages were submitted).
|
||||
picking._fp_outbound_packages = packages
|
||||
self.x_fc_shipping_picking_id = picking.id
|
||||
return picking
|
||||
|
||||
@@ -581,6 +726,72 @@ class FpReceiving(models.Model):
|
||||
('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', picking.id),
|
||||
], order='id asc')
|
||||
# Split labels by format so the two smart buttons (Print PDF /
|
||||
# Print ZPL) on the receiving form each open the right file.
|
||||
# FedEx names ZPL labels '...ZPLII'; PDFs are 'application/pdf'.
|
||||
pdf_atts = label_atts.filtered(
|
||||
lambda a: (a.mimetype or '').lower() == 'application/pdf'
|
||||
or (a.name or '').lower().endswith('.pdf')
|
||||
)
|
||||
zpl_atts = label_atts.filtered(
|
||||
lambda a: 'zpl' in (a.name or '').lower()
|
||||
)
|
||||
# FedEx ZPL ships with `^POI` (print-orientation invert), which
|
||||
# flips the label 180° on the printer. On a desktop thermal
|
||||
# like the Zebra ZD450 that comes out upside-down for the
|
||||
# operator AND labelary renders the PDF preview inverted to
|
||||
# match. Strip ^POI from a copy of the ZPL so both surfaces
|
||||
# show right-side-up. Original FedEx ZPL on the picking is
|
||||
# left untouched for audit. The cleaned copy is what operators
|
||||
# see (PDF preview + ZPL download).
|
||||
Attachment = self.env['ir.attachment'].sudo()
|
||||
cleaned_zpl_atts = self.env['ir.attachment'].sudo()
|
||||
for zpl in zpl_atts:
|
||||
raw = base64.b64decode(zpl.datas) if zpl.datas else b''
|
||||
if not raw:
|
||||
continue
|
||||
cleaned = raw.replace(b'^POI', b'')
|
||||
if cleaned == raw:
|
||||
# No ^POI present — keep using the original attachment.
|
||||
cleaned_zpl_atts |= zpl
|
||||
continue
|
||||
cleaned_name = (zpl.name or 'label.zpl').rsplit('.', 1)
|
||||
cleaned_name = '%s-fixed.%s' % (
|
||||
cleaned_name[0],
|
||||
cleaned_name[1] if len(cleaned_name) > 1 else 'zpl',
|
||||
)
|
||||
cleaned_zpl_atts |= Attachment.create({
|
||||
'name': cleaned_name,
|
||||
'res_model': 'stock.picking',
|
||||
'res_id': picking.id,
|
||||
'datas': base64.b64encode(cleaned),
|
||||
'mimetype': 'text/plain',
|
||||
})
|
||||
zpl_atts = cleaned_zpl_atts or zpl_atts
|
||||
# When the carrier returned ZPL but not PDF, render a PDF
|
||||
# rasterization via labelary so the Print PDF smart button has
|
||||
# something to open. One FedEx ship call → two smart buttons.
|
||||
# Best-effort: if labelary is unreachable, the ZPL button still
|
||||
# works and the operator can print from the Zebra directly.
|
||||
if zpl_atts and not pdf_atts:
|
||||
for zpl in zpl_atts:
|
||||
pdf_bytes = self._fp_zpl_to_pdf_via_labelary(
|
||||
base64.b64decode(zpl.datas) if zpl.datas else None
|
||||
)
|
||||
if not pdf_bytes:
|
||||
continue
|
||||
pdf_name = (zpl.name or 'label.zpl').rsplit('.', 1)[0] + '.pdf'
|
||||
new_pdf = Attachment.create({
|
||||
'name': pdf_name,
|
||||
'res_model': 'stock.picking',
|
||||
'res_id': picking.id,
|
||||
'datas': base64.b64encode(pdf_bytes),
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
pdf_atts |= new_pdf
|
||||
# Primary slot keeps backward-compat: prefer PDF for the main
|
||||
# button, fall back to whatever the carrier returned otherwise.
|
||||
primary_atts = pdf_atts or label_atts
|
||||
# Per-package shipping_data list — one entry per package.
|
||||
sd_list = shipping_data if isinstance(shipping_data, list) else [
|
||||
shipping_data
|
||||
@@ -608,23 +819,28 @@ class FpReceiving(models.Model):
|
||||
primary_tracking = per_pkg_trackings[0] if per_pkg_trackings else ''
|
||||
# Write per-row labels + tracking. Attachments are paired by
|
||||
# index — N labels and N rows. Excess on either side is ignored.
|
||||
# Use primary_atts (PDF-preferred) so the per-row "Label" link
|
||||
# opens a printable PDF, not raw ZPL.
|
||||
for idx, row in enumerate(rows):
|
||||
row_vals = {}
|
||||
if idx < len(per_pkg_trackings):
|
||||
row_vals['tracking_number'] = per_pkg_trackings[idx]
|
||||
if idx < len(label_atts):
|
||||
row_vals['label_attachment_id'] = label_atts[idx].id
|
||||
if idx < len(primary_atts):
|
||||
row_vals['label_attachment_id'] = primary_atts[idx].id
|
||||
if row_vals:
|
||||
row.sudo().write(row_vals)
|
||||
# Shipment-level fields. Primary label = first attachment; mirror
|
||||
# all labels onto x_fc_label_attachment_ids for the multi-print UX.
|
||||
# Shipment-level fields. Primary label = PDF (or first attachment
|
||||
# if carrier didn't return PDF); ZPL goes into its own slot so
|
||||
# the Print ZPL button can find it.
|
||||
vals = {'status': 'confirmed'}
|
||||
if primary_tracking:
|
||||
vals['tracking_number'] = primary_tracking
|
||||
if label_atts:
|
||||
vals['label_attachment_id'] = label_atts[0].id
|
||||
if primary_atts:
|
||||
vals['label_attachment_id'] = primary_atts[0].id
|
||||
if 'x_fc_label_attachment_ids' in ship._fields:
|
||||
vals['x_fc_label_attachment_ids'] = [(6, 0, label_atts.ids)]
|
||||
vals['x_fc_label_attachment_ids'] = [(6, 0, primary_atts.ids)]
|
||||
if zpl_atts and 'x_fc_label_zpl_attachment_id' in ship._fields:
|
||||
vals['x_fc_label_zpl_attachment_id'] = zpl_atts[0].id
|
||||
# Link the synthetic stock.picking so the Transfer field shows
|
||||
# it on the shipment form. Also refresh sender/recipient/carrier
|
||||
# defaults in case the operator changed carrier between create
|
||||
@@ -638,7 +854,7 @@ class FpReceiving(models.Model):
|
||||
ship.sudo().write(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound label generated. Tracking: <b>%s</b>'
|
||||
)) % (tracking_number or '(see attached PDF)'))
|
||||
)) % (primary_tracking or '(see attached PDF)'))
|
||||
# Validate the synthetic picking so it lands in 'done' state
|
||||
# instead of sitting at 'ready'. The shipping label is the proof
|
||||
# of dispatch — keeping the picking open misleads anyone looking
|
||||
@@ -676,12 +892,169 @@ class FpReceiving(models.Model):
|
||||
self.name, picking.name, e,
|
||||
)
|
||||
|
||||
def action_print_label(self):
|
||||
"""Open the label PDF for printing.
|
||||
def action_refresh_shipping_quote(self):
|
||||
"""Fetch a rate quote from FedEx for the current carrier +
|
||||
service + weight/dims and store it as HTML for the preview
|
||||
panel. Best-effort: any failure renders a friendly error
|
||||
message in the same panel instead of raising.
|
||||
|
||||
Returns the standard Odoo download action so the operator can
|
||||
print from their browser. Phase F replaces this with auto-print
|
||||
to a network printer.
|
||||
Only wired up for FedEx REST today; other carriers fall back
|
||||
to a "not supported" message. Add a branch here when wiring
|
||||
Canada Post / UPS rate quotes.
|
||||
"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
carrier = self.x_fc_carrier_id
|
||||
if not carrier:
|
||||
raise UserError(_('Pick an Outbound Carrier first.'))
|
||||
if not self.x_fc_weight or self.x_fc_weight <= 0:
|
||||
raise UserError(_('Enter a non-zero Weight first.'))
|
||||
so = self.sale_order_id
|
||||
if not so:
|
||||
raise UserError(_(
|
||||
'No sale order linked — cannot resolve sender / '
|
||||
'recipient addresses for the quote.'
|
||||
))
|
||||
if carrier.delivery_type != 'fusion_fedex_rest':
|
||||
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
|
||||
_('Rate quote is only wired up for FedEx REST '
|
||||
'right now. Carrier "%s" is not supported.') % (
|
||||
carrier.name,
|
||||
),
|
||||
is_error=True,
|
||||
)
|
||||
return
|
||||
result = self._fp_quote_fedex_rate(carrier, so)
|
||||
self.x_fc_shipping_quote_html = self._fp_format_shipping_quote(
|
||||
result
|
||||
)
|
||||
except UserError as exc:
|
||||
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
|
||||
str(exc), is_error=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Receiving %s: shipping quote failed: %s', self.name, exc,
|
||||
)
|
||||
self.x_fc_shipping_quote_html = self._fp_quote_html_msg(
|
||||
_('Quote failed: %s') % exc, is_error=True,
|
||||
)
|
||||
|
||||
def _fp_quote_fedex_rate(self, carrier, so):
|
||||
"""Call FedEx /rate/v1/rates/quotes with the receiving's
|
||||
current weight + service-type override. Returns the dict from
|
||||
FedexRestRequest._get_shipping_price (price, service_name,
|
||||
delivery_timestamp, etc.).
|
||||
"""
|
||||
# Lazy import — fusion_plating_receiving depends on
|
||||
# fusion_shipping but importing at module load order can race
|
||||
# with the registry. Inside-method keeps everything sane.
|
||||
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
|
||||
FedexRequest as FedexRestRequest,
|
||||
)
|
||||
srm = FedexRestRequest(carrier)
|
||||
if self.x_fc_outbound_service_type:
|
||||
srm.service_type = self.x_fc_outbound_service_type
|
||||
package_type = (
|
||||
carrier.fedex_rest_default_package_type_id.shipper_package_code
|
||||
or 'YOUR_PACKAGING'
|
||||
) if carrier.fedex_rest_default_package_type_id else 'YOUR_PACKAGING'
|
||||
pkg = SimpleNamespace(
|
||||
weight=self.x_fc_weight,
|
||||
dimension={
|
||||
'length': self.x_fc_length or 0,
|
||||
'width': self.x_fc_width or 0,
|
||||
'height': self.x_fc_height or 0,
|
||||
},
|
||||
packaging_type=package_type,
|
||||
total_cost=0,
|
||||
commodities=[],
|
||||
currency_id=so.currency_id,
|
||||
)
|
||||
ship_from = (
|
||||
so.warehouse_id.partner_id
|
||||
if so.warehouse_id else self.env.company.partner_id
|
||||
)
|
||||
return srm._get_shipping_price(
|
||||
ship_from=ship_from,
|
||||
ship_to=so.partner_shipping_id or so.partner_id,
|
||||
packages=[pkg],
|
||||
currency=so.currency_id.name,
|
||||
)
|
||||
|
||||
def _fp_format_shipping_quote(self, result):
|
||||
"""Render a rate-quote result dict as the HTML the form panel
|
||||
displays. Kept here so the styling decisions live next to the
|
||||
view that consumes them.
|
||||
"""
|
||||
price = result.get('price') or 0.0
|
||||
currency = result.get('currency') or ''
|
||||
service_name = result.get('service_name') or ''
|
||||
service_code = result.get('service_type') or ''
|
||||
delivery = result.get('delivery_timestamp') or ''
|
||||
day_of_week = result.get('day_of_week') or ''
|
||||
transit = result.get('transit_time') or ''
|
||||
# Trim the FedEx ISO timestamp to "YYYY-MM-DD HH:MM" if present.
|
||||
if delivery and 'T' in delivery:
|
||||
delivery = delivery.replace('T', ' ')[:16]
|
||||
eta_line = ''
|
||||
if delivery or day_of_week:
|
||||
eta_line = '<div><strong>Estimated delivery:</strong> %s%s</div>' % (
|
||||
delivery or '(date not provided)',
|
||||
' (%s)' % day_of_week.title() if day_of_week else '',
|
||||
)
|
||||
transit_line = (
|
||||
'<div><strong>Transit:</strong> %s</div>'
|
||||
% transit.replace('_', ' ').title()
|
||||
) if transit else ''
|
||||
# Colours come from fp_shipping_quote.scss (theme-aware). Only
|
||||
# structural styling lives inline (sizes, weights, spacing).
|
||||
return (
|
||||
'<div class="fp_shipping_quote_body" style="font-size: 14px;">'
|
||||
'<div style="font-size: 22px; font-weight: 700; margin-bottom: 8px;">'
|
||||
'%(currency)s %(price).2f'
|
||||
'</div>'
|
||||
'<div style="margin-bottom: 4px;"><strong>%(service)s</strong>'
|
||||
'%(code)s</div>'
|
||||
'%(eta)s'
|
||||
'%(transit)s'
|
||||
'<div class="fp_shipping_quote_footnote" '
|
||||
'style="font-size: 11px; opacity: 0.65; margin-top: 10px;">'
|
||||
'Quote is an estimate from FedEx — final charges may differ.'
|
||||
'</div>'
|
||||
'</div>'
|
||||
) % {
|
||||
'currency': currency,
|
||||
'price': price,
|
||||
'service': service_name or service_code or 'Carrier service',
|
||||
'code': ' <span style="opacity:0.65;">(%s)</span>' % service_code if (
|
||||
service_name and service_code and service_name != service_code
|
||||
) else '',
|
||||
'eta': eta_line,
|
||||
'transit': transit_line,
|
||||
}
|
||||
|
||||
def _fp_quote_html_msg(self, msg, is_error=False):
|
||||
"""Wrap a one-line message in the same styling as the quote
|
||||
panel so the right-side column doesn't flicker between layouts.
|
||||
Colour comes from the wrapper class (theme-aware); errors use
|
||||
the Bootstrap danger semantic so dark/light both look right.
|
||||
"""
|
||||
klass = 'text-danger' if is_error else ''
|
||||
icon = 'fa-exclamation-triangle' if is_error else 'fa-info-circle'
|
||||
return (
|
||||
'<div class="%s" style="font-size: 14px;">'
|
||||
'<i class="fa %s me-2"/>%s'
|
||||
'</div>'
|
||||
) % (klass, icon, msg)
|
||||
|
||||
def action_print_label(self):
|
||||
"""Open the primary (PDF) label in the fusion_pdf_preview dialog.
|
||||
|
||||
Delegates to fusion.shipment._action_open_attachment, which
|
||||
routes PDFs through the preview client action and falls back
|
||||
to a new-tab URL when fusion_pdf_preview isn't installed. See
|
||||
CLAUDE.md "PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
@@ -690,11 +1063,64 @@ class FpReceiving(models.Model):
|
||||
'No outbound shipping label on this receiving. '
|
||||
'Generate the label first.'
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%d?download=true' % ship.label_attachment_id.id,
|
||||
'target': 'new',
|
||||
}
|
||||
return ship._action_open_attachment(ship.label_attachment_id)
|
||||
|
||||
def _fp_zpl_to_pdf_via_labelary(self, zpl_bytes):
|
||||
"""POST raw ZPL to labelary and return the rendered PDF bytes.
|
||||
|
||||
Returns None on any failure — caller treats labelary as a
|
||||
best-effort enhancement, never a blocker for label generation.
|
||||
See CLAUDE.md "labelary.com dependency" for privacy + ratelimit
|
||||
notes.
|
||||
"""
|
||||
if not zpl_bytes:
|
||||
return None
|
||||
try:
|
||||
res = requests.post(
|
||||
_LABELARY_URL,
|
||||
data=zpl_bytes,
|
||||
headers={
|
||||
'Accept': 'application/pdf',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
timeout=_LABELARY_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
_logger.warning(
|
||||
'Receiving %s: labelary ZPL→PDF request failed: %s',
|
||||
self.name, exc,
|
||||
)
|
||||
return None
|
||||
if not res.ok:
|
||||
_logger.warning(
|
||||
'Receiving %s: labelary returned %s — %s',
|
||||
self.name, res.status_code, res.text[:200],
|
||||
)
|
||||
return None
|
||||
return res.content
|
||||
|
||||
def action_print_label_zpl(self):
|
||||
"""Open the ZPL/ZPLII label for direct-to-thermal-printer use.
|
||||
|
||||
Visibility on the form is gated by x_fc_has_label_zpl so this
|
||||
only appears when a ZPL attachment is actually present — i.e.
|
||||
the carrier returned ZPL on Generate, or a ZPL fetch was added
|
||||
later. When no ZPL exists, the operator should use the PDF
|
||||
button instead (PDF prints on any printer).
|
||||
"""
|
||||
self.ensure_one()
|
||||
ship = self.x_fc_outbound_shipment_id
|
||||
if not ship or not ship.x_fc_label_zpl_attachment_id:
|
||||
raise UserError(_(
|
||||
'No ZPL label on this shipment. Use the PDF version, '
|
||||
'or switch the FedEx carrier label format to ZPLII and '
|
||||
'regenerate.'
|
||||
))
|
||||
return ship.x_fc_label_zpl_attachment_id.action_fusion_preview(
|
||||
title=ship.x_fc_label_zpl_attachment_id.name or 'ZPL Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines')
|
||||
@@ -804,6 +1230,47 @@ class FpReceiving(models.Model):
|
||||
rec._update_so_receiving_status()
|
||||
rec.message_post(body=_('Receiving closed.'))
|
||||
|
||||
def action_reset_to_counted(self):
|
||||
"""Reset a Closed receiving back to Counted.
|
||||
|
||||
Recovery escape hatch for when receiving was closed prematurely.
|
||||
Blocked once downstream work has begun — operator must cancel
|
||||
every fp.job spawned from this SO and avoid touching any step
|
||||
before the rewind is allowed. Without the gate it's trivial to
|
||||
rewind a receiving while jobs are mid-flight, which silently
|
||||
breaks the qty_received feed and the cert mark-done gate.
|
||||
"""
|
||||
Job = self.env.get('fp.job')
|
||||
for rec in self:
|
||||
if rec.state != 'closed':
|
||||
raise UserError(_('Only Closed receivings can be reset.'))
|
||||
if Job is not None and rec.sale_order_id:
|
||||
jobs = Job.sudo().search([
|
||||
('sale_order_id', '=', rec.sale_order_id.id),
|
||||
])
|
||||
if jobs:
|
||||
started = jobs.step_ids.filtered(
|
||||
lambda s: s.state in (
|
||||
'in_progress', 'paused', 'done', 'skipped',
|
||||
)
|
||||
)
|
||||
if started:
|
||||
raise UserError(_(
|
||||
'Cannot reset — %d step(s) on this order have '
|
||||
'been started. Reset is only allowed before '
|
||||
'work begins.'
|
||||
) % len(started))
|
||||
active = jobs.filtered(lambda j: j.state != 'cancelled')
|
||||
if active:
|
||||
raise UserError(_(
|
||||
'Cannot reset — %d work order(s) on this sale '
|
||||
'order are not cancelled. Cancel them first, '
|
||||
'then retry.'
|
||||
) % len(active))
|
||||
rec.state = 'counted'
|
||||
rec._update_so_receiving_status()
|
||||
rec.message_post(body=_('Receiving reset to Counted.'))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Legacy state actions — kept for backward compatibility.
|
||||
# Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection.
|
||||
|
||||
@@ -10,7 +10,8 @@ here (not in fusion_shipping) to keep the upstream module untouched.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +42,42 @@ class FusionShipment(models.Model):
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Separate slot for the ZPL version of the label. FedEx (and most
|
||||
# carriers) return one format per ship-call; the primary
|
||||
# label_attachment_id holds whatever the carrier was configured to
|
||||
# return (we default to PDF). This field is populated only when a
|
||||
# ZPL variant has been fetched explicitly. Two slots = two smart
|
||||
# buttons on the receiving form, one per format.
|
||||
x_fc_label_zpl_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='ZPL Label',
|
||||
copy=False,
|
||||
help='ZPL/ZPLII version of the shipping label. Empty unless '
|
||||
'the carrier returned ZPL (or a ZPL fetch was triggered '
|
||||
'separately).',
|
||||
)
|
||||
|
||||
def action_view_label_zpl(self):
|
||||
"""Download the ZPL label for direct-to-thermal-printer use.
|
||||
|
||||
ZPL is text/plain — the PDF preview dialog can't render it, so
|
||||
this stays on the legacy download path (no preview, just a file
|
||||
the operator sends to their Zebra). Mirrors fp.receiving's
|
||||
action_print_label_zpl so the button exists on both forms.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_label_zpl_attachment_id:
|
||||
raise UserError(_(
|
||||
'No ZPL label on this shipment. Use the PDF version, '
|
||||
'or switch the carrier label format to ZPLII and '
|
||||
'regenerate.'
|
||||
))
|
||||
return self.x_fc_label_zpl_attachment_id.action_fusion_preview(
|
||||
title=self.x_fc_label_zpl_attachment_id.name or 'ZPL Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
|
||||
# Phase C — resolved carrier tracking URL with the tracking number
|
||||
# substituted into the carrier.tracking_url template. Used by the
|
||||
# shipment_labeled email template and any other place that needs a
|
||||
|
||||
@@ -17,6 +17,9 @@ access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,mod
|
||||
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,group_fp_receiving,1,1,1,1
|
||||
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_label_generate_wizard_receiver,fp.label.generate.wizard.receiver,model_fp_label_generate_wizard,group_fp_receiving,1,1,1,1
|
||||
access_fp_label_generate_wizard_supervisor,fp.label.generate.wizard.supervisor,model_fp_label_generate_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_fp_label_generate_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,group_fp_receiving,1,1,1,1
|
||||
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,78 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shipping-Quote Callout Panel
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Yellow-tinted info panel rendered on the right side of the receiving
|
||||
// form. Branches on Odoo's compile-time $o-webclient-color-scheme so it
|
||||
// produces readable contrast in BOTH light (warm cornsilk) and dark
|
||||
// (deep amber) bundles. See CLAUDE.md "Dark Mode" for why we branch at
|
||||
// compile time instead of using a runtime class selector — Odoo 19
|
||||
// serves two pre-compiled bundles, no .o_dark_mode toggle fires.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// Light (cornsilk on white page)
|
||||
$_fp-quote-bg-hex : #fff8dc;
|
||||
$_fp-quote-border-hex : #e6d28f;
|
||||
$_fp-quote-label-hex : #7a5b00;
|
||||
$_fp-quote-body-hex : #1f2937;
|
||||
$_fp-quote-muted-hex : #6b6452;
|
||||
|
||||
// Dark (warm-amber tint that still reads against $fp-page)
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-quote-bg-hex : #3a2f10 !global;
|
||||
$_fp-quote-border-hex : #5a4820 !global;
|
||||
$_fp-quote-label-hex : #ffd866 !global;
|
||||
$_fp-quote-body-hex : #e5e7eb !global;
|
||||
$_fp-quote-muted-hex : #b8a877 !global;
|
||||
}
|
||||
|
||||
// CSS custom-property fallback chain so a deployment can override
|
||||
// without touching SCSS.
|
||||
$fp-quote-bg : var(--fp-quote-bg, $_fp-quote-bg-hex);
|
||||
$fp-quote-border : var(--fp-quote-border, $_fp-quote-border-hex);
|
||||
$fp-quote-label : var(--fp-quote-label, $_fp-quote-label-hex);
|
||||
$fp-quote-body : var(--fp-quote-body, $_fp-quote-body-hex);
|
||||
$fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
|
||||
|
||||
|
||||
.fp_shipping_quote_callout {
|
||||
background-color: $fp-quote-bg;
|
||||
border: 1px solid $fp-quote-border;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
color: $fp-quote-body;
|
||||
|
||||
.fp_shipping_quote_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.fp_shipping_quote_label {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 700;
|
||||
color: $fp-quote-label;
|
||||
|
||||
i.fa {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quote body — the HTML field rendered by _fp_format_shipping_quote.
|
||||
// Inherits the body colour from the wrapper; the .text-muted-style
|
||||
// small-print uses our muted token.
|
||||
.o_field_html,
|
||||
.o_field_html * {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fp_shipping_quote_placeholder {
|
||||
color: $fp-quote-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,16 @@
|
||||
class="btn-primary"
|
||||
invisible="state not in ('draft', 'inspecting')"/>
|
||||
<button name="action_close"
|
||||
string="Close — Racking Confirmed"
|
||||
string="Close Receiving"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state not in ('counted', 'staged', 'accepted', 'resolved')"/>
|
||||
<button name="action_reset_to_counted"
|
||||
string="Reset to Counted"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
confirm="Reset this receiving back to Counted? Use only if no work has started on the order."
|
||||
invisible="state != 'closed'"/>
|
||||
<!-- Legacy actions (hidden by default; surfaces for old records) -->
|
||||
<button name="action_accept"
|
||||
string="Accept (legacy)"
|
||||
@@ -77,7 +83,7 @@
|
||||
string="Generate Outbound Label"
|
||||
class="btn-primary"
|
||||
icon="fa-print"
|
||||
invisible="not x_fc_carrier_id or not x_fc_weight"/>
|
||||
invisible="not x_fc_carrier_id or not x_fc_weight or x_fc_has_label"/>
|
||||
<button name="action_print_label"
|
||||
type="object"
|
||||
string="Print Label"
|
||||
@@ -108,6 +114,17 @@
|
||||
</div>
|
||||
<field name="x_fc_has_label" invisible="1"/>
|
||||
</button>
|
||||
<button name="action_print_label_zpl"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-barcode"
|
||||
invisible="not x_fc_has_label_zpl">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">ZPL</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
<field name="x_fc_has_label_zpl" invisible="1"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
@@ -139,6 +156,9 @@
|
||||
<field name="received_date"/>
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_outbound_service_type"
|
||||
invisible="not x_fc_carrier_id"
|
||||
placeholder="Carrier default"/>
|
||||
<field name="carrier_tracking"/>
|
||||
<!--
|
||||
Legacy carrier_name (Char) is retained
|
||||
@@ -154,6 +174,35 @@
|
||||
-->
|
||||
<field name="carrier_name" invisible="1"/>
|
||||
</group>
|
||||
<!-- Shipping-quote preview. The .fp_shipping_quote_callout
|
||||
class in fp_shipping_quote.scss handles
|
||||
colour for both light + dark bundles —
|
||||
yellow tint that flips to deep amber on
|
||||
dark theme. Structure-only styling stays
|
||||
inline; semantic colour lives in SCSS. -->
|
||||
<div invisible="not x_fc_carrier_id"
|
||||
class="fp_shipping_quote_callout">
|
||||
<div class="fp_shipping_quote_header">
|
||||
<strong class="fp_shipping_quote_label">
|
||||
<i class="fa fa-truck"/>Shipping Quote
|
||||
</strong>
|
||||
<button name="action_refresh_shipping_quote"
|
||||
type="object"
|
||||
string="Refresh Quote"
|
||||
class="btn btn-sm btn-warning"
|
||||
icon="fa-refresh"/>
|
||||
</div>
|
||||
<field name="x_fc_shipping_quote_html"
|
||||
nolabel="1" readonly="1"
|
||||
widget="html"/>
|
||||
<div invisible="x_fc_shipping_quote_html"
|
||||
class="fp_shipping_quote_placeholder">
|
||||
Click <strong>Refresh Quote</strong> to
|
||||
fetch the price and estimated delivery
|
||||
date for the current carrier + service
|
||||
+ weight.
|
||||
</div>
|
||||
</div>
|
||||
</group>
|
||||
<group string="Outbound Packaging"
|
||||
invisible="not x_fc_carrier_id">
|
||||
|
||||
@@ -27,6 +27,22 @@
|
||||
</list>
|
||||
</field>
|
||||
</xpath>
|
||||
|
||||
<!-- Mirror the receiving form's two-button layout: the
|
||||
existing "Print Label PDF" smart button (rendered by
|
||||
fusion_shipping) handles the primary PDF; this adds a
|
||||
sibling ZPL button only when a ZPL attachment exists. -->
|
||||
<xpath expr="//div[@name='button_box']/button[@name='action_view_label']" position="after">
|
||||
<button name="action_view_label_zpl" type="object"
|
||||
class="oe_stat_button" icon="fa-barcode"
|
||||
invisible="not x_fc_label_zpl_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">ZPL</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
<field name="x_fc_label_zpl_attachment_id" invisible="1"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_label_manual_wizard
|
||||
from . import fp_label_generate_wizard
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Service-type confirmation wizard.
|
||||
|
||||
Opens when fp.receiving.action_generate_outbound_label fires (assuming
|
||||
a label hasn't already been generated). Forces the operator to look at
|
||||
carrier + service tier + weight before the API call is made — cheaper
|
||||
shipping decisions, no surprise charges, and a hard gate against the
|
||||
accidental-double-click bug where every Generate click leaked a fresh
|
||||
FedEx shipment + tracking number.
|
||||
|
||||
Picked service rides through to the FedEx API via the
|
||||
`fp_service_type_override` context key (see CLAUDE.md).
|
||||
"""
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpLabelGenerateWizard(models.TransientModel):
|
||||
_name = 'fp.label.generate.wizard'
|
||||
_description = 'Fusion Plating — Confirm Label Generation'
|
||||
|
||||
receiving_id = fields.Many2one(
|
||||
'fp.receiving', required=True, readonly=True, ondelete='cascade',
|
||||
)
|
||||
receiving_name = fields.Char(related='receiving_id.name', readonly=True)
|
||||
carrier_id = fields.Many2one(
|
||||
related='receiving_id.x_fc_carrier_id', readonly=True,
|
||||
)
|
||||
carrier_default_service = fields.Char(
|
||||
compute='_compute_carrier_default_service',
|
||||
string='Carrier Default',
|
||||
help='What the carrier would use if no override is set.',
|
||||
)
|
||||
service_type = fields.Selection(
|
||||
selection='_fp_get_service_type_selection',
|
||||
string='Service Type',
|
||||
help='Leave blank to use the carrier default shown above. Pick '
|
||||
'a faster tier (Priority Overnight, 2Day, etc.) when the '
|
||||
'customer is paying for expedited delivery.',
|
||||
)
|
||||
weight = fields.Float(string='Weight', digits=(10, 3), required=True)
|
||||
weight_uom = fields.Selection(
|
||||
related='receiving_id.x_fc_weight_uom', readonly=True,
|
||||
)
|
||||
length = fields.Float(string='Length', digits=(10, 2))
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
dim_uom = fields.Selection(
|
||||
related='receiving_id.x_fc_dim_uom', readonly=True,
|
||||
)
|
||||
|
||||
def _fp_get_service_type_selection(self):
|
||||
# Single source of truth — pulls the curated list from
|
||||
# fp.receiving so both the form dropdown and the wizard stay
|
||||
# in sync. See fp.receiving._FP_USABLE_FEDEX_SERVICES.
|
||||
Receiving = self.env.get('fp.receiving')
|
||||
if Receiving is None:
|
||||
return []
|
||||
return Receiving._fp_get_service_type_selection()
|
||||
|
||||
def _compute_carrier_default_service(self):
|
||||
for wiz in self:
|
||||
carrier = wiz.carrier_id
|
||||
if not carrier or 'fedex_rest_service_type' not in carrier._fields:
|
||||
wiz.carrier_default_service = ''
|
||||
continue
|
||||
code = carrier.fedex_rest_service_type or ''
|
||||
label = dict(
|
||||
carrier._fields['fedex_rest_service_type'].selection
|
||||
).get(code, code)
|
||||
wiz.carrier_default_service = label or _('(none set)')
|
||||
|
||||
@classmethod
|
||||
def _fp_default_from_receiving(cls, env, rec):
|
||||
"""Build the wizard create-vals from a receiving record."""
|
||||
return {
|
||||
'receiving_id': rec.id,
|
||||
'service_type': rec.x_fc_outbound_service_type or False,
|
||||
'weight': rec.x_fc_weight or 0.0,
|
||||
'length': rec.x_fc_length or 0.0,
|
||||
'width': rec.x_fc_width or 0.0,
|
||||
'height': rec.x_fc_height or 0.0,
|
||||
}
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply the operator's choices back to the receiving, then
|
||||
delegate to fp.receiving._fp_actually_generate_outbound_label
|
||||
which makes the API call. Wizard closes; result action is the
|
||||
outbound shipment view (or the manual-fallback wizard on error).
|
||||
"""
|
||||
self.ensure_one()
|
||||
rec = self.receiving_id
|
||||
if not rec:
|
||||
raise UserError(_('Wizard is detached from the receiving.'))
|
||||
if not self.weight or self.weight <= 0:
|
||||
raise UserError(_('Enter a non-zero weight before generating.'))
|
||||
rec.write({
|
||||
'x_fc_outbound_service_type': self.service_type or False,
|
||||
'x_fc_weight': self.weight,
|
||||
'x_fc_length': self.length,
|
||||
'x_fc_width': self.width,
|
||||
'x_fc_height': self.height,
|
||||
})
|
||||
return rec._fp_actually_generate_outbound_label()
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Service-type confirmation wizard. Pops up before every
|
||||
"Generate Outbound Label" so the operator can pick the FedEx
|
||||
service tier (or accept the carrier default) and double-check
|
||||
weight/dimensions before any API call is made.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_label_generate_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.label.generate.wizard.form</field>
|
||||
<field name="model">fp.label.generate.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate Outbound Label">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>Generate Label —
|
||||
<field name="receiving_name"
|
||||
readonly="1" nolabel="1" class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
Confirm the service tier and package details before
|
||||
the carrier API is called. Each generation creates
|
||||
a billable shipment.
|
||||
</div>
|
||||
<group>
|
||||
<group string="Carrier">
|
||||
<field name="carrier_id" readonly="1"/>
|
||||
<field name="carrier_default_service"
|
||||
readonly="1"/>
|
||||
<field name="service_type"
|
||||
placeholder="Use carrier default"/>
|
||||
</group>
|
||||
<group string="Package">
|
||||
<label for="weight"/>
|
||||
<div class="o_row">
|
||||
<field name="weight" class="oe_inline"/>
|
||||
<field name="weight_uom" nolabel="1"
|
||||
readonly="1" class="oe_inline"/>
|
||||
</div>
|
||||
<label for="length" string="Dimensions (L×W×H)"/>
|
||||
<div class="o_row">
|
||||
<field name="length" class="oe_inline"/>
|
||||
<span>×</span>
|
||||
<field name="width" class="oe_inline"/>
|
||||
<span>×</span>
|
||||
<field name="height" class="oe_inline"/>
|
||||
<field name="dim_uom" nolabel="1"
|
||||
readonly="1" class="oe_inline"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Generate Label" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.15.0',
|
||||
'version': '19.0.11.26.7',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
fusion_plating/scripts/fp_fedex_service_matrix.py
Normal file
135
fusion_plating/scripts/fp_fedex_service_matrix.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One-off rate-quote sweep across every FedEx service code.
|
||||
|
||||
Loops the full carrier selection (~38 services) against two routes —
|
||||
CA domestic (matching SO-30045) and CA → US — to figure out which
|
||||
services are valid for the shipping lanes EN Technologies actually
|
||||
uses. Prints a CSV-ish matrix to stdout so the report can be pasted
|
||||
straight into chat.
|
||||
|
||||
Run with:
|
||||
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
|
||||
FedexRequest as FedexRestRequest,
|
||||
)
|
||||
|
||||
CARRIER_ID = 17 # FedEx REST carrier on entech
|
||||
WEIGHT_LB = 5.0
|
||||
DIMS = {'length': 12, 'width': 10, 'height': 6}
|
||||
|
||||
carrier = env['delivery.carrier'].browse(CARRIER_ID)
|
||||
assert carrier.exists(), 'FedEx carrier id=17 not found on this DB.'
|
||||
|
||||
service_codes = [code for code, _label in carrier._fields[
|
||||
'fedex_rest_service_type'
|
||||
].selection]
|
||||
|
||||
# CA Toronto sender (from company address)
|
||||
sender = SimpleNamespace(
|
||||
street='36 Taber Road', street2=False,
|
||||
city='Toronto', zip='M9W3A8',
|
||||
state_id=env['res.country.state'].search(
|
||||
[('code', '=', 'ON'), ('country_id.code', '=', 'CA')], limit=1,
|
||||
),
|
||||
country_id=env['res.country'].search([('code', '=', 'CA')], limit=1),
|
||||
name='ENTECH', phone='4167492400', email='ship@entech.test',
|
||||
commercial_partner_id=None, parent_id=None, vat=False,
|
||||
is_company=True,
|
||||
)
|
||||
|
||||
# Route A — CA domestic (Niagara Falls, ON)
|
||||
ca_recipient = env['res.partner'].search([
|
||||
('city', 'ilike', 'Niagara Falls'), ('country_id.code', '=', 'CA'),
|
||||
], limit=1)
|
||||
assert ca_recipient.exists(), 'No CA partner found for the domestic route.'
|
||||
|
||||
# Route B — CA → US (a real US partner with a complete address)
|
||||
us_recipient = env['res.partner'].search([
|
||||
('country_id.code', '=', 'US'), ('city', '!=', False),
|
||||
('zip', '!=', False), ('state_id', '!=', False),
|
||||
], limit=1)
|
||||
if not us_recipient:
|
||||
# Fabricate one in memory (we won't write to DB).
|
||||
us_recipient = env['res.partner'].new({
|
||||
'name': 'Test US Customer',
|
||||
'street': '1 World Trade Center',
|
||||
'city': 'New York',
|
||||
'zip': '10007',
|
||||
'state_id': env['res.country.state'].search(
|
||||
[('code', '=', 'NY'), ('country_id.code', '=', 'US')], limit=1,
|
||||
).id,
|
||||
'country_id': env['res.country'].search(
|
||||
[('code', '=', 'US')], limit=1,
|
||||
).id,
|
||||
'phone': '2125551212',
|
||||
'email': 'us@test.com',
|
||||
})
|
||||
|
||||
# Sender partner — use the company partner for proper address resolution.
|
||||
sender_partner = env.company.partner_id
|
||||
|
||||
|
||||
def quote(service_code, recipient):
|
||||
srm = FedexRestRequest(carrier)
|
||||
srm.service_type = service_code
|
||||
pkg = SimpleNamespace(
|
||||
weight=WEIGHT_LB,
|
||||
dimension=DIMS,
|
||||
packaging_type='YOUR_PACKAGING',
|
||||
total_cost=0, commodities=[], currency_id=env.ref('base.CAD'),
|
||||
)
|
||||
try:
|
||||
res = srm._get_shipping_price(
|
||||
ship_from=sender_partner,
|
||||
ship_to=recipient,
|
||||
packages=[pkg],
|
||||
currency='CAD',
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'price': res.get('price'),
|
||||
'currency': res.get('currency'),
|
||||
'service_name': res.get('service_name', '').strip(),
|
||||
'delivery': (res.get('delivery_timestamp') or '')[:16].replace(
|
||||
'T', ' ',
|
||||
),
|
||||
'transit': res.get('transit_time', ''),
|
||||
'error': '',
|
||||
}
|
||||
except Exception as exc:
|
||||
msg = str(exc).replace('\n', ' ').strip()
|
||||
# Trim Odoo's "Error from FedEx: " prefix if present.
|
||||
return {
|
||||
'ok': False, 'price': 0, 'currency': '',
|
||||
'service_name': '', 'delivery': '', 'transit': '',
|
||||
'error': msg[:140],
|
||||
}
|
||||
|
||||
|
||||
def emit_row(route, code, label, result):
|
||||
print('|{route}|{code}|{label}|{ok}|{price}|{cur}|{eta}|{transit}|{err}|'.format(
|
||||
route=route,
|
||||
code=code,
|
||||
label=label[:50],
|
||||
ok='OK' if result['ok'] else 'FAIL',
|
||||
price=('%.2f' % result['price']) if result['ok'] else '',
|
||||
cur=result['currency'],
|
||||
eta=result['delivery'],
|
||||
transit=result['transit'],
|
||||
err=result['error'],
|
||||
))
|
||||
|
||||
|
||||
print('|Route|ServiceCode|Label|Status|Price|Cur|DeliveryETA|Transit|Error|')
|
||||
print('|---|---|---|---|---|---|---|---|---|')
|
||||
|
||||
label_map = dict(carrier._fields['fedex_rest_service_type'].selection)
|
||||
for code in service_codes:
|
||||
label = label_map.get(code, code)
|
||||
emit_row('CA->CA', code, label, quote(code, ca_recipient))
|
||||
emit_row('CA->US', code, label, quote(code, us_recipient))
|
||||
|
||||
print('DONE')
|
||||
56
fusion_plating/scripts/fp_reset_cert_30045.py
Normal file
56
fusion_plating/scripts/fp_reset_cert_30045.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One-off: reset CoC-30045 to draft for re-issue testing.
|
||||
|
||||
Clears all thickness data + attachments so the operator can re-run
|
||||
the Issue Certs wizard from a clean slate (upload RTF + PNG image,
|
||||
verify the full inline-render flow).
|
||||
|
||||
Run with:
|
||||
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
|
||||
"""
|
||||
cert = env['fp.certificate'].browse(501)
|
||||
print('before: state=%s, readings=%d, attachment_id=%s, evidence=%s, image=%s' % (
|
||||
cert.state,
|
||||
len(cert.thickness_reading_ids),
|
||||
cert.attachment_id.id if cert.attachment_id else None,
|
||||
cert.x_fc_local_thickness_evidence_id.id if cert.x_fc_local_thickness_evidence_id else None,
|
||||
cert.x_fc_thickness_image_id.id if cert.x_fc_thickness_image_id else None,
|
||||
))
|
||||
|
||||
# Drop all readings
|
||||
n_readings = len(cert.thickness_reading_ids)
|
||||
cert.thickness_reading_ids.unlink()
|
||||
|
||||
# Delete any attachments on the cert (RTF, regenerated PDF, image)
|
||||
atts = env['ir.attachment'].search([
|
||||
('res_model', '=', 'fp.certificate'),
|
||||
('res_id', '=', cert.id),
|
||||
])
|
||||
n_atts = len(atts)
|
||||
atts.unlink()
|
||||
|
||||
# Wipe cert-level thickness metadata + state-affecting fields
|
||||
cert.write({
|
||||
'state': 'draft',
|
||||
'attachment_id': False,
|
||||
'x_fc_local_thickness_pdf': False,
|
||||
'x_fc_local_thickness_pdf_filename': False,
|
||||
'x_fc_local_thickness_evidence_id': False,
|
||||
'x_fc_thickness_image_id': False,
|
||||
'x_fc_thickness_operator': False,
|
||||
'x_fc_thickness_product': False,
|
||||
'x_fc_thickness_application': False,
|
||||
'x_fc_thickness_directory': False,
|
||||
'x_fc_thickness_equipment': False,
|
||||
'x_fc_thickness_datetime': False,
|
||||
'x_fc_thickness_measuring_time_sec': 0,
|
||||
'x_fc_thickness_source_filename': False,
|
||||
})
|
||||
|
||||
env.cr.commit()
|
||||
cert.invalidate_recordset()
|
||||
print('after: state=%s, readings=%d, attachments_removed=%d, prior_readings=%d' % (
|
||||
cert.state, len(cert.thickness_reading_ids), n_atts, n_readings,
|
||||
))
|
||||
print('CoC-30045 ready for re-issue. Open the Issue Certs wizard '
|
||||
'from WO-30045, upload the RTF + PNG image, click Confirm & Issue.')
|
||||
52
fusion_plating/scripts/fp_retro_image_30045.py
Normal file
52
fusion_plating/scripts/fp_retro_image_30045.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Retro-extract WMF image from CoC-30045's attached RTF, attach as
|
||||
PNG, link to x_fc_thickness_image_id, regenerate cert PDF.
|
||||
|
||||
Run with:
|
||||
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
|
||||
"""
|
||||
import base64
|
||||
|
||||
from odoo.addons.fusion_plating_jobs.wizards.fp_cert_issue_wizard import (
|
||||
_fp_extract_rtf_images,
|
||||
_fp_pick_microscope_image,
|
||||
)
|
||||
|
||||
cert = env['fp.certificate'].browse(501)
|
||||
att = env['ir.attachment'].search([
|
||||
('res_model', '=', 'fp.certificate'),
|
||||
('res_id', '=', 501),
|
||||
('name', 'ilike', 'XRF'),
|
||||
], limit=1)
|
||||
|
||||
raw = base64.b64decode(att.datas)
|
||||
pngs = _fp_extract_rtf_images(raw)
|
||||
print('extracted PNG blocks:', len(pngs),
|
||||
'sizes:', [len(p) for p in pngs])
|
||||
|
||||
img_bytes, w, h = _fp_pick_microscope_image(pngs)
|
||||
if not img_bytes:
|
||||
print('no microscope image found (all blocks below area threshold)')
|
||||
else:
|
||||
print('picked microscope image: %dx%d, %d bytes' % (w, h, len(img_bytes)))
|
||||
img_att = env['ir.attachment'].sudo().create({
|
||||
'name': 'CoC-30045-microscope.png',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(img_bytes),
|
||||
'mimetype': 'image/png',
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({'x_fc_thickness_image_id': img_att.id})
|
||||
print('attached as ir.attachment id=%d' % img_att.id)
|
||||
|
||||
# Regenerate the cert PDF so the layout includes the image.
|
||||
if cert.attachment_id:
|
||||
cert.attachment_id.unlink()
|
||||
cert.invalidate_recordset()
|
||||
new_att = cert._fp_render_and_attach_pdf()
|
||||
env.cr.commit()
|
||||
print('regen done · cert PDF=%s · size=%d bytes' % (
|
||||
new_att.name if new_att else 'NONE',
|
||||
new_att.file_size if new_att else 0,
|
||||
))
|
||||
77
fusion_plating/scripts/fp_retro_thickness_30045.py
Normal file
77
fusion_plating/scripts/fp_retro_thickness_30045.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One-off: re-parse RTF on CoC-30045 + populate new metadata fields
|
||||
+ regenerate the cert PDF. Run on entech after deploying the parser
|
||||
extensions.
|
||||
|
||||
Run with:
|
||||
odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < this_file
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.addons.fusion_plating_jobs.wizards.fp_cert_issue_wizard import (
|
||||
_fp_parse_fischerscope_rtf,
|
||||
)
|
||||
|
||||
cert = env['fp.certificate'].browse(501)
|
||||
att = env['ir.attachment'].search([
|
||||
('res_model', '=', 'fp.certificate'),
|
||||
('res_id', '=', 501),
|
||||
('name', 'ilike', 'XRF'),
|
||||
], limit=1)
|
||||
|
||||
raw = base64.b64decode(att.datas)
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
print('parsed metadata:', {
|
||||
k: parsed[k] for k in (
|
||||
'operator', 'product', 'application', 'directory',
|
||||
'equipment', 'measuring_time_sec', 'date_str', 'time_str',
|
||||
'calibration',
|
||||
)
|
||||
})
|
||||
|
||||
vals = {
|
||||
'x_fc_thickness_operator': parsed['operator'],
|
||||
'x_fc_thickness_product': parsed['product'],
|
||||
'x_fc_thickness_application': parsed['application'],
|
||||
'x_fc_thickness_directory': parsed['directory'],
|
||||
'x_fc_thickness_equipment': parsed['equipment'] or 'Fischerscope XDAL 600',
|
||||
'x_fc_thickness_measuring_time_sec': parsed['measuring_time_sec'] or 0,
|
||||
'x_fc_thickness_source_filename': att.name,
|
||||
}
|
||||
|
||||
date_str = (parsed.get('date_str') or '').strip()
|
||||
time_str = (parsed.get('time_str') or '').strip()
|
||||
if date_str:
|
||||
combined = ('%s %s' % (date_str, time_str)).strip()
|
||||
for fmt in (
|
||||
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
|
||||
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
|
||||
'%m/%d/%Y',
|
||||
):
|
||||
try:
|
||||
vals['x_fc_thickness_datetime'] = datetime.strptime(combined, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
cert.write(vals)
|
||||
print('wrote vals:', list(vals.keys()))
|
||||
|
||||
# Backfill calibration on existing readings (created earlier).
|
||||
calib = parsed.get('calibration') or ''
|
||||
if calib:
|
||||
cert.thickness_reading_ids.write({'calibration_std_ref': calib})
|
||||
print('backfilled calibration on %d readings' % len(cert.thickness_reading_ids))
|
||||
|
||||
# Regenerate the cert PDF so the new layout takes effect.
|
||||
if cert.attachment_id:
|
||||
cert.attachment_id.unlink()
|
||||
cert.invalidate_recordset()
|
||||
new_att = cert._fp_render_and_attach_pdf()
|
||||
env.cr.commit()
|
||||
print('done · readings=%d · new PDF=%s · size=%d bytes' % (
|
||||
len(cert.thickness_reading_ids),
|
||||
new_att.name if new_att else 'NONE',
|
||||
new_att.file_size if new_att else 0,
|
||||
))
|
||||
@@ -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': """
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -165,7 +165,21 @@ class FedexRequest:
|
||||
def _process_errors(self, res_body):
|
||||
err_msgs = []
|
||||
for err in res_body.get('errors', []):
|
||||
err_msgs.append(f"{err['message']} ({err['code']})")
|
||||
msg = f"{err.get('message', '')} ({err.get('code', '')})"
|
||||
# FedEx hides the specific field-validation failures in
|
||||
# parameterList (e.g. INVALID.INPUT.EXCEPTION's top-level
|
||||
# message is just "Validation failed for object='X'. Error
|
||||
# count: 1" — the actual field name lives in parameterList).
|
||||
# Surface them so operators see "city cannot be null" instead
|
||||
# of a useless generic exception.
|
||||
params = err.get('parameterList') or []
|
||||
details = '; '.join(
|
||||
f"{p.get('key', '')}={p.get('value', '')}"
|
||||
for p in params if p.get('key') or p.get('value')
|
||||
)
|
||||
if details:
|
||||
msg += f"\n {details}"
|
||||
err_msgs.append(msg)
|
||||
return ','.join(err_msgs)
|
||||
|
||||
def _process_alerts(self, response):
|
||||
@@ -395,7 +409,8 @@ class FedexRequest:
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
|
||||
try:
|
||||
rate = next(filter(lambda d: d['currency'] == fedex_currency, res['rateReplyDetails'][0]['ratedShipmentDetails']), {})
|
||||
reply = res['rateReplyDetails'][0]
|
||||
rate = next(filter(lambda d: d['currency'] == fedex_currency, reply['ratedShipmentDetails']), {})
|
||||
if rate.get('totalNetChargeWithDutiesAndTaxes', 0):
|
||||
price = rate['totalNetChargeWithDutiesAndTaxes']
|
||||
else:
|
||||
@@ -403,8 +418,22 @@ class FedexRequest:
|
||||
except KeyError:
|
||||
raise ValidationError(_('Could not decode response')) from None
|
||||
|
||||
# Commit info — service display name + estimated delivery date
|
||||
# for the receiving form's shipping-quote preview panel.
|
||||
# FedEx returns several shapes depending on service; fall
|
||||
# through gracefully so callers that only need `price` still
|
||||
# work.
|
||||
commit = reply.get('commit') or {}
|
||||
date_detail = commit.get('dateDetail') or {}
|
||||
return {
|
||||
'price': price,
|
||||
'currency': fedex_currency,
|
||||
'service_type': reply.get('serviceType') or self.service_type,
|
||||
'service_name': reply.get('serviceName') or '',
|
||||
'delivery_timestamp': commit.get('deliveryTimestamp')
|
||||
or date_detail.get('dayCxsFormat') or '',
|
||||
'day_of_week': commit.get('dayOfWeek') or '',
|
||||
'transit_time': commit.get('transitTime') or '',
|
||||
'alert_message': self._process_alerts(res),
|
||||
}
|
||||
|
||||
|
||||
@@ -2459,13 +2459,29 @@ class DeliveryCarrier(models.Model):
|
||||
def fusion_fedex_rest_send_shipping(self, pickings):
|
||||
res = []
|
||||
srm = FedexRestRequest(self)
|
||||
# Per-shipment service override — fp.receiving sets this on the
|
||||
# carrier via with_context() before calling send_shipping. Empty
|
||||
# falls back to the carrier-level default already on srm.
|
||||
# See CLAUDE.md "Per-shipment service override".
|
||||
override = self.env.context.get('fp_service_type_override')
|
||||
if override:
|
||||
srm.service_type = override
|
||||
for picking in pickings:
|
||||
packages = self._get_packages_from_picking(picking, self.fedex_rest_default_package_type_id)
|
||||
# SoldTo defaults to the SO's invoice partner, but many setups
|
||||
# leave the parent contact (used as invoice fallback) with a
|
||||
# name-only record and no address — FedEx rejects on `soldTo.
|
||||
# address.city cannot be null`. If the invoice partner has no
|
||||
# city, treat ship-to as sold-to so _ship_package skips the
|
||||
# soldTo block entirely (line guard: `if sold_to != ship_to`).
|
||||
invoice_partner = picking.sale_id.partner_invoice_id
|
||||
if not (invoice_partner and invoice_partner.city):
|
||||
invoice_partner = picking.partner_id
|
||||
response = srm._ship_package(
|
||||
ship_from_wh=picking.picking_type_id.warehouse_id.partner_id,
|
||||
ship_from_company=picking.company_id.partner_id,
|
||||
ship_to=picking.partner_id,
|
||||
sold_to=picking.sale_id.partner_invoice_id,
|
||||
sold_to=invoice_partner,
|
||||
packages=packages,
|
||||
currency=picking.sale_id.currency_id.name or picking.company_id.currency_id.name,
|
||||
order_no=picking.sale_id.name,
|
||||
|
||||
@@ -267,10 +267,22 @@ class FusionShipment(models.Model):
|
||||
}
|
||||
|
||||
def _action_open_attachment(self, attachment):
|
||||
"""Open an attachment PDF in the browser viewer (new tab)."""
|
||||
"""Open an attachment for the operator.
|
||||
|
||||
Delegates to ir.attachment.action_fusion_preview — PDFs render
|
||||
in the preview dialog, anything else (ZPL, etc.) downloads.
|
||||
Helper falls back gracefully when fusion_pdf_preview isn't
|
||||
installed. See CLAUDE.md "PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not attachment:
|
||||
return False
|
||||
if hasattr(attachment, 'action_fusion_preview'):
|
||||
return attachment.action_fusion_preview(
|
||||
title=attachment.name or 'Shipping Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=false' % attachment.id,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
47
fusion_tasks/graphify-out/calendar_check.sql
Normal file
47
fusion_tasks/graphify-out/calendar_check.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
\pset border 2
|
||||
\pset format aligned
|
||||
|
||||
\echo '== A. Calendar event coverage on active tasks (last 30 days + future) =='
|
||||
SELECT
|
||||
COALESCE(NULLIF(x_fc_sync_source,''), '<local>') AS source,
|
||||
status,
|
||||
COUNT(*) AS task_count,
|
||||
COUNT(calendar_event_id) AS with_calendar_event,
|
||||
COUNT(*) - COUNT(calendar_event_id) AS missing
|
||||
FROM fusion_technician_task
|
||||
WHERE active = TRUE
|
||||
AND scheduled_date >= CURRENT_DATE - 30
|
||||
AND technician_id IS NOT NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2;
|
||||
|
||||
\echo ''
|
||||
\echo '== B. Spot-check: recent tasks WITHOUT calendar_event_id =='
|
||||
SELECT id, name, technician_id, x_fc_sync_source, status, scheduled_date, datetime_start, datetime_end
|
||||
FROM fusion_technician_task
|
||||
WHERE active = TRUE
|
||||
AND scheduled_date >= CURRENT_DATE - 7
|
||||
AND technician_id IS NOT NULL
|
||||
AND calendar_event_id IS NULL
|
||||
AND status NOT IN ('cancelled', 'completed')
|
||||
ORDER BY scheduled_date DESC, id DESC
|
||||
LIMIT 20;
|
||||
|
||||
\echo ''
|
||||
\echo '== C. Sample of linked calendar.event records (most recent 5) =='
|
||||
SELECT t.id AS task_id, t.name AS task_name,
|
||||
ce.id AS event_id, ce.name AS event_name,
|
||||
ce.start AS ev_start, ce.stop AS ev_stop,
|
||||
t.x_fc_sync_source AS source
|
||||
FROM fusion_technician_task t
|
||||
JOIN calendar_event ce ON ce.id = t.calendar_event_id
|
||||
WHERE t.active = TRUE
|
||||
ORDER BY t.write_date DESC
|
||||
LIMIT 5;
|
||||
|
||||
\echo ''
|
||||
\echo '== D. Are external calendar sync modules installed? =='
|
||||
SELECT name, state, latest_version FROM ir_module_module
|
||||
WHERE name IN ('google_calendar', 'microsoft_calendar', 'calendar', 'mail')
|
||||
OR name LIKE '%calendar%'
|
||||
ORDER BY name;
|
||||
42
fusion_tasks/graphify-out/calendar_check2.sql
Normal file
42
fusion_tasks/graphify-out/calendar_check2.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
\pset border 2
|
||||
\pset format aligned
|
||||
|
||||
\echo '== E. Are calendar events linked to the tech as organizer + attendee? =='
|
||||
SELECT t.id AS task_id, t.name AS task_name,
|
||||
ce.user_id AS event_organizer_uid,
|
||||
u_org.login AS organizer_login,
|
||||
u_tech.login AS task_tech_login,
|
||||
(SELECT COUNT(*) FROM calendar_event_res_partner_rel
|
||||
WHERE calendar_event_id = ce.id) AS attendee_count,
|
||||
(SELECT COUNT(*) FROM calendar_event_res_partner_rel cer
|
||||
JOIN res_users u2 ON u2.partner_id = cer.res_partner_id
|
||||
WHERE cer.calendar_event_id = ce.id AND u2.id = t.technician_id) AS tech_is_attendee
|
||||
FROM fusion_technician_task t
|
||||
JOIN calendar_event ce ON ce.id = t.calendar_event_id
|
||||
JOIN res_users u_tech ON u_tech.id = t.technician_id
|
||||
LEFT JOIN res_users u_org ON u_org.id = ce.user_id
|
||||
WHERE t.active = TRUE
|
||||
AND t.scheduled_date >= CURRENT_DATE - 3
|
||||
AND t.scheduled_date <= CURRENT_DATE + 7
|
||||
ORDER BY t.scheduled_date, t.id
|
||||
LIMIT 12;
|
||||
|
||||
\echo ''
|
||||
\echo '== F. Microsoft Calendar OAuth: how many users have it connected? =='
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE microsoft_calendar_token IS NOT NULL AND microsoft_calendar_token <> '') AS users_with_ms_token,
|
||||
COUNT(*) FILTER (WHERE x_fc_is_field_staff = TRUE
|
||||
AND microsoft_calendar_token IS NOT NULL
|
||||
AND microsoft_calendar_token <> '') AS field_staff_with_ms_token,
|
||||
COUNT(*) FILTER (WHERE x_fc_is_field_staff = TRUE AND active = TRUE) AS active_field_staff
|
||||
FROM res_users;
|
||||
|
||||
\echo ''
|
||||
\echo '== G. Per-tech: connected to MS calendar? =='
|
||||
SELECT u.login, u.x_fc_tech_sync_id,
|
||||
(microsoft_calendar_token IS NOT NULL AND microsoft_calendar_token <> '') AS ms_connected,
|
||||
(microsoft_calendar_sync_token IS NOT NULL AND microsoft_calendar_sync_token <> '') AS ms_sync_token,
|
||||
microsoft_calendar_account_id
|
||||
FROM res_users u
|
||||
WHERE u.x_fc_is_field_staff = TRUE AND u.active = TRUE
|
||||
ORDER BY u.login;
|
||||
25
fusion_tasks/graphify-out/mobility_sync_fix.py
Normal file
25
fusion_tasks/graphify-out/mobility_sync_fix.py
Normal file
@@ -0,0 +1,25 @@
|
||||
print('=== BEFORE ===')
|
||||
for uid in (32, 27):
|
||||
u = env['res.users'].browse(uid)
|
||||
print(f" uid={uid} login={u.login} active={u.active} "
|
||||
f"field_staff={u.x_fc_is_field_staff} sync_id={u.x_fc_tech_sync_id!r}")
|
||||
|
||||
try:
|
||||
env['res.users'].browse(32).x_fc_tech_sync_id = 'simranjeet'
|
||||
env['res.users'].browse(27).write({
|
||||
'x_fc_is_field_staff': True,
|
||||
'x_fc_tech_sync_id': 'hk',
|
||||
})
|
||||
env.cr.commit()
|
||||
print('Commit OK')
|
||||
except Exception as e:
|
||||
env.cr.rollback()
|
||||
print(f'FAILED: {type(e).__name__}: {e}')
|
||||
raise
|
||||
|
||||
print('=== AFTER ===')
|
||||
for uid in (32, 27):
|
||||
u = env['res.users'].browse(uid)
|
||||
print(f" uid={uid} login={u.login} active={u.active} "
|
||||
f"field_staff={u.x_fc_is_field_staff} sync_id={u.x_fc_tech_sync_id!r}")
|
||||
print('DONE')
|
||||
62
fusion_tasks/graphify-out/sync_evidence.sql
Normal file
62
fusion_tasks/graphify-out/sync_evidence.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
\pset border 2
|
||||
\pset format aligned
|
||||
|
||||
\echo '== 1. Local instance ID =='
|
||||
SELECT key, value
|
||||
FROM ir_config_parameter
|
||||
WHERE key = 'fusion_claims.sync_instance_id';
|
||||
|
||||
\echo ''
|
||||
\echo '== 2. Remote sync configs (other instances we sync with) =='
|
||||
SELECT id, name, instance_id, url, database, username, active,
|
||||
last_sync, LEFT(COALESCE(last_sync_error,''), 200) AS last_sync_error
|
||||
FROM fusion_task_sync_config;
|
||||
|
||||
\echo ''
|
||||
\echo '== 3. Field technicians and sync IDs =='
|
||||
SELECT u.id, u.login, p.name AS partner_name,
|
||||
u.x_fc_is_field_staff, u.x_fc_tech_sync_id, u.active
|
||||
FROM res_users u
|
||||
JOIN res_partner p ON p.id = u.partner_id
|
||||
WHERE u.x_fc_is_field_staff = TRUE
|
||||
OR (u.x_fc_tech_sync_id IS NOT NULL AND u.x_fc_tech_sync_id <> '')
|
||||
ORDER BY u.active DESC, u.login;
|
||||
|
||||
\echo ''
|
||||
\echo '== 4. Recent task flow (last 7 days) =='
|
||||
SELECT
|
||||
COALESCE(NULLIF(x_fc_sync_source,''), '<local>') AS source,
|
||||
status,
|
||||
COUNT(*) AS cnt,
|
||||
MIN(scheduled_date) AS min_date,
|
||||
MAX(scheduled_date) AS max_date
|
||||
FROM fusion_technician_task
|
||||
WHERE create_date > NOW() - INTERVAL '7 days'
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2;
|
||||
|
||||
\echo ''
|
||||
\echo '== 5. Cron jobs for Fusion Tasks =='
|
||||
SELECT
|
||||
c.id,
|
||||
REPLACE(REPLACE(c.cron_name, 'Fusion Tasks:', ''), ' ', ' ') AS job,
|
||||
c.active,
|
||||
c.interval_number || ' ' || c.interval_type AS every,
|
||||
c.lastcall, c.nextcall
|
||||
FROM ir_cron c
|
||||
WHERE c.cron_name LIKE 'Fusion Tasks%'
|
||||
ORDER BY c.cron_name;
|
||||
|
||||
\echo ''
|
||||
\echo '== 6. Tasks scheduled today/tomorrow by tech =='
|
||||
SELECT
|
||||
u.login AS tech_login,
|
||||
u.x_fc_tech_sync_id AS sync_id,
|
||||
COALESCE(NULLIF(t.x_fc_sync_source,''), '<local>') AS source,
|
||||
COUNT(*) AS cnt
|
||||
FROM fusion_technician_task t
|
||||
JOIN res_users u ON u.id = t.technician_id
|
||||
WHERE t.scheduled_date BETWEEN CURRENT_DATE - 1 AND CURRENT_DATE + 7
|
||||
AND t.active = TRUE
|
||||
GROUP BY 1,2,3
|
||||
ORDER BY 1,3;
|
||||
19
fusion_tasks/graphify-out/sync_evidence_2.sql
Normal file
19
fusion_tasks/graphify-out/sync_evidence_2.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
\pset border 2
|
||||
\pset format aligned
|
||||
|
||||
\echo '== Garry vs Gurpreet on westin =='
|
||||
SELECT u.id, u.login, u.active, u.share, u.create_date, u.write_date,
|
||||
u.x_fc_tech_sync_id,
|
||||
(SELECT COUNT(*) FROM fusion_technician_task t WHERE t.technician_id = u.id) AS total_tasks,
|
||||
(SELECT MAX(create_date) FROM fusion_technician_task t WHERE t.technician_id = u.id) AS last_task_create,
|
||||
(SELECT COUNT(*) FROM mail_message m WHERE m.author_id = u.partner_id) AS messages
|
||||
FROM res_users u
|
||||
WHERE u.id IN (2, 85);
|
||||
|
||||
\echo ''
|
||||
\echo '== HK detail on westin =='
|
||||
SELECT u.id, u.login, p.name AS partner_name, p.email, p.phone, p.mobile,
|
||||
u.x_fc_is_field_staff, u.x_fc_tech_sync_id, u.active
|
||||
FROM res_users u
|
||||
JOIN res_partner p ON p.id = u.partner_id
|
||||
WHERE u.id = 39;
|
||||
31
fusion_tasks/graphify-out/sync_verify.sql
Normal file
31
fusion_tasks/graphify-out/sync_verify.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
\pset border 2
|
||||
\pset format aligned
|
||||
|
||||
\echo '== A. All field staff and sync IDs (live) =='
|
||||
SELECT u.id, u.login, p.name, u.x_fc_is_field_staff, u.x_fc_tech_sync_id, u.active
|
||||
FROM res_users u JOIN res_partner p ON p.id = u.partner_id
|
||||
WHERE u.x_fc_is_field_staff = TRUE
|
||||
OR (u.x_fc_tech_sync_id IS NOT NULL AND u.x_fc_tech_sync_id <> '')
|
||||
ORDER BY u.active DESC, u.login;
|
||||
|
||||
\echo ''
|
||||
\echo '== B. Last pull cron run + sync config status =='
|
||||
SELECT
|
||||
(SELECT to_char(lastcall, 'YYYY-MM-DD HH24:MI:SS') FROM ir_cron WHERE cron_name LIKE 'Fusion Tasks: Sync Remote Tasks (Pull)') AS last_pull_cron,
|
||||
(SELECT to_char(last_sync, 'YYYY-MM-DD HH24:MI:SS') FROM fusion_task_sync_config LIMIT 1) AS last_sync,
|
||||
(SELECT LEFT(COALESCE(last_sync_error,'(none)'),120) FROM fusion_task_sync_config LIMIT 1) AS last_sync_error,
|
||||
to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') AS now;
|
||||
|
||||
\echo ''
|
||||
\echo '== C. Tasks by tech in next 7 days (target: simranjeet + hk shadows now appear) =='
|
||||
SELECT
|
||||
u.login AS tech_login,
|
||||
u.x_fc_tech_sync_id AS sync_id,
|
||||
COALESCE(NULLIF(t.x_fc_sync_source,''), '<local>') AS source,
|
||||
COUNT(*) AS cnt
|
||||
FROM fusion_technician_task t
|
||||
JOIN res_users u ON u.id = t.technician_id
|
||||
WHERE t.scheduled_date BETWEEN CURRENT_DATE - 1 AND CURRENT_DATE + 7
|
||||
AND t.active = TRUE
|
||||
GROUP BY 1,2,3
|
||||
ORDER BY 1,3;
|
||||
16
fusion_tasks/graphify-out/westin_sync_fix.py
Normal file
16
fusion_tasks/graphify-out/westin_sync_fix.py
Normal file
@@ -0,0 +1,16 @@
|
||||
print('=== BEFORE ===')
|
||||
for uid in (85, 100, 39):
|
||||
u = env['res.users'].browse(uid)
|
||||
print(f" uid={uid} login={u.login} active={u.active} sync_id={u.x_fc_tech_sync_id!r}")
|
||||
|
||||
env['res.users'].browse(85).active = False
|
||||
env['res.users'].browse(100).x_fc_tech_sync_id = 'simranjeet'
|
||||
env['res.users'].browse(39).x_fc_tech_sync_id = 'hk'
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
print('=== AFTER ===')
|
||||
for uid in (85, 100, 39):
|
||||
u = env['res.users'].with_context(active_test=False).browse(uid)
|
||||
print(f" uid={uid} login={u.login} active={u.active} sync_id={u.x_fc_tech_sync_id!r}")
|
||||
print('DONE')
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user