From 8ef57a4bb1d7ed306df92d39bb33918c8a7e21b4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 22:29:48 -0400 Subject: [PATCH 01/22] fix(task_sync): defend against silent sync_id integrity violations The cross-instance sync silently drops tasks when x_fc_tech_sync_id is missing on the technician, and silently collapses duplicates via dict comprehension. Both make sync break in ways that are invisible until someone notices a missing task on the other instance. - _get_remote_tech_map / _get_local_syncid_to_uid: warn on duplicates - _push_tasks_to_remote: info-log when a task is skipped because the tech has no sync_id or no remote counterpart - res.users onchange: warn in the form when entering a sync_id that is already used by another active field staff Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_tasks/__manifest__.py | 2 +- fusion_tasks/models/res_users.py | 25 ++++++++++++++++++++++- fusion_tasks/models/task_sync.py | 34 ++++++++++++++++++++++++++------ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/fusion_tasks/__manifest__.py b/fusion_tasks/__manifest__.py index 717e31ce..3521493c 100644 --- a/fusion_tasks/__manifest__.py +++ b/fusion_tasks/__manifest__.py @@ -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.', diff --git a/fusion_tasks/models/res_users.py b/fusion_tasks/models/res_users.py index 7d82b551..d622dac7 100644 --- a/fusion_tasks/models/res_users.py +++ b/fusion_tasks/models/res_users.py @@ -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." + ), + } + } diff --git a/fusion_tasks/models/task_sync.py b/fusion_tasks/models/task_sync.py index 99ee3684..6a02f093 100644 --- a/fusion_tasks/models/task_sync.py +++ b/fusion_tasks/models/task_sync.py @@ -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 From f1cea2fb352a5c59662a7c2cbab9962b15e4fda0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 23:21:15 -0400 Subject: [PATCH 02/22] fix(fusion_schedule): stop archiving valid events on @removed=changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft Graph's delta API returns @removed={reason:'changed'} when an event drifts outside the original delta-query window — the event still exists upstream. The old code treated any truthy @removed the same as a real delete and archived the local calendar.event. Combined with _find_existing_event filtering by active=True, every subsequent sync recreated a duplicate (then archived it on the next pass), accumulating 5x duplicates and emptying the user's calendar. - _process_microsoft_event: only archive on isCancelled or @removed.reason='deleted'; skip on @removed.reason='changed' - _process_microsoft_event link path: reactivate when MS Graph confirms a previously-archived event still exists - _process_microsoft_event iCalUId path: same reactivation - _find_existing_event: include archived records so wrongly-archived duplicates are reused instead of piling up - callers reactivate the matched archived record Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_schedule/__manifest__.py | 2 +- .../models/fusion_calendar_account.py | 31 ++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/fusion_schedule/__manifest__.py b/fusion_schedule/__manifest__.py index bcd7e9bb..896b1bbb 100644 --- a/fusion_schedule/__manifest__.py +++ b/fusion_schedule/__manifest__.py @@ -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': """ diff --git a/fusion_schedule/models/fusion_calendar_account.py b/fusion_schedule/models/fusion_calendar_account.py index 4cfd157a..1672a471 100644 --- a/fusion_schedule/models/fusion_calendar_account.py +++ b/fusion_schedule/models/fusion_calendar_account.py @@ -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' From b2f483d67c61207984c20a2136d2cc4adf11f219 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:29:23 -0400 Subject: [PATCH 03/22] docs(fusion_claims): add dashboard redesign spec Action-oriented dashboard replacing the existing 4-panel HTML overview: posting-week banner with live countdown, 3 KPI tiles, 8 funder hotlinks, ADP + MOD workflow flag tiles, role-aware filtering, dark-mode aware SCSS. Spec captures all design decisions from the brainstorm session; ready to hand off to writing-plans. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/.gitignore | 1 + ...26-05-21-fusion-claims-dashboard-design.md | 432 ++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 fusion_claims/.gitignore create mode 100644 fusion_claims/docs/superpowers/specs/2026-05-21-fusion-claims-dashboard-design.md diff --git a/fusion_claims/.gitignore b/fusion_claims/.gitignore new file mode 100644 index 00000000..7a95436d --- /dev/null +++ b/fusion_claims/.gitignore @@ -0,0 +1 @@ +.superpowers/ diff --git a/fusion_claims/docs/superpowers/specs/2026-05-21-fusion-claims-dashboard-design.md b/fusion_claims/docs/superpowers/specs/2026-05-21-fusion-claims-dashboard-design.md new file mode 100644 index 00000000..ebbc81fb --- /dev/null +++ b/fusion_claims/docs/superpowers/specs/2026-05-21-fusion-claims-dashboard-design.md @@ -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//`, 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_` (~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__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 + + + + + +``` + +### 10.4 Use in form view + +```xml + +``` + +## 11. Manifest changes + +```python +'version': '', # 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. From 1314f4581dbada100f20094dc2ee356e321f8b6f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:37:25 -0400 Subject: [PATCH 04/22] changes --- CLAUDE.md | 18 + .../models/fusion_cp_shipment.py | 15 +- fusion_claims/CLAUDE.md | 3106 +++++++++++++++++ fusion_pdf_preview/__manifest__.py | 3 +- fusion_pdf_preview/models/__init__.py | 1 + fusion_pdf_preview/models/ir_attachment.py | 48 + .../static/src/js/open_attachment_action.js | 42 + fusion_plating/CLAUDE.md | 15 +- .../models/fp_fair.py | 12 +- .../__manifest__.py | 2 +- .../models/fp_certificate.py | 107 +- .../views/fp_certificate_views.xml | 59 +- .../views/sale_order_views.xml | 17 +- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/fp_job.py | 17 + .../static/src/js/fp_record_inputs_dialog.js | 7 +- .../views/fp_certificate_views.xml | 141 +- .../wizards/fp_cert_issue_wizard.py | 401 ++- .../wizards/fp_cert_issue_wizard_views.xml | 17 + .../models/fp_delivery.py | 6 +- .../data/mail_template_data.xml | 49 +- .../fusion_plating_receiving/__manifest__.py | 15 +- .../models/fp_receiving.py | 539 ++- .../models/fusion_shipment.py | 39 +- .../security/ir.model.access.csv | 3 + .../static/src/scss/fp_shipping_quote.scss | 78 + .../views/fp_receiving_views.xml | 53 +- .../views/fusion_shipment_inherit_views.xml | 16 + .../wizards/__init__.py | 1 + .../wizards/fp_label_generate_wizard.py | 106 + .../fp_label_generate_wizard_views.xml | 68 + .../fusion_plating_reports/__manifest__.py | 2 +- .../report/report_coc.xml | 275 +- .../scripts/fp_fedex_service_matrix.py | 135 + fusion_plating/scripts/fp_reset_cert_30045.py | 56 + .../scripts/fp_retro_image_30045.py | 52 + .../scripts/fp_retro_thickness_30045.py | 77 + fusion_shipping/api/fedex_rest/request.py | 33 +- fusion_shipping/models/delivery_carrier.py | 18 +- fusion_shipping/models/fusion_shipment.py | 14 +- fusion_tasks/graphify-out/calendar_check.sql | 47 + fusion_tasks/graphify-out/calendar_check2.sql | 42 + .../graphify-out/mobility_sync_fix.py | 25 + fusion_tasks/graphify-out/sync_evidence.sql | 62 + fusion_tasks/graphify-out/sync_evidence_2.sql | 19 + fusion_tasks/graphify-out/sync_verify.sql | 31 + fusion_tasks/graphify-out/westin_sync_fix.py | 16 + 47 files changed, 5730 insertions(+), 177 deletions(-) create mode 100644 fusion_claims/CLAUDE.md create mode 100644 fusion_pdf_preview/models/ir_attachment.py create mode 100644 fusion_pdf_preview/static/src/js/open_attachment_action.js create mode 100644 fusion_plating/fusion_plating_receiving/static/src/scss/fp_shipping_quote.scss create mode 100644 fusion_plating/fusion_plating_receiving/wizards/fp_label_generate_wizard.py create mode 100644 fusion_plating/fusion_plating_receiving/wizards/fp_label_generate_wizard_views.xml create mode 100644 fusion_plating/scripts/fp_fedex_service_matrix.py create mode 100644 fusion_plating/scripts/fp_reset_cert_30045.py create mode 100644 fusion_plating/scripts/fp_retro_image_30045.py create mode 100644 fusion_plating/scripts/fp_retro_thickness_30045.py create mode 100644 fusion_tasks/graphify-out/calendar_check.sql create mode 100644 fusion_tasks/graphify-out/calendar_check2.sql create mode 100644 fusion_tasks/graphify-out/mobility_sync_fix.py create mode 100644 fusion_tasks/graphify-out/sync_evidence.sql create mode 100644 fusion_tasks/graphify-out/sync_evidence_2.sql create mode 100644 fusion_tasks/graphify-out/sync_verify.sql create mode 100644 fusion_tasks/graphify-out/westin_sync_fix.py diff --git a/CLAUDE.md b/CLAUDE.md index bee454de..c5033b04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,24 @@ Odoo content-hashes the compiled bundle URL (`/web/assets//...`). 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 diff --git a/fusion_canada_post/models/fusion_cp_shipment.py b/fusion_canada_post/models/fusion_cp_shipment.py index 59766873..e23f1445 100644 --- a/fusion_canada_post/models/fusion_cp_shipment.py +++ b/fusion_canada_post/models/fusion_cp_shipment.py @@ -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, diff --git a/fusion_claims/CLAUDE.md b/fusion_claims/CLAUDE.md new file mode 100644 index 00000000..062617a8 --- /dev/null +++ b/fusion_claims/CLAUDE.md @@ -0,0 +1,3106 @@ +# fusion_claims — Claude Code Instructions + +> Read together with the repo-root `../CLAUDE.md` (Odoo 19 rules, naming, dark-mode SCSS pattern, PDF preview helper, Supabase KB credentials). This file documents only what is specific to `fusion_claims`. + +## 1. What this module is + +- **Name**: Fusion Claims (declared as `application: True` — it owns its own top-level menu "ADP Claims"). +- **Category**: Sales. +- **License**: OPL-1 (Nexa Systems Inc.). +- **Version**: `19.0.8.0.7` (bump on every CSS/asset change — see asset cache-busting in repo CLAUDE.md). +- **Purpose**: end-to-end management of Ontario funder claims for assistive devices. Single sale-order record drives the entire lifecycle across **eight distinct funder workflows**, each with its own state machine, wizards, emails, and reports. The largest module in the repo by far. +- **Customers**: Westin Healthcare (primary) and NEXA Systems internal use. + +## 2. Dependencies + +### Odoo addons (`__manifest__.py:75-88`) + +``` +base, sale, sale_management, sale_margin, purchase, +account, sales_team, stock, calendar, ai, +fusion_ringcentral, fusion_tasks +``` + +- **`ai`** (Odoo 19 native): used for the `ai.agent` integration. The `ai_agent_fusion_claims` record + 3 server-action tools (`_fc_tool_search_clients`, `_fc_tool_client_details`, `_fc_tool_claims_stats`) live in `data/ai_agent_data.xml` and `models/ai_agent_ext.py`. Channels of type `ai_chat` are spawned from `fusion.client.profile.action_open_ai_chat`. +- **`fusion_tasks`** (sibling Nexa module): provides + - `fusion.email.builder.mixin` — the `_email_build(title, summary, sections, note, email_type, button_url, ...)` helper used by ~50 email methods in `models/sale_order.py`. Without this dependency, every email send breaks. + - `fusion.technician.task` — the base model that `models/technician_task.py` inherits from to add `sale_order_id` / `purchase_order_id` links, delivery/pickup hooks, and rental inspection fields. +- **`fusion_ringcentral`** (sibling Nexa module): click-to-dial + softphone for any phone field. No code in this module calls into it directly — it's a runtime UX dependency. +- **`calendar`**: `schedule_assessment_wizard` creates `calendar.event` records with an email alarm 1 day before. +- **`sale_margin`**: used by the landscape report (margin column). + +### ⚠ Soft (undeclared) dependency: `fusion_faxes` + +`wizard/odsp_submit_to_odsp_wizard.py` calls into `fusion_faxes.send.fax.wizard` (the fax composer) and reads `partner.x_ff_fax_number` — **but `fusion_faxes` is NOT in `__manifest__.py.depends`**. The fax actions are guarded by `hasattr` checks so the wizard still loads if `fusion_faxes` is missing, but the "Send Fax" / "Send Email + Fax" buttons will fail at click-time. If you're moving this module to a new database, install `fusion_faxes` alongside it. + +### ⚠ Reverse-dependency: `fusion_authorizer_portal` always installed alongside + +The dependency direction is **`fusion_authorizer_portal` → `fusion_claims`** (hard, declared in fusion_authorizer_portal's manifest), but fusion_claims uses APIs that only exist when fusion_authorizer_portal is installed: + +- `sale.order._apply_pod_signature_to_approval_form` imports `PDFTemplateFiller` from `odoo.addons.fusion_authorizer_portal.utils.pdf_filler` — `ImportError` if missing. +- `fusion.page11.sign.request` renders PDFs using `fusion.pdf.template` records — that **model lives in fusion_authorizer_portal**, not here. +- The `/page11/sign/` URL that the Page 11 wizard generates is handled by `fusion_authorizer_portal.controllers.portal_page11_sign` — without it the public signing flow is dead. +- `page11_sign_request._generate_signed_pdf` references `fusion.assessment` records — that model also lives in fusion_authorizer_portal. + +In practice both modules are always installed together. See §29 for the full integration map. + +### External Python (`__manifest__.py:89-91`) + +- **`pdf2image`**, **`PIL`** — required (manifest declares). +- **`pdfrw`** — optional, used by `wizard/odsp_sa_mobility_wizard.py` to fill the SA Mobility government PDF form. Module logs a warning and disables that wizard if missing. +- **`requests`** — used implicitly by AI calls (`models/client_chat.py`) and SMS (`_twilio_send_sms`). Inherited from base Odoo deps. + +### Post-init hook (`__init__.py:14-54`) + +`_load_adp_device_codes` runs on install **and** every upgrade. Two idempotent steps: + +1. `fusion.adp.device.code._load_packaged_device_codes()` imports `data/device_codes/adp_mobility_manual.json` (~hundreds of records) via `import_from_json`. +2. `_link_products_to_device_codes()` runs two SQL `UPDATE` statements: one links `product_template.x_fc_adp_device_code_id` for products that already have `x_fc_adp_device_code` set and matches a device code, and one toggles `x_fc_is_adp_product = TRUE` for products with a code but no flag. Both are guarded by `IS NULL` checks — preserve idempotence if you edit them. + +## 3. Funder workflows (the architecture) + +`x_fc_sale_type` on `sale.order` (`models/sale_order.py:320-339`) selects one of: + +``` +adp, adp_odsp, odsp, wsib, direct_private, insurance, +march_of_dimes, muscular_dystrophy, other, rental, hardship +``` + +Once any non-quotation status is set on a funder workflow, `x_fc_sale_type_locked` becomes True (`models/sale_order.py:347-371`) and the dropdown is read-only — override via setting `fusion_claims.allow_sale_type_override`. + +Each funder has its own status field, wizards, kanban columns, emails, and (in some cases) submission helpers: + +| Funder | Status field | Module is_* flag | show_* flag | Key wizards | +|---|---|---|---|---| +| **ADP** (Assistive Devices Program) | `x_fc_adp_application_status` (22 states) | `x_fc_is_adp_sale` | implicit | schedule_assessment, assessment_completed, application_received, ready_for_submission, submission_verification, device_approval, ready_for_delivery, ready_to_bill, case_close_verification, status_change_reason, send_page11 | +| **MOD** (March of Dimes HVMP) | `x_fc_mod_status` (14 states) | `x_fc_is_mod_sale` | `x_fc_show_mod_fields` | mod_submission_path, send_to_mod (drawing/quotation/POD), mod_awaiting_funding, mod_funding_approved, mod_funding_denied, mod_pca_received, mod_resubmit, mod_submission_confirmed | +| **ODSP Standard** | `x_fc_odsp_std_status` | `x_fc_is_odsp_sale` | `x_fc_show_odsp_fields` | odsp_submit_to_odsp, odsp_pre_approved, odsp_ready_delivery | +| **ODSP SA Mobility** | `x_fc_sa_status` | (subset of ODSP) | | odsp_sa_mobility (fills gov PDF via pdfrw) | +| **ODSP Ontario Works** | `x_fc_ow_status` | (subset of ODSP) | | odsp_discretionary | +| **WSIB** | `x_fc_wsib_status` | `x_fc_is_wsib_sale` | `x_fc_show_wsib_fields` | (generic funder transitions) | +| **Insurance** | `x_fc_insurance_status` | `x_fc_is_insurance_sale` | `x_fc_show_insurance_fields` | (generic funder transitions) | +| **MDC** (Muscular Dystrophy) | `x_fc_mdc_status` | `x_fc_is_mdc_sale` | `x_fc_show_mdc_fields` | (generic funder transitions) | +| **Hardship** | `x_fc_hardship_status` | `x_fc_is_hardship_sale` | `x_fc_show_hardship_fields` | (generic funder transitions) | +| Direct/Private, Other, Rental | — | — | — | invoiced directly, no funder lifecycle | + +`x_fc_odsp_division` distinguishes the three ODSP sub-workflows; `_get_odsp_status()` returns whichever of `x_fc_sa_status`/`x_fc_odsp_std_status`/`x_fc_ow_status` is active. + +## 4. The ADP workflow (the spine) + +### 4.1 Status field + +`x_fc_adp_application_status` (`models/sale_order.py:2302-2333`) — 22 states. Workflow sequence enforced by `_STATUS_ORDER` (`models/sale_order.py:2361-2384`) which drives the kanban column order (`_read_group` override at line 2393). + +``` +quotation → assessment_scheduled → assessment_completed → waiting_for_application → +application_received → ready_submission → submitted → + accepted (within 24h) / rejected → resubmitted → + needs_correction → (corrected then back to submitted/resubmitted) → +approved / approved_deduction → ready_delivery → ready_bill → billed → case_closed + +Special branches: + on_hold (any time) ←→ resume_from_hold (back to previous status) + withdrawn → resubmit_from_withdrawn (back to ready_submission) + denied → resubmit_from_denied (back to ready_submission) + cancelled → reopen (only if not reported to ADP) + expired (12 months after approved with no delivery) → reopen / duplicate_for_reassessment +``` + +There is also a **legacy** `x_fc_adp_status` field (7-state, simpler) — keep it in mind but do NOT use it for new logic. + +### 4.2 Status transitions are NEVER set via dropdown + +Every controlled status (every transition that should fire an email, write to chatter, or update related records) lives on a button that opens a dedicated wizard. `static/src/js/status_selection_filter.js` registers a `filtered_status_selection` field that **hides** controlled statuses from the dropdown: + +```javascript +const CONTROLLED_STATUSES = [ + 'assessment_scheduled', 'assessment_completed', 'application_received', + 'ready_submission', 'submitted', 'resubmitted', 'approved', + 'approved_deduction', 'ready_bill', 'billed', 'case_closed', + 'on_hold', 'withdrawn', 'denied', 'cancelled', 'needs_correction', +]; +``` + +When you must bypass validation (legitimate framework calls like sync paths), pass `with_context(skip_status_validation=True)`. + +### 4.3 Two-stage verification system + +| Stage | Wizard | What it captures | When | +|---|---|---|---| +| **1. Submission** | `submission_verification_wizard.py` (397 lines) | `x_fc_submitted_device_types` (JSON dict `{device_type: True}` of device types submitted), groups lines by `fusion.adp.device.code.device_type` | Before `submitted` | +| **2. Approval** | `device_approval_wizard.py` (724 lines) | per-line `x_fc_adp_approved`, plus optional deductions and approval-letter attachments | After ADP approves | + +Stage 1 is dual-purpose — when invoked with context `submit_application=True`, the SAME wizard also writes status to `submitted` (or `resubmitted` if current status is `needs_correction`) and stores the final application PDF (`x_fc_final_submitted_application`) + XML file (`x_fc_xml_file`). It validates the application is PDF + XML is `.xml`. + +When stage 2 is complete (`x_fc_device_verification_complete = True`) lines with `x_fc_adp_approved = False` flip to client-100% (see §6). Header-level rollups: `x_fc_approved_device_count`, `x_fc_total_device_count`, `x_fc_device_approval_done`, `x_fc_has_unapproved_devices`. + +The device approval wizard can be re-opened from the **client invoice** if it was created before approval — `account.move.action_open_device_approval_wizard` finds the linked SO and routes there. + +**Stage 2 in `mark_as_approved` mode** (context flag) — the wizard does THREE more things: +1. Sets status to `approved` (no deductions) or `approved_deduction` (any deduction applied). +2. Saves `claim_number`, `claim_approval_date`, `approval_letter` (single PDF) — plus a Many2many `approval_photo_ids` of approval screenshots. +3. **Critical attachment persistence fix**: many2many_binary attachments uploaded to a TransientModel are garbage-collected when the wizard closes. The wizard **copies** the attachment data into NEW `ir.attachment` records linked to `sale.order` so they persist. Both single-file attachments AND screenshots are posted in a SINGLE chatter card (`alert-success`) with all `attachment_ids` attached at once. +4. Calls `_sync_approval_to_invoices(updated_lines)` — for each existing invoice, updates lines so unapproved items get 100% to client (price=0 on ADP invoice, full subtotal on client invoice) and approved items get the normal split. Tracks `invoices_updated` count for the notification. + +### 4.3.1 Assessment completed override mode + +`assessment_completed_wizard` has a **scheduling-override** branch: if status is currently `quotation` (the user is skipping the scheduled-assessment step entirely), then: +- `is_override` computes True. +- `override_reason` becomes mandatory (raises `UserError` if blank). +- The chatter card uses yellow `alert-warning` styling with the override reason shown. +- If `notify_authorizer=True`, the email to the authorizer includes a yellow note explaining the override. +- Normal (non-override) path uses green `alert-success` styling. + +Also validates `completion_date >= assessment_start_date`. + +### 4.4 Three-mode "Application Received" intake (`application_received_wizard`) + +The wizard handles three distinct ways Pages 11 & 12 (client consent) can arrive — this is a key business invariant: + +| Mode | Field set | Description | +|---|---|---| +| **bundled** | `x_fc_pages_11_12_in_original = True` | A single PDF that already contains the signed pages 11 & 12 (no separate file needed) | +| **separate** | `x_fc_signed_pages_11_12` + `x_fc_signed_pages_filename` populated | Original application + a separate PDF with the signed pages | +| **remote** | A `fusion.page11.sign.request` exists in state `sent` or `signed` | Page 11 sent to client/agent for digital signature via `/page11/sign/` | + +`x_fc_has_signed_pages_11_12` is a **computed boolean** that returns True if ANY of the three conditions hold — DO NOT check `x_fc_signed_pages_11_12` directly to gate workflow steps, that misses bundled and remote modes. (`models/sale_order.py:2921-2942`.) + +The wizard does **two layers of PDF validation**: +1. Filename constraint — must end in `.pdf` (case-insensitive). +2. **Magic-byte check** — base64-decoded payload must start with `%PDF-`. Test fixtures use `b'%PDF-1.4\n%fake pdf for tests'` to pass. + +`default_get` picks the initial mode based on existing state: bundled flag set → bundled; separate file present → separate; pending sign request → remote; otherwise bundled. + +The wizard has an inline button `action_request_page11_signature` that opens `send_page11_wizard` without leaving the parent wizard. + +### 4.5 Document locks (separate from `x_fc_case_locked`) + +`sale.order.write` enforces document-level locks based on workflow status (`models/sale_order.py:7289-7382`): + +| Document field(s) | Locked when status ≥ | +|---|---| +| `x_fc_original_application`, `x_fc_signed_pages_11_12` (+ filenames) | `submitted` | +| `x_fc_final_submitted_application`, `x_fc_xml_file` (+ filenames) | `approved` | +| `x_fc_approval_letter` (+ filename) | `billed` | +| `x_fc_proof_of_delivery` (+ filename) | `billed` | + +Bypass requires **both**: setting `fusion_claims.allow_document_lock_override = True` AND user in `group_document_lock_override`. Context flag `skip_document_lock_validation=True` bypasses for programmatic writes only. + +### 4.6 Case-wide lock (`x_fc_case_locked`) + +Distinct from the per-document status locks above — this is a **manual** lock toggled via the "Case Locked" switch in the ADP Order Trail tab. When True, the `write` override blocks **all** `x_fc_*` field writes except: + +- `x_fc_case_locked` (so you can untoggle it) +- `message_main_attachment_id`, `message_follower_ids`, `activity_ids` (Odoo plumbing) + +Used for archiving completed legacy cases. Bypass with `with_context(skip_all_validations=True)` (used by crons/email-tracking). + +### 4.7 Document audit trail in chatter + +The same `write` override (`models/sale_order.py:7384-7430`) preserves the OLD copy of any replaced document (`x_fc_original_application`, `x_fc_signed_pages_11_12`, `x_fc_final_submitted_application`, `x_fc_xml_file`, `x_fc_proof_of_delivery`, `x_fc_approval_letter`) in chatter **before** it gets overwritten. Set `with_context(skip_document_chatter=True)` to suppress. + +### 4.8 `reason_for_application` field (12 values) + +`x_fc_reason_for_application` controls invoicing rules and required fields: + +``` +first_access — First Time Access (NO previous ADP) +additions — Additions +mod_non_adp — Modification/Upgrade — original NOT through ADP +mod_adp — Modification/Upgrade — original through ADP +replace_status — Replacement — Change in Status +replace_size — Replacement — Change in Body Size +replace_worn — Replacement — Worn out (past useful life) +replace_lost — Replacement — Lost +replace_stolen — Replacement — Stolen +replace_damaged — Replacement — Damaged beyond repair +replace_no_longer_meets — Replacement — No longer meets needs +growth — Growth/Change in condition +``` + +Rules: +- `previous_funding_date` is **required** for all reasons except `first_access` and `mod_non_adp`. +- `<5 years` warning: `x_fc_under_5_years` (computed from `previous_funding_date`) — if True and reason ∈ {`replace_status`, `replace_size`, `replace_worn`}, posts a chatter warning when creating client invoice. (Surfaces possible ADP deductions.) +- **Modification reasons** (`mod_non_adp`, `mod_adp`) **block** client invoice creation until status reaches `approved` or `approved_deduction` — surfaced as a `danger`-styled sticky notification. + +### 4.9 Status-driven side effects in `sale.order.write` + +The 800-line `write` override (`models/sale_order.py:7225-8023`) does much more than save fields. When ADP status changes, it: + +**Auto-populates dates** (only if not already in `vals`): +| Status target | Date field auto-set | +|---|---| +| `assessment_scheduled` | `x_fc_assessment_start_date` = today | +| `assessment_completed` | `x_fc_assessment_end_date` = today, then auto-advances to `waiting_for_application` | +| `submitted` / `resubmitted` | `x_fc_claim_submission_date` = today | +| `accepted` | `x_fc_claim_acceptance_date` = today | +| `approved` / `approved_deduction` | `x_fc_claim_approval_date` = today | +| `billed` | `x_fc_billing_date` = today | + +**Required-field gates** (raise `UserError` if missing — also enforced by the dedicated wizards, but this is the second safety net): + +| Status target | Required fields | +|---|---| +| `assessment_completed` | assessment_start_date, assessment_end_date | +| `application_received` | assessment_start_date | +| `ready_submission` | assessment dates, reason_for_application, client_ref_1, client_ref_2, claim_authorization_date, previous_funding_date (if reason needs it), original_application, **`x_fc_signed_pages_11_12`** — NOTE: this gate uses the raw field, NOT `x_fc_has_signed_pages_11_12`. The application_received_wizard sidesteps this by populating one of the three computed-sources, but a direct write may fail; see gotcha #21 below. | +| `submitted` / `resubmitted` | final_submitted_application, xml_file, claim_submission_date | +| `approved` / `approved_deduction` | claim_number, claim_approval_date | +| `ready_bill` | adp_delivery_date, proof_of_delivery | +| `billed` | billing_date | +| `case_closed` | billing_date | +| MOD `contract_received` | x_fc_case_reference (HVMP Reference Number) | +| MOD `pod_submitted` | x_fc_mod_proof_of_delivery | + +**Authorizer required-field gate** (only fires when authorizer-related fields are in vals: `x_fc_sale_type`, `x_fc_authorizer_id`, `x_fc_authorizer_required`, `x_fc_adp_application_status`): +- Always required for: `adp`, `adp_odsp`, `wsib`, `march_of_dimes`, `muscular_dystrophy` +- Optional based on `x_fc_authorizer_required='yes'` for: `odsp`, `direct_private`, `insurance`, `other` +- Never required for: `rental` + +**Resume from on_hold checks 3-month assessment validity** — if `x_fc_assessment_expired` is True (>90 days since `x_fc_assessment_end_date`), blocks resume with `UserError` and a chatter warning showing days past expiry. The OT must redo the assessment. + +**`needs_correction` document clearing** — when status changes to `needs_correction`, the override: +1. Posts the existing `x_fc_final_submitted_application` and `x_fc_xml_file` to chatter for preservation. +2. **Clears** these fields plus `x_fc_final_application_filename`, `x_fc_xml_filename`, `x_fc_claim_submission_date`. +3. Posts a yellow warning notice. + +**Submission history auto-creation** — on `submitted` / `resubmitted`, creates a `fusion.submission.history` record (type `initial` or `resubmission`) and **resets `x_fc_acceptance_reminder_sent`** so the acceptance reminder fires again for the new submission cycle (2026-04 anti-spam fix). + +**Submission history result update** — on `accepted` or `rejected`, finds the most recent pending submission record (by date desc, limit 1) and calls `update_result()` to mark it. + +**MOD follow-up counter reset** — on ANY real MOD status change (detected via pre-write snapshot `old_mod_status_by_id`), resets `x_fc_mod_followup_month_count`, `_month_start`, `_escalated`, `_cap_notified`. This is the "new chapter" reset that makes the rolling cap work correctly. + +**MOD auto-stamp dates** — `quote_submitted` stamps `x_fc_case_submitted` if blank; `funding_approved` stamps `x_fc_case_approved` if blank. + +**POD signature auto-overlay** — when `x_fc_pod_signature` is set, calls `_apply_pod_signature_to_approval_form` (overlays the client signature onto the SA Mobility government PDF), and auto-advances SA Mobility from `ready_delivery` → `delivered`. Bypass via `skip_pod_signature_hook=True` context. + +**Field-mapping aware recompute** — reads ICP `fusion_claims.field_sale_type` / `field_so_client_type` and triggers `_compute_is_adp_sale` + line-level `_compute_adp_portions` if those fields (or their mapped equivalents) changed. + +**Sync to invoices** — when sync fields change (`x_fc_claim_number`, `x_fc_client_ref_1/2`, `x_fc_adp_delivery_date`, `x_fc_authorizer_id`, `x_fc_client_type`, `x_fc_primary_serial`, `x_fc_service_start/end_date`), calls `_sync_fields_to_invoices` to push to all linked invoices. + +### 4.10 Special transitions matrix + +Each special transition has its own allowed-from set (`models/sale_order.py:4426-4762`): + +| Action | Allowed FROM | Effect | +|---|---|---| +| `action_adp_put_on_hold` | `approved`, `approved_deduction` ONLY (2026-04 rule) | Status → `on_hold`, records previous status, resets hold-reminder flags | +| `action_adp_withdraw` | `submitted`, `resubmitted`, `accepted`, `ready_submission`, `on_hold` | Status → `withdrawn`, records previous status, sends withdrawal email | +| `action_adp_mark_rejected` | `submitted`, `resubmitted`, `accepted` | Status → `rejected` | +| `action_adp_mark_denied` | `submitted`, `resubmitted`, `accepted`, `approved`, `approved_deduction` | Status → `denied` | +| `action_adp_mark_needs_correction` | `submitted`, `resubmitted`, `accepted`, `approved`, `approved_deduction`, `rejected` | Status → `needs_correction` (clears submission docs, see §4.9) | +| `action_adp_cancel` | Anything except `case_closed`, `cancelled`, `expired`, `billed` | Status → `cancelled`, records previous status | +| `action_adp_reopen_cancelled` | `cancelled` AND `x_fc_cancel_reported_to_adp=False` | Restores to previous status. If `x_fc_cancel_reported_to_adp=True`, REJECTS and points to `duplicate_for_reassessment`. | +| `action_adp_reopen_expired` | `expired` | **Back-compat shim** — just calls `action_adp_duplicate_for_reassessment` (per 2026-04 policy: expired cases cannot be self-renewed) | +| `action_adp_duplicate_for_reassessment` | `expired`, `cancelled` | Creates a new SO with `x_fc_previous_sale_order_id` pointing here; new order starts at `quotation` | +| `action_adp_resubmit_from_denied` | `denied` | Status → `ready_submission` for fresh attempt | + +`_adp_chatter_transition(title, icon, colour_class, details)` is the shared helper for posting Bootstrap-styled cards (`alert-warning` / `alert-danger` / `alert-secondary` / `alert-info` / etc.). + +### 4.11 Cron-driven transitions + +- `_cron_adp_expire_approved` (3 AM daily): approved orders past `fusion_claims.adp_approval_expiry_months` (default 12) auto-transition to `expired`. Now scans `approved`, `approved_deduction`, **and** `on_hold` + `ready_delivery` (2026-04 update — funding window applies regardless of intermediate state). +- `_cron_auto_close_billed_cases` (daily): `billed` → `case_closed` 1 month after billing. +- `_cron_adp_hold_expiry_reminders` (9:30 AM daily): monthly reminder to client (authorizer **excluded** per 2026-04 policy) on on-hold cases; one final pre-expiry warning to client + authorizer ~30 days before funding window closes. + +## 5. The MOD (March of Dimes HVMP) workflow + +The Home and Vehicle Modification Program is its own complete lifecycle in `models/sale_order.py:438-985, 8590-9805`. + +### 5.1 Status (`x_fc_mod_status`) + +``` +quotation → assessment_scheduled → assessment_completed → processing_drawing → +quote_submitted → handed_off (client or authorizer) → awaiting_funding → + funding_approved → contract_received (PCA) → in_production → + project_complete → pod_submitted → case_closed + funding_denied → resubmit / cancel +on_hold / resume +``` + +### 5.2 Submission paths + +`mod_submission_path_wizard` records who submits the application to MOD: + +- **internal** — we submit; auto-triggers `_send_mod_vod_request_email` to the authorizer the first time it's selected (so they fill the Verification of Disability form and send it back). Settings: `company.x_fc_mod_vod_form` is the latest blank VOD form, auto-attached. +- **client** — client submits themselves. +- **authorizer** — OT submits. + +For non-internal paths, `_cron_mod_handoff_followup` creates an `mail.activity.type.mod_followup` activity for the office contact (or sales rep, per `company.x_fc_mod_followup_assignee_mode`) every 14 days. + +### 5.3 Follow-up rolling cap & cron architecture + +The MOD follow-up system has THREE distinct loops sharing one rolling 2-per-30-days cap (`fusion_claims.mod_followup_max_per_month` × `_window_days`): + +| Cron | Time | What it does | +|---|---|---| +| `_cron_mod_schedule_followups` | 8 AM daily | For orders in `quote_submitted` / `awaiting_funding`: if no open follow-up activity AND cap not reached AND `x_fc_mod_next_followup_date` is in the past, creates a new `mail.activity` (assignee = sales rep), `automated=True` to suppress Odoo's default "activity assigned" email. Increments month counter, bumps `x_fc_mod_next_followup_date` by 14 days. Per-run throttle `fusion_claims.mod_followup_schedule_max_per_cron_run` = 10 (default). | +| `_cron_mod_escalate_followups` | 10 AM daily | For overdue follow-up activities (`date_deadline <= today - 3 days`): processes oldest-first. If status moved past follow-up phase → unlinks stale activity. Else calls `_send_mod_followup_email`; if it returns True (sent), unlinks activity. If cap blocks the send, activity stays put so a human can action it. Per-run throttle `fusion_claims.mod_followup_max_per_cron_run` = 10. | +| `_cron_mod_handoff_followup` | 9 AM daily | For `handoff_to_client` orders: creates an activity (assignee from `company.x_fc_mod_followup_assignee_mode`) with deadline +3 days. Same rolling cap, dedup against existing open activities. Summary includes `({N} days since handoff)`. | + +`_mod_followup_cap_state()` is a **pure read** that returns `(within_cap, reset_needed, new_start, max_per_month)` — call it before creating activities or sending emails, then mutate the order's counters via the returned tuple. The `x_fc_mod_followup_cap_notified` one-shot flag posts a chatter note the first time the cap blocks a send in a given window, resets when the window expires. + +**Activity dedup pattern**: every MOD cron checks for an existing open `mail.activity` of type `mail_activity_type_mod_followup` on the order before creating a new one — prevents daily activity spam. + +`_send_mod_followup_email` enforces the cap internally and returns True only when an email actually goes out. The escalator cron uses that boolean to decide whether to unlink the activity. + +### 5.4 send_to_mod_wizard (multi-mode email composer) + +Three modes — `drawing`, `quotation`, `completion` — driven by the `mod_wizard_mode` context flag. The wizard pre-fills recipients differently per mode, validates per-mode required files, advances MOD status, and attaches the right documents. + +| Mode | Required uploads | Status target | TO | CC | Attachments | +|---|---|---|---|---|---| +| `drawing` | `drawing_file` | `quote_submitted`, stamps `x_fc_mod_drawing_submitted_date` | Client | MOD partner (auto-created by email), Authorizer, Sales Rep | Quotation PDF (rendered from `fusion_claims.action_report_mod_quotation`) + Drawing + Initial Photos | +| `quotation` | none — just re-send | (no change) | Client | MOD partner, Authorizer, Sales Rep | Quotation PDF + (toggle-controlled) Drawing + Initial Photos | +| `completion` | `completion_photos_file` AND `pod_file` | `pod_submitted`, stamps `x_fc_mod_pod_submitted_date` | Case worker (or MOD partner fallback) | Authorizer, Sales Rep | Completion Photos + POD | + +**Subject line includes HVMP reference if set**: `"{prefix} - {ref} - {client_name}"` when `x_fc_case_reference` is populated; otherwise `"{prefix} - {client_name} - {order.name}"`. + +`_DOC_NAMES` dict (`wizard/send_to_mod_wizard.py` module-level) maps internal field name → user-facing label: +```python +'x_fc_mod_drawing' → 'Drawing' +'x_fc_mod_initial_photos' → 'Assessment Photos' +'x_fc_mod_pca_document' → 'Payment Commitment Agreement' +'x_fc_mod_proof_of_delivery' → 'Proof of Delivery' +'x_fc_mod_completion_photos' → 'Completion Photos' +``` + +`_pro_name(field_name, order, orig_filename)` builds professional attachment names: `"{display_name} - {client_name_underscored} - {order.name}.{ext}"`. Example: `"Drawing - John_Doe - S29958.pdf"`. Spaces and commas are stripped from the client name. + +`_get_field_att(order, field_name)` — finds the existing `ir.attachment` for a binary field (Odoo auto-creates one per `attachment=True` field), **renames it** in-place to the pro format, and returns the record. Don't create a new attachment for binary fields — the helper reuses the existing one. + +The MOD partner is auto-created on first use via `_get_mod_partner(email)` — `name='March of Dimes Canada (HVMP)'`, `is_company=True`, `company_type='company'`. + +### 5.5 Documents tracked on the SO + +Binary fields prefixed `x_fc_mod_*`: `drawing`, `initial_photos`, `pca_document` (Payment Commitment Agreement), `proof_of_delivery`, `completion_photos`, `application_form_doc`, `vod_letter`, `notice_of_assessment`, `property_tax`, `proposal_doc`. Plus the audit-trail booleans `x_fc_mod_trail_*` (computed by `_compute_mod_trail` / `_compute_mod_audit_trail`). + +### 5.6 MOD invoicing + +`_create_mod_invoice(partner_id, invoice_lines, portion_type, label)` and `action_mod_send_invoice` on `account.move`. Invoices use `x_fc_invoice_type = 'march_of_dimes'`; the MOD invoice template + send action attach the PDF and email the case worker. Distinct from ADP split invoicing. + +## 6. ADP/Client portion calculation rules + +The heart of the billing logic lives in `models/sale_order_line.py:148-267` (`_compute_adp_portions`) and is mirrored in `models/account_move_line.py:96-164` for invoice lines. + +For each line, the algorithm: + +1. **Skip if not ADP sale** — `order._is_adp_sale()` returns False unless `x_fc_sale_type` contains `adp`. ADP portion = 0, client portion = 0 (the line is "regular" billing). +2. **NON-ADP funded products → client 100%**. `product.product.is_non_adp_funded()` returns True when the device code (case-insensitive, prefix match) starts with: `NON-ADP`, `NON-FUNDED`, `UNFUNDED`, `NOT-FUNDED`, `ACS`, `ODS`, `OWP`. These are explicitly out-of-ADP-scope codes used to bill ancillary items on a single SO. +3. **Product without a valid ADP device code → client 100%**. The line must have a code that resolves against `fusion.adp.device.code` (`active=True`). Code lookup order on the product template (see `sale_order_line._get_adp_device_code`): + 1. `x_fc_adp_device_code` (this module's field) + 2. `x_adp_code` (Studio/legacy field) + 3. `default_code` (internal reference) + 4. Code in parentheses in the product name, e.g. `[MXA-1618] GEOMATRIX SILVERBACK MAX BACKREST - ACTIVE (SE0001109)` → `SE0001109`. The strict regex on `account.move.line` is `r'\(([A-Z]{2}\d{7})\)'`. +4. **Verification complete AND line not approved → client 100%**. (`x_fc_device_verification_complete=True` and `x_fc_adp_approved=False`.) +5. **Otherwise — split by client type:** + - **REG** → 75% ADP / 25% client. + - **ODS, OWP, ACS, LTC, SEN, CCA** → 100% ADP / 0% client. +6. **Deductions** (applied after the base split): + - **PCT** — `effective_adp_pct = base_adp_pct × (deduction_value / 100)`; client gets the rest. + - **AMT** — subtract `deduction_value` (per unit) from the base ADP portion (floored at 0); client gets the rest. + +### 6.1 Price-source priority for the calculation + +When computing the ADP base (NOT `price_unit` × qty): + +1. `product.product_tmpl_id.x_fc_adp_price` (this module's stored price) +2. line `x_fc_adp_max_price` (override at the line level) +3. line `price_unit` (last resort) + +Invoice lines (`account_move_line._compute_adp_portions`) prefer `fusion.adp.device.code.adp_price` directly via a `search` — slightly different priority chain, but the deduction maths is identical. + +Always use `product.product.get_adp_price()` / `.get_adp_device_code()` rather than reading the fields directly: those helpers honour the legacy field-mapping ICP (see §15). + +### 6.2 Recomputation triggers + +`_compute_adp_portions` `@api.depends` on: `price_subtotal`, `product_uom_qty`, `price_unit`, `product_id`, `order_id.x_fc_sale_type`, `order_id.x_fc_client_type`, `order_id.x_fc_device_verification_complete`, `x_fc_deduction_type`, `x_fc_deduction_value`, `x_fc_adp_max_price`, `x_fc_adp_approved`. Header rollups `x_fc_adp_portion_total` / `x_fc_client_portion_total` recompute on any line change. + +## 7. Split invoicing (the model under §3.1) + +ADP REG sales typically yield **two** invoices on the same SO: + +- **Client invoice** (`x_fc_adp_invoice_portion = 'client'`) — 25% in REG. `action_create_client_invoice` (`models/sale_order.py:5321-5417`) does NOT require device verification (clients can pay before ADP approval) but **blocks** modification-reason cases (`mod_non_adp`, `mod_adp`) until approval is in. A `<5y` replacement triggers a chatter warning. +- **ADP invoice** (`x_fc_adp_invoice_portion = 'adp'`) — 75% in REG, 100% for ODS/OWP/ACS/LTC/SEN/CCA. `action_create_adp_invoice` requires verification + POD (`x_fc_proof_of_delivery`). + +Both link back to the SO via `x_fc_source_sale_order_id` (indexed). Per-order quick-access fields `x_fc_adp_invoice_id` and `x_fc_client_invoice_id` can be set manually for invoices that pre-date this module's tracking. + +### 7.1 Two-way sync + +`account.move.action_sync_to_sale_order` (`models/account_move.py:698-789`) treats the invoice as source of truth: copies `x_fc_claim_number`, `x_fc_client_ref_1/2`, `x_fc_adp_delivery_date`, `x_fc_authorizer_id`, `x_fc_client_type`, `x_fc_service_start/end_date`, `x_fc_primary_serial` back to the SO, then calls `sale.order._sync_fields_to_invoices` to push the values out to all sibling invoices. Serial numbers sync line-by-line (`_sync_line_fields_to_sale_order`) via `sale_line_ids` → `invoice_line_ids` mapping. Loop prevention: pass `with_context(skip_sync=True)` on the writes. + +Mark `is_manually_modified = True` on an invoice to opt it out of the SO → invoice sync direction (the model honours this flag in `_sync_fields_to_invoices`). + +**`_sync_fields_to_invoices` body** (`models/sale_order.py:8067-8145`): +1. For each non-cancelled invoice on the order: +2. Build a `vals` dict of `x_fc_*` fields from the SO, **but ONLY include each key if the field exists on `account.move`** (`in invoice._fields` check). This is defensive — the module doesn't assume Studio fields are present. +3. Write with `skip_sync=True` to prevent recursion. +4. After all invoices are updated, call `_sync_serial_numbers_to_invoices`. + +**`_sync_serial_numbers_to_invoices` body** (`models/sale_order.py:8147-8197`): +- Uses **dynamic field mappings** from settings (`mappings['sol_serial']` and `mappings['aml_serial']`). +- **Each SO line syncs its OWN serial to its linked invoice lines** — no header-fallback. If the SOL has no serial, the AML's serial is left alone. +- Searches via `sale_line_ids` link to find matching invoice lines. +- Bypass: `skip_sync=True` context returns early. + +### 7.2 Sibling totals + +`x_fc_sibling_adp_total` / `x_fc_sibling_client_total` (computed) read the **other** portion's total off the source SO so the PDF report can always show both halves even before the sibling invoice exists. + +### 7.3 `sale.advance.payment.inv` extension + +`wizard/sale_advance_payment_inv.py` adds two new options to the **Create Invoice** wizard's `advance_payment_method`: + +- `adp_client` — "ADP Client Invoice (25%)" — only valid when client_type = `REG`; raises if not ADP sale. +- `adp_portion` — "ADP Invoice (75%/100%)" — for any ADP client type. + +Both route through `sale.order._create_adp_split_invoice(invoice_type='client'|'adp')`, the same method the per-order action buttons use. This means the standard "Create Invoice" UI also produces split invoices when used on ADP orders. + +### 7.4 Payment registration extension + +`wizard/account_payment_register.py` extends the payment register wizard with: + +- `x_fc_card_last_four` (size 4) — required when paying via a card method +- `x_fc_payment_note` — free text +- `x_fc_is_card_payment` (computed) — reads `payment_method_line_id.x_fc_requires_card_digits` (set on the journal form via `views/account_journal_views.xml`). Fallback: keyword match on method name (`credit`, `visa`, `mastercard`, `amex`) + +`action_create_payments` override validates the last-4 input is **exactly 4 numeric digits** before delegating. Values persist onto the created `account.payment` via the `x_fc_card_last_four` / `x_fc_payment_note` fields (`models/account_payment.py`). + +The journal form view adds the "Req. Card #" column to **both** inbound and outbound payment method lists. + +### 7.5 The `_create_adp_split_invoice` body (`models/sale_order.py:5553-5960`) + +Things to know when reading or modifying this 400-line core method: + +1. **Customer switch on ADP invoice** — when `invoice_type='adp'`, the invoice's `partner_id` is set to the ADP partner record (searched by name: `'ADP (Assistive Device Program)'`, `'Assistive Device Program'`, `'ADP'`, or `'ADP -'`). The original client becomes `partner_shipping_id`. So an ADP invoice is billed to ADP, shipped to the client. Client invoices keep the original customer. +2. **`x_fc_invoice_type` is sale-type aware** — client invoices always get `'adp_client'`. ADP-portion invoices use the sale type directly (`adp`, `adp_odsp`, `odsp`, `wsib`, ...), preserving funder context for downstream reports. +3. **`x_fc_adp_billing_status='waiting'`** is auto-set on creation for ADP invoices (kicks off the billing-deadline cron). +4. **`invoice_origin` carries the portion suffix** — `S29958 (Client 25%)` or `S29958 (ADP 75%)`. This is what surfaces in the user-facing breadcrumbs. +5. **Price-mismatch detection AND auto-correction** — when the device-code DB price differs from the product's `x_fc_adp_price` by > $0.01: + - Posts a chatter warning listing each mismatched product. + - Auto-updates the product's `x_fc_adp_price` to the DB price (only for products WITHOUT a `x_fc_adp_device_code_id` link — products with the Many2one are kept managed via the link instead). +6. **`[NOT APPROVED - 100% Client]`** suffix added to the line name on client invoices when an ADP device exists in DB but wasn't approved. Useful audit trail. +7. **Unapproved + non-ADP-funded items are SKIPPED from the ADP invoice entirely** (not even a $0 line). They appear only on the client invoice. +8. **`Markup` chatter cards** at the end of the method are styled with Bootstrap alerts — `alert-primary` (blue) for client invoice, `alert-success` (green) for ADP invoice, both with a "View Invoice" link. + +### 7.6 `_get_invoiceable_lines` override + +`sale.order._get_invoiceable_lines` is overridden (`models/sale_order.py:5965`) to include **ALL** `line_section`, `line_subsection`, and `line_note` lines regardless of position. Standard Odoo only includes display lines that have an invoiceable product line AFTER them — which drops warranty notes, refund policy sections, etc. placed at the bottom of the order. This override keeps them on every invoice. + +### 7.7 `_prepare_invoice` override + invoice type normalization + +`sale.order._prepare_invoice` is overridden (`models/sale_order.py:5995-6021`) to copy ADP fields to the invoice on creation. It **normalizes `x_fc_sale_type`** to lowercase and validates against the selection. If the normalized value isn't in the valid list: +- Contains `'adp'` → falls back to `'adp'` +- Otherwise → falls back to `'other'` + +Note: when called by `_create_adp_split_invoice`, this base `x_fc_invoice_type` gets immediately overwritten — client invoices → `'adp_client'`, ADP invoices → the raw sale_type. The override in `_prepare_invoice` matters mainly for invoices created OUTSIDE the split flow (e.g., via the standard "Create Invoice" button without the ADP method selection). + +### 7.8 Document chatter helper (`_post_document_to_chatter`) + +The shared helper for document audit trail (`models/sale_order.py:6026-6087`): +- Default mode — references the existing `ir.attachment` (Odoo creates one for each `attachment=True` binary field). +- `preserve_copy=True` — creates a SEPARATE `_archived.` copy. Used when the original is about to be deleted/replaced (e.g., needs_correction clearing) and we need to snapshot before Odoo's attachment is removed. +- Posts a `{label} uploaded by {user}` chatter message with the attachment attached. + +Companion utilities: `_build_attachment_name(field_name)` builds the user-facing filename, `_get_document_attachment(field_name)` resolves the existing attachment, `_prepare_attachment_for_email(attachment, field_name)` renames it for outbound mail. + +### 7.9 MOD action method index + +All on `sale.order`, all in `models/sale_order.py:8594-8901`: + +| Method | What it does | Status target | +|---|---|---| +| `action_mod_schedule_assessment` | bare write | `assessment_scheduled`, stamps `x_fc_mod_assessment_scheduled_date` | +| `action_mod_complete_assessment` | bare write | `assessment_completed`, stamps `x_fc_mod_assessment_completed_date` | +| `action_mod_processing_drawing` | writes `processing_drawings`, then opens `send_to_mod_wizard` in `mod_wizard_mode='drawing'` | progresses to `quote_submitted` via the wizard | +| `action_mod_awaiting_funding` | opens `mod_awaiting_funding_wizard` | `awaiting_funding` | +| `action_mod_funding_approved` | opens `mod_funding_approved_wizard` (records case worker + HVMP ref) | `funding_approved` | +| `action_mod_funding_denied` | opens `mod_funding_denied_wizard` (category + reason) — 2026-04 was bare write, now captures denial reason | `funding_denied` | +| `action_mod_contract_received` | opens `mod_pca_received_wizard` (PCA upload + full/partial invoice split) | `contract_received` | +| `action_mod_in_production` | bare write | `in_production`, stamps `x_fc_mod_production_started_date` | +| `action_mod_project_complete` | bare write | `project_complete`, stamps `x_fc_mod_project_completed_date` | +| `action_mod_pod_submitted` | opens `send_to_mod_wizard` in `mod_wizard_mode='completion'` | `pod_submitted` via the wizard | +| `action_mod_close_case` | bare write | `case_closed`, stamps `x_fc_mod_case_closed_date` | +| `action_mod_on_hold` | saves previous status into `x_fc_mod_previous_status_before_hold` (2026-04 fix — was being lost) | `on_hold` | +| `action_mod_resume` | restores from `x_fc_mod_previous_status_before_hold` (default `in_production`) — 2026-04 fix — was hardcoded to `in_production` | previous status | +| `action_mod_set_submission_path` | opens `mod_submission_path_wizard` (internal/client/authorizer) | n/a — sets `x_fc_mod_submitted_by` | +| `action_mod_request_vod` | emails authorizer the blank VOD form (also auto-fired when internal path first selected) | n/a | +| `action_mod_handoff_to_client` | only when `submitted_by ∈ (client, authorizer)`; requires `proposal_doc` + `drawing` | `handoff_to_client`, stamps `x_fc_mod_handoff_date` | +| `action_mod_confirmed_submission` | opens `mod_submission_confirmed_wizard` | confirms client/authorizer submitted | +| `action_mod_resubmit_from_denied` | opens `mod_resubmit_wizard`; only from `funding_denied` | back to earlier status via wizard | +| `action_mod_cancel_from_denied` | only from `funding_denied` | `cancelled` | +| `action_mod_reopen_cancelled` | only from `cancelled` | `need_to_schedule`, clears `x_fc_mod_funding_denial_reason` | +| `action_cancel` (override on `sale.order`) | when the SO is cancelled (built-in), also force-sets `x_fc_mod_status='cancelled'` for MOD orders | `cancelled` | + +`_get_mod_partner()` — finds or **creates** the MOD partner by email `fusion_claims.mod_default_email` (default `hvmp@marchofdimes.ca`). New record is created with name `'March of Dimes Canada (HVMP)'`, `is_company=True`. + +`_create_mod_invoice(partner_id, invoice_lines, portion_type, label)` — the shared MOD invoice creator. Sets `x_fc_invoice_type='march_of_dimes'`, `x_fc_adp_invoice_portion=portion_type`, populates `narration` with HVMP reference + client + case worker + SO + vendor code as an HTML block. + +### 7.10 Stage 2 invoice sync (`_sync_approval_to_invoices`) — re-posts posted invoices + +When the device approval wizard's `mark_as_approved` mode runs `_sync_approval_to_invoices(updated_lines)`, the method walks every non-cancelled invoice linked to the order and rewrites line `price_unit` + suffixes the line name based on the new approval state: + +| State | Client invoice line | ADP invoice line | +|---|---|---| +| Non-ADP funded item (code in NON-ADP/NON-FUNDED/etc.) | `price_unit = full subtotal`, name unchanged | `price_unit = 0`, name suffixed `[NON-ADP - Excluded]` | +| Unapproved ADP device | `price_unit = full subtotal`, name suffixed `[NOT APPROVED - 100% Client]` | `price_unit = 0`, name suffixed `[NOT APPROVED - Excluded]` | +| Approved ADP device | `price_unit = x_fc_client_portion / qty`, name unchanged | `price_unit = x_fc_adp_portion / qty`, name unchanged | + +For **POSTED invoices**, the method calls `invoice.button_draft() → write → action_post()` — i.e. it resets to draft, rewrites the lines, then re-posts. If the posted invoice has already been exported to ADP or emailed to the client, **the reset-then-repost cycle can break sequence numbers, re-fire post-actions, or invalidate exports**. Set `is_manually_modified=True` on the invoice to opt it out of this sync direction if you need to lock it. + +The wizard reports the count of `invoices_updated` in the success notification. + +### 7.11 Activity scheduling pattern (`_schedule_or_renew_adp_activity`) + +On `account.move`, the helper `_schedule_or_renew_adp_activity(activity_type_xmlid, user_id, date_deadline, summary, note)` is the shared pattern for ADP billing/correction activities: + +- **Finds existing** activity of the same type AND same user_id on the record. +- **If found**: UPDATES `date_deadline`, `summary`, `note` (preserves existing note if new is blank). +- **If not found**: creates new via `activity_schedule`. + +Companion `_complete_adp_activities(activity_type_xmlid)` calls `activity.action_feedback(feedback='Completed automatically')` on every activity of the given type — used to auto-close ADP Billing activities when the invoice flips to `submitted`/`payment_issued`, and ADP Correction activities when flipped to `resubmitted`/`payment_issued`. + +This dedup pattern shows up in three places: ADP billing reminders, ADP correction reminders, and MOD follow-ups — different fields, same logic. + +### 7.12 MOD PCA dual-invoice split (`wizard/mod_pca_received_wizard.py`) + +When `contract_received` is set via this wizard, the user picks `approval_type`: + +- **`full`** — creates ONE invoice (to the MOD partner) for the full order amount; client owes nothing. +- **`partial`** — creates TWO invoices simultaneously: + - **MOD invoice**: each line × `(approved_amount / order.amount_untaxed)` = MOD's share. + - **Client invoice**: each line × `(1 - ratio)` = client's share. If the client's per-line amount is ≤ 0 (fully covered), the line name is suffixed `\n[Covered by March of Dimes]` and `price_unit=0`. + +Live preview (`preview_line_ids` — `fusion_claims.mod.funding.approved.wizard.line` transient lines) updates via `_onchange_compute_preview` as the user types `approved_amount`. Both invoices flow through `sale.order._create_mod_invoice(partner_id, invoice_lines, portion_type, label)`. + +**`portion_type='adp'` for the MOD partial invoice**: the wizard reuses the `x_fc_adp_invoice_portion` field (set to `'adp'`) for the MOD invoice as well as the ADP one — so `x_fc_adp_invoice_portion == 'adp'` does NOT mean ADP. Always check `x_fc_invoice_type` (`'march_of_dimes'` vs `'adp'`) when disambiguating. + +## 8. ADP billing lifecycle & posting schedule + +### 8.1 Posting schedule mixin + +`fusion_claims.adp.posting.schedule.mixin` (`models/adp_posting_schedule.py`) is inherited by `sale.order`, `account.move`, and `fusion_claims.adp.export.record`. + +``` +posting cycle = every `fusion_claims.adp_posting_frequency_days` days (default 14) + starting from `fusion_claims.adp_posting_base_date` (default 2026-01-23) +submission deadline = Wednesday 6 PM of the posting week +delivery reminder = Tuesday of the posting week +billing reminder = Monday of the posting week +payment processed = posting day + 7 +payment received = posting day + 10 +``` + +Methods: `_get_next_posting_date`, `_get_current_posting_date`, `_get_posting_week_monday/tuesday/wednesday`, `_get_expected_payment_date`, `_get_payment_processed_date`, `_is_past_submission_deadline` (checks past 6 PM Wed), `_get_adp_billing_reminder_user`, `_get_adp_correction_reminder_users`. + +### 8.2 Billing status (post-export) + +`x_fc_adp_billing_status` on `account.move`: + +``` +not_applicable → waiting → submitted → resubmitted / need_correction → payment_issued / cancelled +``` + +Cron `_cron_renew_billing_reminders` (`account.move`) reschedules overdue `mail_activity_type_adp_billing` activities on `waiting` invoices to the next posting week's Monday. `_cron_renew_correction_reminders` does the same for `need_correction` against `mail_activity_type_adp_correction` activities (scheduled for **all** users in `fusion_claims.adp_correction_reminder_user_ids`). + +Auto-write hook: when `payment_state` transitions to `paid` / `in_payment` on an ADP invoice currently `submitted` / `resubmitted` / `waiting`, the billing status flips to `payment_issued` automatically (`account.move.write` override at `models/account_move.py:884-892`). + +## 9. ADP claim export + +`wizard/adp_export_wizard.py` (`fusion_claims.export.wizard`): + +- Pulls invoices via `active_ids`, filters to `out_invoice` / `out_refund`. +- For each line, regenerates the comma-separated ADP claim line using the device-code database price, then **verifies** the stored `x_fc_adp_portion` / `x_fc_client_portion` against the recomputed values (tolerance `$0.01 × qty`). **CRITICAL: Verification mismatches RAISE a `UserError` that BLOCKS the export** — the previous draft of this doc was wrong; mismatches don't just warn, they hard-stop and the user must fix invoices before re-trying. +- Skips lines whose device code resolves to one of `FUNDING`, `NON-FUNDED`, `N/A`, `NA`, `NON-ADP`, `LABOUR`, `DELIVERY`, or empty. +- **Per-unit-quantity expansion**: ADP expects `qty=1` per line. A line with `qty=3` generates 3 export rows, each with the per-unit portion (`stored_portion / qty`). +- **Filename format is fixed**: `{vendor_code}_{YYYY-MM-DD}.txt` — ADP rejects renamed files. If the same filename already exists in `fusion_claims.adp.export.record`, the wizard adds a yellow warning but still proceeds; the user must manually rename for the resubmission case. +- **CSV format** (no header) — 16 fields per row, comma-separated, with 3 reserved/empty fields at positions 7, 8, 11 (1-indexed): + ``` + vendor_code, claim_number, client_ref_2, invoice_number, invoice_date, + delivery_date, [empty], [empty], device_code, serial_number, + [empty], qty(=1), device_price, adp_portion, client_portion, client_type + ``` +- Creates a `fusion_claims.adp.export.record` (`models/adp_export_record.py`) — the file lives there, the model auto-extracts invoice numbers from the file content and back-links them via `invoice_ids`. Records are grouped by `year` / `month` / `posting_period_label` in the menu. + +### 9.1 ADP export record helpers (`models/adp_export_record.py`) + +| Method | Purpose | +|---|---| +| `_parse_export_filename(filename)` | Regex `^(.+?)_(\d{4}-\d{2}-\d{2})\.\w+$` extracts `(vendor_code, file_date)` from a filename. Returns `(None, None)` if unparseable. | +| `_get_posting_period_for_file(file_date)` | Maps a file date to the **posting period** it belongs to. If `file_date <= current_posting`, returns `current_posting`. Otherwise returns `current_posting + frequency` (next posting). Handles pre-base-date floor division correctly. | +| `_collect_subfolder_ids(Document, parent_ids)` | Recursively finds all `documents.document` subfolder IDs under given parents (used by migration helper). | +| `migrate_from_documents()` | One-shot migration — searches `documents.document` records under "ADP Billing Files" hierarchy, creates export records, archives the originals (`active=False`). Idempotent (skips by filename). Called via "Migrate ADP Export Files" button in Settings. Only runs if the `documents` app is installed. | +| `action_download` | Single-file download via `/web/content` URL. | +| `action_download_zip` | **Multi-record action** — zips all selected export records into a single `ADP_Export_Files_{YYYY-MM-DD}.zip` via `zipfile.ZIP_DEFLATED` and serves it as a transient `ir.attachment`. | +| `action_view_invoices` | Opens the list of `invoice_ids` linked to the record. | +- **Per-invoice flags updated** on each invoice in the export: `adp_exported=True`, `adp_export_date=now`, `adp_export_count += 1`. +- Settings: `fusion_claims.vendor_code`. + +The `_validate_dates` helper warns on future invoice/delivery dates and delivery-after-invoice mismatches — these are soft warnings, only the calculation mismatches are hard blocks. + +## 10. ADP Mobility Manual / device codes + +`fusion.adp.device.code` (`models/fusion_adp_device_code.py`, 428 lines): + +Fields: `device_code` (unique, indexed, required), `device_type` (indexed), `manufacturer`, `build_type` (modular / custom_fabricated), `device_description`, `adp_price`, `max_quantity`, `sn_required`, `active`, `last_updated`. Display name auto-formats as `CODE - DESCRIPTION ($PRICE)`. + +Loaded on every install/upgrade from `data/device_codes/adp_mobility_manual.json`. Importable via JSON or CSV (`device_import_wizard`, manager-only). The model defines `_clean_text`, `_parse_price` utilities; CSV expects columns `Device Type, Manufacturer, Device Description, Device Code, Qty, Approved Price, Serial`. + +Write override (`fusion_adp_device_code.write`): when `adp_price` or `device_code` changes, propagates to all `product.template.x_fc_adp_device_code_id` linked products (`x_fc_adp_price`, `x_fc_adp_device_code` denormalized fields). + +### 10.1 Linking products + +`product.template` (`models/product_template.py`): +- `x_fc_is_adp_product` (bool) — toggle to mark ADP product. `@api.constrains` requires a `x_fc_adp_device_code_id` when True. +- `x_fc_adp_device_code_id` (Many2one) — the canonical link. +- `x_fc_adp_device_code` (Char) — denormalized for query/legacy use. +- `x_fc_adp_price` (Float) — denormalized for query/legacy use. +- `x_fc_adp_device_type`, `x_fc_adp_build_type`, `x_fc_adp_max_quantity` — all `related` fields stored. +- Also: `x_fc_security_deposit_type/amount/percent` for rentals (sibling module fusion_rental). +- `action_sync_adp_price_from_database` — admin button to re-sync the link from the JSON for a given product. + +## 11. Client profile, applications, XML parser + +### 11.1 fusion.client.profile (`models/client_profile.py`) + +One record per client (linked to `res.partner`). Stores personal info, address, contact, benefit eligibility (ODSP/OWP/ACSD/WSIB/VAC), and latest medical condition + mobility status. `_compute_claim_stats` aggregates `claim_count`, `total_adp_funded`, `total_client_portion`, `total_amount` across the partner's `sale_order_ids` filtered to ADP sale types. `_compute_ai_analysis` writes a human-readable summary into `ai_summary` and risk flags (`ai_risk_flags`) — frequency analysis (avg days between applications), multiple-replacement detection. + +### 11.2 fusion.adp.application.data (`models/adp_application_data.py`, 670 lines) + +One record per submitted ADP application (parsed from XML). Captures **all ~300 XML fields** for round-trip fidelity: +- Section 1: applicant biographical + benefits confirmation. +- Section 2 (devices/eligibility): medical condition, mobility status, previously-funded checkboxes (12 device types), currently-required device checkboxes (16 device types). +- Section 2a: Ambulation Aids / walkers + paediatric. +- Section 2b: Manual Wheelchairs. +- Section 2c: Power Bases / Scooters. +- Section 2d: Positioning / Seating. + +Each section captures every confirmation checkbox, every prescription field (seat width/depth/height, handle height, brakes, wheels, back support, custom modifications, etc.). `raw_xml` stores the original; `xml_data_json` stores a dot-notation JSON dict for export. + +### 11.3 fusion.xml.parser (`models/xml_parser.py`, 772 lines) + +`AbstractModel`. Public API: +- `parse_from_binary(binary_data, sale_order=None)` — base64 → XML → records. +- `parse_and_create(xml_content, sale_order=None)` — string → records. +- `reparse_existing(app_data_record)` — re-parse existing `raw_xml` in place. + +Flow: XML → 1:1 dot-notation JSON dict (`_xml_to_json`) → model values (`_json_to_model_vals`) → find-or-create `fusion.client.profile` keyed by health card → create `fusion.adp.application.data` → auto-link the application's authorizer to the SO by ADP registration number (`x_fc_authorizer_number` on `res.partner` → matched against `authorizer_adp_number` from the XML). + +Auto-parse: setting `fusion_claims.auto_parse_xml = True` runs the parser when `x_fc_xml_file` is uploaded to a SO. + +Bulk import: `xml_import_wizard` (manager-only) processes multiple XML files at once. + +**Profile matching priority** (`_find_or_create_profile`): +1. Health Card Number (exact). +2. First + Last name + DOB (case-insensitive, exact DOB). + +If neither matches, a new profile is created. Either way, `profile_vals` is always written — existing profiles get updated with the latest XML data, so the most recent application's personal info wins. + +**Authorizer auto-linking** (`_link_authorizer_by_adp_number`): +1. Match `res.partner` by `x_fc_authorizer_number` (exact). +2. Fallback: fuzzy name match (`name ilike "{first} {last}"` OR `"{last}, {first}"`). +3. **Learn-as-we-go enrichment**: when a partner matches by NAME AND has no `x_fc_authorizer_number`, the parser **writes the ADP number to the partner**. Future imports for the same OT then match by number directly. This is how the authorizer database gets populated over time without manual data entry. +4. Skips if SO already has `x_fc_authorizer_id` set; skips ADP numbers that are `'NA'`, `'N/A'`, or empty. + +**Date parsing** (`_pd`): tries `%Y/%m/%d`, `%Y-%m-%d`, `%Y%m%d` in that order. Returns `False` on failure. Used for every date field in the XML. + +**Section 4 fields** also captured: Vendor 1 + Vendor 2 (business name, ADP number, representative, position, location, phone+ext, sign date); Equipment Spec table (`Table2.Row1.Cell1–5`); POD (received_by, date); Note to ADP section markers (boolean per section: `section1`, `section2a–d`, `section3and4`) plus vendor replacement / custom / funding chart / letter free-text notes. + +## 12. AI integration + +### 12.1 Native AI agent (Odoo 19 `ai` module) + +`data/ai_agent_data.xml`: +- 3 `ir.actions.server` records with `use_in_ai=True` — call methods on `ai.agent` (extended via `models/ai_agent_ext.py`): + - `_fc_tool_search_clients(search_term, city_filter, condition_filter)` + - `_fc_tool_client_details(profile_id)` + - `_fc_tool_claims_stats()` +- One `ai.topic` ("Fusion Claims Client Intelligence") bundles them. +- One `ai.agent` ("Fusion Claims Intelligence") with the topic + system prompt + model `gpt-4.1` + `analytical` response style. + +`fusion.client.profile.action_open_ai_chat` opens a chat (channel of type `ai_chat`) seeded with the client's context. + +### 12.2 Legacy `fusion.client.chat.session` (`models/client_chat.py`) + +Hand-rolled OpenAI chat layer. Calls `https://api.openai.com/v1/chat/completions` directly. Settings: `fusion_claims.ai_api_key` (manager-only), `fusion_claims.ai_model` (`gpt-4o-mini` / `gpt-4o` / `gpt-4.1-mini` / `gpt-4.1`). Falls back to local DB-only responses (`_generate_local_response`) when no key is configured. Kept for back-compat; the native AI agent above is the preferred path going forward. + +## 13. Technician tasks integration + +`models/technician_task.py` (674 lines) extends `fusion.technician.task` (from `fusion_tasks`): + +- Adds `sale_order_id` + `purchase_order_id` (validates exactly one is set unless task is from cross-instance sync or is an `ltc_visit`). +- Onchange auto-fills `partner_id` + address from the SO/PO shipping/destination address. +- Hook overrides: + - `_create_vals_fill` — copy partner + address into create vals. + - `_on_create_post_actions` — chatter notice; if `mark_ready_for_delivery` context flag is set, advance SO to `ready_delivery`; if `mark_odsp_ready_for_delivery`, advance ODSP order. + - `_check_completion_requirements` — rental pickup tasks block completion until inspection done. + - `_on_complete_extra` — ODSP `ready_delivery` → `delivered`; rental pickup → write inspection back to SO and refund security deposit (if passed) or schedule activity (if flagged). + - `_on_cancel_extra` — delivery cancellation reverts SO to `x_fc_status_before_delivery` (if no other delivery tasks remain), sends cancellation email. +- Rental inspection fields: `rental_inspection_condition` (excellent/good/fair/damaged), `rental_inspection_notes`, `rental_inspection_photo_ids` (max 6, opens in FileViewer via gallery hook). +- Email overrides: routes through `sale_order._email_build` so technician emails match the rest of the project's email style; CCs the SO sales rep + office notification recipients. + +## 14. Page 11/12 signing workflow + +ADP form 13027E pages 11 & 12 require client and authorizer signatures. + +### 14.1 Page 11 (client consent) + +`fusion.page11.sign.request` (`models/page11_sign_request.py`): +- Standalone signing request: random `access_token` (UUID4), public URL `/page11/sign/`, `state` of `draft / sent / signed / expired / cancelled`. +- Signer can be client, spouse, parent, legal_guardian, power_of_attorney, public_trustee. Agent details (full address) captured when not the client. +- `consent_signed_by`: `applicant` (client signs themselves) or `agent` (anyone else). `send_page11_wizard` auto-sets this based on `signer_type`. +- Public security ACL: `base.group_public` has read-only access to `fusion.page11.sign.request` so the public sign page can resolve the token. +- `_generate_signed_pdf` uses `fusion.pdf.template` (from another module; the active template is named `adp_page_11` or `page 11`) to render a filled PDF, then writes the result to `x_fc_signed_pages_11_12` on the SO + creates an `ir.attachment`. +- `send_page11_wizard` opens the composer. Default expiry 7 days. Pre-fills signer from `partner_id.name` + `partner_id.email`. Signer relationship auto-fills from the signer_type label (spouse → "Spouse", etc.). +- Form view header buttons: Resend Email (sent/expired), Request New Signature (signed/cancelled, with confirm dialog), Cancel (draft/sent). Statusbar: draft → sent → signed. +- Cron `_cron_expire_requests` (2 AM daily) marks expired unsigned requests. + +### 14.2 Page 12 (authorizer + vendor) + +Tracked directly on the SO via two booleans + signer fields: +- `x_fc_page12_authorizer_signed` — OT signs after page 11 is received. +- `x_fc_page12_vendor_signed` + `x_fc_page12_vendor_signer_id` — designated vendor signer (`fusion_claims.designated_vendor_signer` setting) signs on the company's behalf. + +### 14.3 SA Mobility + OW Discretionary signing + +Two government-issued PDFs are filled by Python (using `pdfrw`): + +| Form | Template | Wizard | +|---|---|---| +| **SA Mobility** form (ODSP SA division) | `static/src/pdf/sa_mobility_form_template.pdf` (482 KB) | `odsp_sa_mobility_wizard` (560 lines) | +| **OW Discretionary Benefits** form | `static/src/pdf/discretionary_benefits_form_template.pdf` (1.1 MB) | `odsp_discretionary_wizard` (395 lines) | + +The SA Mobility wizard's `_build_field_mapping()` is the **field-name reference for the gov form 13007E**: + +- Vendor section: `Text 1` (with space — gov form quirk), `Text2`, `Text3`, ..., `Text7`. Salesperson: `Text8`, `Text9`. Client: `Text10`, `Text11`. +- **Member ID is 9 separate text boxes** (`Text12`-`Text20`) — one per digit, left-justified via `.ljust(9)`. +- Relationship checkboxes: `Check Box16` (self), `17` (spouse), `18` (dependent). +- Device type checkboxes: `Check Box19` (manual_wheelchair) ... `Check Box24` (other). +- Parts table (up to **6 rows**): `Text30`-`Text59` in groups of 5 (qty, description, unit_price, taxes, amount). Total at `Text60`. +- Labour table (up to **5 rows**): `Text61`-`Text80` in groups of 4 (hours, rate, taxes, amount). Total at `Text81`. +- Additional Fees (up to **4 rows**): `Text82`-`Text97` in groups of 4 (description, rate, taxes, amount). Total at `Text98`. +- Estimated totals summary: `Text99` (parts), `Text100` (labour), `Text101` (fees), `Text102` (grand total). +- Page 2 Notes/Comments area: **`Text1`** (collides with vendor `Text 1` — different field, gov-form naming quirk). + +The wizard pre-populates from the SO automatically: products with `default_code == 'LABOR'` → labour tab; everything else → parts tab. + +**`_get_template_path()`** uses raw `os.path` operations to resolve the template file, NOT Odoo's `tools.misc.file_path()`. **Brittle if the module is loaded as a zip** — consider migrating to `file_path('fusion_claims/static/src/pdf/sa_mobility_form_template.pdf')` if that issue arises. + +### 14.3.1 OW Discretionary Benefits wizard quirks + +This wizard fills a different gov form (`discretionary_benefits_form_template.pdf`, 1.1 MB) with its own set of footguns: + +- **Uses `PyPDF2`, not `pdfrw`** — because the gov PDF is AES-encrypted (no password, just "protected mode"). pdfrw cannot decrypt; PyPDF2 handles it via `reader.decrypt('')`. If pdfrw is the only PDF library available, the wizard will fail. Both are optional Python deps. +- Preserves `/AcroForm` from the original document and sets `/NeedAppearances` = True so the filled form renders correctly in Acrobat (without this, the values are stored but not visible). +- Splits text and checkbox fields into separate dicts. Text fields use PyPDF2's bulk `update_page_form_field_values`; **checkboxes are updated directly by mutating each annotation's `/V` and `/AS` to `/1` (checked) or `/Off` (unchecked)** via `NameObject`. PyPDF2 doesn't have a clean API for this. +- **The gov form has misleading field names** — they don't match physical layout. Hard-earned mapping: + | Field name (PDF) | Actually populates | + |---|---| + | `txt_First[0]` | Client Name | + | `txt_CITY[1]` | **Member ID** (not city — note the `[1]` index) | + | `txt_add[0]` | Address | + | `txt_CITY[0]` | City (the `[0]` index — different from `[1]`) | + | `txt_email[0]` | **Phone** (not email) | + | `txt_bphone[0]` | Alternate Phone | + | `txt_emp_phone[0]` | **Email** (not employer phone) | + | `txt_clientnumber[0]` | **Date** (not client number) | + | `CheckBox15[0]` | Medical Equipment | + | `CheckBox11[0]` | Dentures | + | `CheckBox11[1]` | Vision Care | + | `CheckBox13[0]` | Other | + | `TextField1[0]` | Description/details | + + Don't normalize or rename when building the mapping — write to the names exactly as ODSP shipped them. +- 4 item types: `medical_equipment`, `vision_care`, `dentures`, `other`. + +ODSP signing positions (signature/initial fields) are managed via Configuration > PDF Templates with a drag-and-drop visual editor — managed in `fusion.pdf.template` records (category=ODSP), not as static templates in `data/pdf_template_data.xml` (which is now an empty placeholder with a "templates retired" note). + +`static/src/pdf/sa_mobility_page2_sample.pdf` (241 KB) is a reference sample showing what page 2 should look like when filled. + +### 14.4 ODSP submission paths (`odsp_submit_to_odsp_wizard`) + +Three actions on the wizard, all of which (a) render the standard `sale.action_report_saleorder` PDF, (b) attach `x_fc_odsp_authorizer_letter`, and (c) advance status `quotation`/`documents_ready` → `submitted_to_odsp`: + +| Action | What it does | +|---|---| +| `action_send_email` | Emails the ODSP office (`x_fc_odsp_office_id`) with both attachments. Uses `_send_odsp_submission_email` with `email_body_notes`. | +| `action_send_fax` | Opens `fusion_faxes.send.fax.wizard` (soft-depended on) with attachments pre-loaded and the office's `x_ff_fax_number` if available. | +| `action_send_fax_and_email` | Sends email first, then chains into the fax wizard via `display_notification.next`. | + +The wizard can override `x_fc_odsp_office_id` per-submission and syncs the choice back to the SO. + +### 14.4.1 ODSP submission document bundling + +Two methods on `sale.order` build the email payload sent to the funder: + +| Method | Used by | Attaches | +|---|---|---| +| `_sa_mobility_submit_documents` | SA Mobility flow | (1) signed SA form (`x_fc_sa_signed_form` OR `x_fc_sa_physical_signed_copy`), (2) internal POD PDF via `_get_sa_pod_pdf`, (3) latest **posted** invoice PDF (Odoo's `account.account_invoices` report) | +| `_odsp_std_submit_documents` | ODSP Standard flow | (1) `x_fc_odsp_approval_document`, (2) internal POD PDF, (3) **auto-confirms SO and creates invoice** if no posted one exists, then attaches invoice PDF | + +Both call the corresponding `_send_*_email` method (SA Mobility → `_send_sa_mobility_completion_email`; Standard → `_send_odsp_submission_email`). Both post a green chatter card listing the attachment names. + +### 14.4.2 Ontario Works invoice flow + +`_ow_payment_create_invoice` (`action_odsp_payment_received` for OW orders): + +1. Auto-confirms the SO if `state != 'sale'`. +2. Calls standard `self._create_invoices()`. +3. Writes `x_fc_source_sale_order_id` on the invoice. +4. Advances ODSP status to `payment_received` with chatter note "Ontario Works payment confirmed. Invoice {name} created." +5. Returns an action that opens the new invoice form. + +This is the ONE case where payment comes BEFORE delivery — the OW office pays the vendor, the vendor delivers later (and the auto-close cron uses `delivered` not `payment_received` as the timer trigger — see §18). + +### 14.4.3 ODSP on_hold / resume (2026-04 fix) + +Mirrors the MOD on_hold pattern. `x_fc_odsp_previous_status_before_hold` saves the current status; `action_odsp_resume` restores it (falls back to `quotation` only for legacy records held before the fix shipped). Blocks hold from `on_hold`/`case_closed`/`cancelled`. Calls `_odsp_advance_status` which routes to whichever of `x_fc_sa_status`/`x_fc_odsp_std_status`/`x_fc_ow_status` is active. + +### 14.4.4 SA Mobility signature overlay — TWO mechanisms + +| Method | Used when | Mechanism | +|---|---|---| +| `action_sign_sa_mobility_form` | Client signs the SA Mobility form directly (Page 2 client consent) | **Hard-coded coordinates**: writes printed name at `(180, h-180)` and `(72, h-560)`, date at `(350, h-560)`, signature image at `(72, h-540, 200×50px)`. Uses `reportlab.pdfgen.canvas` + `odoo.tools.pdf.PdfFileReader/Writer`. **Brittle** — if the gov PDF layout changes, the coordinates must be re-measured. | +| `_apply_pod_signature_to_approval_form` | POD signature collected (auto-fired by `write` override when `x_fc_pod_signature` is set) | **PDFTemplateFiller** from `fusion_authorizer_portal` — reads field positions from the active `fusion.pdf.template` (category=`odsp`), uses per-case `x_fc_sa_signature_page`. Configurable via drag-and-drop visual editor, not code. Bypass via `skip_pod_signature_hook=True` context. | + +The PDFTemplateFiller approach is the preferred path going forward — it survives gov form revisions because positions live in the database, not in Python code. + +### 14.5 ODSP signature-page setup (`odsp_ready_delivery_wizard`) + +When transitioning SA Mobility / OW orders to `ready_delivery`, this wizard: + +1. Loads field positions from the active `fusion.pdf.template` (category=`odsp`). +2. Renders a **preview image** of the chosen `signature_page` using `pdf2image.convert_from_bytes` + PIL `ImageDraw`, with colored markers overlaid at each field position: + - **blue** for text fields (sample text: "John Smith") + - **purple** for date fields (sample: "2026-02-17") + - **red rectangles** for signature fields +3. Writes `x_fc_sa_signature_page` to the SO. +4. **Returns an action that opens the technician task form** pre-filled with `default_task_type='delivery'`, `default_pod_required=True`, and `mark_odsp_ready_for_delivery=True` context — the task model's `_on_create_post_actions` hook then advances ODSP status to `ready_delivery`. + +`action_preview_full` opens the full PDF via the custom `fusion_claims.preview_document` client action tag. + +## 15. Field-mapping system (legacy Studio support) + +This module ships with a **field mapping layer** that lets it run against existing Studio-created fields in production databases. The mapping uses `ir.config_parameter` keys (`fusion_claims.field_*`) → field name. Defaults live in `data/ir_config_parameter_data.xml`. + +Getters that respect the mapping: +- `sale.order._get_sale_type / _get_client_type / _get_authorizer / _get_claim_number / _get_client_ref_1 / _get_client_ref_2 / _get_adp_delivery_date` +- `account.move._get_invoice_type / _get_client_type / _get_authorizer / _get_claim_number / _get_client_ref_1 / _get_client_ref_2 / _get_adp_delivery_date` +- `sale.order.line._get_serial_number / _get_device_placement` +- `account.move.line._get_serial_number / _get_device_placement` +- `product.product.get_adp_device_code / get_adp_price` + +**Always go through the getters when reading these fields.** Direct attribute access breaks legacy databases where the canonical field name is `x_studio_*` instead of `x_fc_*`. + +Auto-detection: +- `fusion_claims.config.action_detect_existing_fields` (`models/fusion_central_config.py`) scans for custom `x_*` fields on the canonical models, fuzzy-matches against keyword lists, and writes the discovered names into the matching `ir.config_parameter` keys. Surfaces unmapped fields for review. +- `field_mapping_config_wizard` is the form-based config UI. + +The full list of mapping keys is in `models/res_config_settings.py` (`fc_field_*`) — ~25 ICP keys covering SO, SOL, invoice, invoice line, and product fields. + +## 16. Email system + +Every workflow transition has a corresponding `_send_*_email` method on `sale.order`, all routed through `fusion.email.builder.mixin._email_build(...)`: + +### 16.1 ADP emails (~25) + +Each method is on `sale.order` and routed through `_email_build`. Key ones: + +| Method | Trigger | Recipients | Attachments | +|---|---|---|---| +| `_send_submission_email` | status → `submitted`/`resubmitted` | Client (TO), Authorizer + Sales Rep + Office (CC) | Final Application PDF + XML File | +| `_send_assessment_scheduled_email` | status → `assessment_scheduled` | Client (TO), Authorizer + Sales Rep (CC) | none | +| `_send_application_received_email` | status → `application_received` | Authorizer (TO), Sales Rep (CC) | none | +| `_send_application_reminder_email` | cron, X=4 days after `assessment_end_date` if still `waiting_for_application`/`assessment_completed` AND `x_fc_application_reminder_sent=False` | Authorizer (TO), Sales Rep + Office (CC). Sets `x_fc_application_reminder_sent=True` after send. | none | +| `_send_application_reminder_2_email` | cron, X+Y=8 days after `assessment_end_date` if first reminder sent AND `x_fc_application_reminder_2_sent=False`. Email mentions 90-day assessment validity. | Authorizer (TO), Sales Rep + Office (CC) | none | +| `_send_accepted_email` | status → `accepted` | Client + Authorizer (TO) | none | +| `_send_approval_email` | status → `approved`/`approved_deduction` | Client (TO), Authorizer + Sales Rep + Office (CC). Differentiates `approved` (standard message) vs `approved_deduction` (extra note about deduction). | **Generates** `action_report_approved_items` PDF via `_generate_approved_items_pdf`. Also embeds `_build_approved_items_html` table inline. | +| `_send_denial_email` | status → `denied` | Client (TO), Authorizer + Sales Rep + Office (CC). Urgent style. | none | +| `_send_rejection_email` | status → `rejected` | Client (TO), Authorizer + Sales Rep + Office (CC). 2026-04 fix: client previously excluded. Includes `rejection_reason` label (5 enum values) + free-text `x_fc_rejection_reason_other`. | none | +| `_send_correction_needed_email(reason)` | status → `needs_correction` (typically with wizard's reason text) | Client + Authorizer + Sales Rep + Office | none | +| `_send_billed_summary_email` | status → `billed` | Authorizer + Sales Rep (TO). Green success card with totals. | none | +| `_send_case_closed_email` | status → `case_closed` | (uses `_get_email_recipients` standard) | none | +| `_send_ready_for_delivery_email` | called from `ready_for_delivery_wizard` after task creation | Client + Authorizer + technicians (CC) | none | +| `_send_on_hold_email`, `_send_withdrawal_email`, `_send_expired_email`, `_send_cancelled_email` | corresponding status transitions | varies | none | + +`_build_approved_items_html(for_pdf=False)` builds an HTML table with columns S/N | ADP Code | Device Type | Product | Qty | ADP Portion | Client Portion | (Deduction — only when any line has one). Total row at bottom. Different font stack for PDF (`Arial,Helvetica,sans-serif`) vs email (system font). Truncates product names > 40 chars in email mode. + +`_generate_approved_items_pdf` renders `fusion_claims.action_report_approved_items` QWeb report → attaches with filename `{first}_{last}_Approved_Items.pdf`. + +### 16.1.1 Quotation/SO send override + +`sale.order.action_quotation_send` is overridden to auto-select the ADP email template: + +- Draft state → `email_template_adp_quotation` +- Sale/done state → `email_template_adp_sales_order` +- Layout: `mail.mail_notification_layout` +- Context flags: `mark_so_as_sent=True`, `force_email=True` + +Falls back to the standard behaviour for non-ADP sales. Mirror override exists on `account.move.action_invoice_sent` for ADP invoices (selects `email_template_adp_invoice`). + +### 16.2 MOD emails (~14) +`_send_mod_assessment_scheduled_email`, `_send_mod_assessment_completed_email`, `_send_mod_quote_submitted_email`, `_send_mod_vod_request_email`, `_send_mod_handoff_email`, `_send_mod_funding_approved_email`, `_send_mod_funding_denied_email`, `_send_mod_contract_received_email`, `_send_mod_invoice_submitted_email`, `_send_mod_initial_payment_email`, `_send_mod_project_complete_email`, `_send_mod_pod_submitted_email`, `_send_mod_final_payment_email`, `_send_mod_case_closed_email`, `_send_mod_cancelled_email`, `_send_mod_followup_email`. Build helper: `_mod_email_build`. + +### 16.3 ODSP emails +`_send_sa_mobility_email`, `_send_sa_mobility_completion_email`, `_send_odsp_submission_email`. Build helper: `_odsp_email_build`. + +### 16.4 Generic funder emails (WSIB / Insurance / MDC / Hardship) + +Five client-facing methods + five authorizer-facing variants, all routed through the unified `_send_funder_email(recipient, milestone, email_type, title, summary, attachment_ids, attachments_note)`: + +| Method | Used at | +|---|---| +| `_send_funder_package_ready_*` | Quotation + application package prepared | +| `_send_funder_approval_*` | Funding approved (attaches the funder-specific approval letter via `_get_funder_approval_attachments`) | +| `_send_funder_delivered_*` | Product delivered | +| `_send_funder_case_closed_*` (client only) | Case closed | +| `_send_funder_denial_*` | Funding denied | + +**Per-funder trigger maps** (class constants on `sale.order`) connect statuses to methods. The write override calls `_fire_funder_emails(trigger_map, new_status)` for each funder workflow. + +| Trigger map | Status keys → methods | +|---|---| +| `_WSIB_EMAIL_TRIGGERS` | `documents_ready` → package ready (both), `pre_approved` → approval (both), `delivered` → delivery (both), `case_closed` → client only, `denied` → denial (both) | +| `_INSURANCE_EMAIL_TRIGGERS` | `documents_ready` → package ready (client only), `approval_received` / `pre_auth_approved` → approval (both), `delivered` → delivery (both), `case_closed` → client only, `denied` → denial (both) | +| `_MDC_EMAIL_TRIGGERS` | `documents_ready` → package ready (both), `po_received` → approval (both), `delivered` → delivery (both), `case_closed` → client only, `denied` → denial (both) | +| `_HARDSHIP_EMAIL_TRIGGERS` | `application_package_ready` → package ready (both), `approval_received` → approval (both), `delivered` → delivery (both), `case_closed` → client only, `denied` / `eligibility_failed` → denial (both) | + +`_FUNDER_LABELS` maps the sale type to a human label (`'wsib': 'WSIB'`, `'insurance': 'Insurance'`, `'muscular_dystrophy': 'Muscular Dystrophy Canada'`, `'hardship': 'Hardship Funding'`). Used for subject lines and email body. + +`_build_funder_case_rows()` builds the email's "Case Details" section dynamically per funder — WSIB adds claim#, adjudicator, approval date; Insurance adds company, policy#, claim#, pre-auth expiry; MDC adds Client ID, PO number/date; Hardship adds funder partner, approval date. + +### 16.5 Recipients & CC + +`_get_email_recipients(include_client, include_authorizer, include_sales_rep)` collects the to/cc list. The CC list always includes contacts in `company.x_fc_office_notification_ids`. Master toggle: `fusion_claims.enable_email_notifications` (`_is_email_notifications_enabled`). + +MOD has its own recipient helper: `_get_mod_email_recipients(include_client, include_authorizer, include_mod_contact, include_sales_rep)` — adds `include_mod_contact` (uses `x_fc_mod_contact_email`). Returns a dict with `to`, `cc`, `office_cc` plus the partner records (`authorizer`, `sales_rep`, `client`). Authorizer becomes TO if no client email, else CC. + +**Per-MOD-method recipient rules** (some include CC of the MOD case worker, some don't): +| Method | Client | Authorizer | MOD contact | Notes | +|---|---|---|---|---| +| `assessment_scheduled` | ✓ | ✓ | — | | +| `assessment_completed` | ✓ | ✓ | — | 2026-04 fix: authorizer was previously excluded | +| `quote_submitted` | ✓ | ✓ | ✓ | | +| `funding_approved` | ✓ | ✓ | — | with amounts section | +| `funding_denied` | ✓ | ✓ | — | urgent style | +| `contract_received` | ✓ | — | — | client only | +| `invoice_submitted` | — | — | ✓ | MOD contact only | +| `initial_payment` | ✓ | — | — | client only | +| `project_complete` | ✓ | ✓ | — | | +| `pod_submitted` | ✓ | ✓ | ✓ | 2026-04 fix: client was previously excluded | + +### 16.4.1 Authorizer email policy (2026-04 audit) + +A consistent rule across the module: the authorizer (OT) is only CC'd / notified for states that **require their action** or are **definitive outcomes**. They are deliberately excluded from passive intermediate states to reduce noise. + +| State | Authorizer in email? | Why | +|---|---|---| +| `assessment_scheduled` | ✓ | They're conducting it | +| `assessment_completed` | ✓ | Triggers next-step actions for them | +| `application_received` | ✓ | Confirms handoff to office | +| `submitted` / `resubmitted` | ✓ | Material progress | +| `accepted` | ✗ | Passive intermediate state, no OT action needed | +| `approved` / `approved_deduction` | ✓ | Definitive funding outcome | +| `denied` / `rejected` / `needs_correction` | ✓ | OT may need to act | +| `on_hold` | ✓ | OT should know case paused | +| `ready_for_delivery` | ✗ | Operational scheduling, not delivery confirmation | +| `case_closed` | ✓ | Definitive outcome — they get the "delivered" notification at this point | +| `cancelled` / `expired` / `withdrawn` | ✓ | Closing the case | + +Hold reminders + final warning have a slightly different rule (see §4.11) — monthly client reminder excludes authorizer, but the final warning includes them. + +### 16.4.2 `_adp_send_stage_email` helper + +The shared sender for ADP stage emails (`_send_assessment_scheduled_email`, `_send_assessment_completed_email`, `_send_application_received_email`, `_send_accepted_email`, `_send_cancelled_email`, `_send_expired_email`). Signature: + +```python +_adp_send_stage_email(title, subject, summary, note, + note_color='#4f8cff', email_type='info', + include_authorizer=True) +``` + +Defaults to client + authorizer + sales rep. `include_authorizer=False` is the one knob — used by `_send_accepted_email` and explained in the docstring with reference to the 2026-04 audit rule. + +### 16.4.3 `_send_withdrawal_email` three-intent dispatch + +Takes `reason` (free text) and `intent` arguments: + +| `intent` | Title / subject | Note | Color | +|---|---|---|---| +| `'cancel'` | "Application Withdrawn & Cancelled" | "Permanently withdrawn and cancelled. SO and invoices cancelled." | `#dc3545` urgent | +| `'resubmit'` | "Application Withdrawn for Correction" | "Will be resubmitted. Back to Ready for Submission." | `#d69e2e` attention | +| (None) | "Application Withdrawn" | "Withdrawn from ADP." | `#d69e2e` attention | + +`action_adp_withdraw` calls with `intent='cancel'` by default; the `status_change_reason_wizard` exposes both intents. + +### 16.5.1 Three footer voices + +The module branches the footer line of every transactional email by funder type, via two thin wrappers around `_email_build`: + +| Helper | Footer reads | +|---|---| +| `_email_build` (default) | "This is an automated notification from the **ADP Claims Management System**." | +| `_mod_email_build` | "This is an automated notification from the **Accessibility Case Management System**." | +| `_odsp_email_build` | "This is an automated notification from the **ODSP Case Management System**." | + +Both wrappers do a literal string replace on the result of `_email_build`. If you change the master footer text, update the two replace calls or the branching will silently break. + +### 16.6 mail.template records (`data/mail_template_data.xml`) + +Three templates, all with `auto_delete=True` and a report attached: +- `email_template_adp_quotation` — sale.order, landscape report. +- `email_template_adp_sales_order` — confirmation, landscape report. +- `email_template_adp_invoice` — account.move, landscape report. `account.move.action_invoice_sent` is overridden to pre-select this template for ADP invoices (don't remove this without preserving the routing). + +## 17. SMS via Twilio + +Settings: `fusion_claims.twilio_enabled`, `fusion_claims.twilio_account_sid` (manager-only), `fusion_claims.twilio_auth_token` (manager-only), `fusion_claims.twilio_phone_number`. + +Helpers on `sale.order`: +- `_twilio_send_sms(to_number, message)` — low-level POST to `https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json` with basic auth, 10-second timeout. Returns True on HTTP 200/201, False otherwise. +- `_send_mod_sms(trigger)` — picks a message template from a hard-coded dict keyed by trigger string. Reads `partner.mobile` then falls back to `partner.phone`. Silently bails if Twilio isn't enabled or the partner has no phone. + +**4 MOD SMS message templates** (`models/sale_order.py:9823-9840`): + +| Trigger | Sample message | +|---|---| +| `assessment_scheduled` | "Hi {name}, your accessibility assessment with **Westin Healthcare** has been scheduled. We will confirm the exact date and time shortly. For questions, call {company_phone}." | +| `funding_approved` | "Hi {name}, great news! Your March of Dimes funding has been approved. Our team will be in touch with next steps. Questions? Call {company_phone}." | +| `initial_payment_received` | "Hi {name}, we have received the initial payment for your project. Work is in progress. We will keep you updated. Call {company_phone} for info." | +| `project_complete` | "Hi {name}, your accessibility modification project is now complete! If you have any questions or concerns, call us at {company_phone}." | + +> ⚠ **Multi-tenant gotcha**: the `assessment_scheduled` template hard-codes the string `"Westin Healthcare"` — it's NOT pulled from `self.company_id.name`. If this module is deployed at a non-Westin customer, that message reads wrong. Fix or parameterize before going multi-tenant. + +ODSP also has `_send_sa_mobility_email` (request_type='batteries'|'repair', device_description, attachment_ids, email_body_notes) and `_send_odsp_submission_email`. SA Mobility email defaults to `samobility@ontario.ca` (`fusion_claims.sa_mobility_email`). + +## 18. Cron jobs (13 total) + +All defined in `data/ir_cron_data.xml` and `data/ir_actions_server_data.xml` (currently empty placeholder). + +| Cron | Time | What it does | +|---|---|---| +| Renew Delivery Reminders | daily | `sale.order._cron_renew_delivery_reminders` — finds `approved`/`approved_deduction` orders with overdue `mail_activity_type_adp_delivery` activities, reschedules to next posting Tuesday | +| Renew Billing Reminders | daily | `account.move._cron_renew_billing_reminders` — for `waiting`-status ADP invoices with overdue billing activities | +| Renew Correction Reminders | daily | `account.move._cron_renew_correction_reminders` — for `need_correction`-status ADP invoices | +| Auto-Close Billed Cases | daily | `billed` → `case_closed` 30 days after `x_fc_billing_date`. Posts chatter with `days_since_billed` | +| Auto-Close ODSP Paid Cases | daily | SA Mobility / ODSP Standard: 7 days after `payment_received` → `case_closed`. **Ontario Works: 7 days after `delivered`** (payment comes BEFORE delivery for OW) | +| First Application Reminder | 9 AM daily | `_cron_send_application_reminders` — `assessment_end_date == today - X` (default 4), status in `waiting_for_application`/`assessment_completed`, `x_fc_application_reminder_sent=False`. Sends to authorizer + sales rep + office | +| Second Application Reminder | 9 AM daily | `_cron_send_application_reminders_2` — `assessment_end_date == today - (X+Y)`, first reminder sent, second not yet. Mentions 90-day assessment validity | +| Acceptance Reminders | 9 AM daily | `_cron_send_acceptance_reminders` — see §18.1 below | +| MOD Follow-up Scheduler | 8 AM daily | Bi-weekly MOD activity creation (capped). See §5.3 | +| MOD Follow-up Escalation | 10 AM daily | Auto-email after activity 3 days overdue. See §5.3 | +| MOD Handoff Follow-up | 9 AM daily | Office call activity for client/authorizer MOD paths. See §5.3 | +| Expire Page 11 Signing Requests | 2 AM daily | Mark unsigned past-expiry requests `expired` | +| ADP Expire Approved Applications | 3 AM daily | `approved` → `expired` 12 months later | +| ADP Hold Expiry Reminders | 9:30 AM daily | Monthly client reminder + 30-day final warning | + +### 18.1 `_cron_send_acceptance_reminders` mechanics + +Sends reminders for orders still `submitted` next business day after submission. Three layers of anti-spam protection (2026-04 update): + +| Layer | Setting | Effect | +|---|---|---| +| 14-day backlog guard | `fusion_claims.acceptance_reminder_max_age_days` (default 14) | Only reminds cases submitted **within the last 14 days** — first cron run after deploy doesn't blast every old stuck `submitted` case | +| 2-day lower bound | hard-coded | `cutoff_date = today - 2 days` — skips weekend-only delays | +| Per-run cap | `fusion_claims.acceptance_reminder_max_per_cron_run` (default 10) | Spreads large backlogs across multiple runs | +| One-shot flag | `x_fc_acceptance_reminder_sent` | Each order gets at most ONE reminder per submission cycle. Reset to False on resubmission (see §4.9) | + +**FIRST/SECOND tier escalation**: +- `days_since_submission ≤ 3` → "FIRST" reminder → **office only** +- `days_since_submission > 3` → "SECOND" reminder → **office + sales rep** + +Subject differs: "Pending Review: Acceptance Status - {SO}" (first) vs "Follow-up Review: Acceptance Status - {SO}" (second). + +`mail_activity_type_*` records (`data/mail_activity_type_data.xml`): `adp_delivery` (sale.order), `adp_billing` (account.move), `adp_correction` (account.move, danger decoration), `mod_followup` (sale.order, 14-day delay). + +## 18.2 MOD VOD request + handoff email mechanics + +**`_send_mod_vod_request_email`** (fired when `submitted_by` first set to `internal`, also manual button): +- TO: authorizer; CC: sales rep +- Subject: "Verification of Disability needed - {client} - {SO}" +- Attaches the **blank VOD form** stored on `res.company.x_fc_mod_vod_form` (via `_mod_company_attachment` helper) +- Stamps `x_fc_mod_vod_requested_date = today` + +**`_send_mod_handoff_email`** (fired by `action_mod_handoff_to_client`, only when `submitted_by ∈ ('client', 'authorizer')`): + +| `submitted_by` | TO | CC | Subject | Tone | +|---|---|---|---|---| +| `client` | client | authorizer + sales rep | "Your March of Dimes Application Package - {SO}" | Direct to client, numbered next-steps list, "Call us once you've submitted" | +| `authorizer` | authorizer | client + sales rep | "MOD Application Package for {client} - {SO}" | Professional, "Please confirm with us once submitted" | + +Attaches: `x_fc_mod_proposal_doc` + `x_fc_mod_drawing` + blank MOD Application Form from `res.company.x_fc_mod_application_form`. + +**`_mod_company_attachment(field_name, filename_field, default_name)`** — reusable helper that pulls a Binary from `res.company` and creates an `ir.attachment` for outbound emails. Returns `[attachment_id]` or `[]` if the company field is blank. + +## 19. PDF reports (15+ templates) + +All declared in `report/report_actions.xml` and bound to their models. Custom paperformat `paperformat_a4_landscape` (A4 Landscape, margins 20/20/7/7, header spacing 20, dpi 90). + +| Report | Model | Type | XML file | +|---|---|---|---| +| Quotation / Order (Portrait - ADP) | sale.order | Portrait | sale_report_portrait.xml | +| Quotation / Order (Landscape - ADP) | sale.order | Landscape | sale_report_landscape.xml | +| Invoice (Portrait) | account.move | Portrait | invoice_report_portrait.xml | +| Invoice (Landscape - ADP) | account.move | Landscape (no menu binding) | invoice_report_landscape.xml | +| ADP Proof of Delivery | sale.order | | report_proof_of_delivery.xml | +| Proof of Delivery (Standard) | sale.order | | report_proof_of_delivery_standard.xml | +| Proof of Pickup | sale.order | | report_proof_of_pickup.xml | +| Approved Items | sale.order | | report_approved_items.xml | +| Grab Bar Waiver | sale.order | | report_grab_bar_waiver.xml | +| Accessibility Contract | sale.order | | report_accessibility_contract.xml | +| MOD Quotation | sale.order | | report_mod_quotation.xml | +| MOD Invoice | account.move | | report_mod_invoice.xml | + +### 19.1 Shared report-template snippets (`report/report_templates.xml`) + +The following QWeb templates are `t-call`-able from any report: + +| Template ID | Purpose | +|---|---| +| `report_header_fusion_claims` | Company logo + name + `x_fc_store_address_1/2` + `x_fc_company_tagline` (3-col / 9-col Bootstrap row) | +| `report_address_boxes` | Bordered billing + delivery address columns (fallback to billing when no shipping address) | +| `report_serial_numbers` | Polymorphic — handles BOTH `sale.order` and `account.move`. Extracts `x_fc_serial_number` from order_line / invoice_line_ids and renders as a bulleted list inside a bordered box. | +| `report_payment_terms` | Outputs `company.x_fc_payment_terms_html`, preceded by "Payment Communication: {doc.name}" | +| `report_refund_policy_page` | Gated by `company.x_fc_include_refund_page` — separate page (`page-break-before: always`) with header + refund policy HTML | +| `report_footer_fusion_claims` | Phone & Fax + email + HST VAT + website (single centered row) | +| `report_styles_fusion_claims` | Inline `
@@ -193,12 +233,13 @@ - -
- Quantities - Quantités -
- + +
@@ -244,9 +285,225 @@
- + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ Fischerscope XRF Thickness Report + Rapport d'épaisseur Fischerscope XRF +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Equipment + Équipement + + + + Calibration Std. + Étalon +
+ Product + Produit + + Operator + Opérateur +
+ Application + Application + + Measured + Mesuré le + + + +
+ Directory + Répertoire + + Measuring Time + Durée de mesure + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#NiP (mils)Ni %P %
+ Mean + Moyenne +
+ Std Dev + Écart-type +
CoV (%)
+ Range + Étendue +
N
+
+ + +
+ Source file: + Fichier source : + + (attached to cert as evidence) +
+
+
+ + - + ' - f'' - f'' - f'' - f'' - f'' + 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 ( - '
Certified By: diff --git a/fusion_plating/scripts/fp_fedex_service_matrix.py b/fusion_plating/scripts/fp_fedex_service_matrix.py new file mode 100644 index 00000000..7cf1ef00 --- /dev/null +++ b/fusion_plating/scripts/fp_fedex_service_matrix.py @@ -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') diff --git a/fusion_plating/scripts/fp_reset_cert_30045.py b/fusion_plating/scripts/fp_reset_cert_30045.py new file mode 100644 index 00000000..01667da6 --- /dev/null +++ b/fusion_plating/scripts/fp_reset_cert_30045.py @@ -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.') diff --git a/fusion_plating/scripts/fp_retro_image_30045.py b/fusion_plating/scripts/fp_retro_image_30045.py new file mode 100644 index 00000000..ab2cc4fa --- /dev/null +++ b/fusion_plating/scripts/fp_retro_image_30045.py @@ -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, +)) diff --git a/fusion_plating/scripts/fp_retro_thickness_30045.py b/fusion_plating/scripts/fp_retro_thickness_30045.py new file mode 100644 index 00000000..b0aa6496 --- /dev/null +++ b/fusion_plating/scripts/fp_retro_thickness_30045.py @@ -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, +)) diff --git a/fusion_shipping/api/fedex_rest/request.py b/fusion_shipping/api/fedex_rest/request.py index 6ba2dcee..efdf4171 100644 --- a/fusion_shipping/api/fedex_rest/request.py +++ b/fusion_shipping/api/fedex_rest/request.py @@ -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), } diff --git a/fusion_shipping/models/delivery_carrier.py b/fusion_shipping/models/delivery_carrier.py index aec96ff7..636213ee 100644 --- a/fusion_shipping/models/delivery_carrier.py +++ b/fusion_shipping/models/delivery_carrier.py @@ -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, diff --git a/fusion_shipping/models/fusion_shipment.py b/fusion_shipping/models/fusion_shipment.py index c97046eb..324787b7 100644 --- a/fusion_shipping/models/fusion_shipment.py +++ b/fusion_shipping/models/fusion_shipment.py @@ -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, diff --git a/fusion_tasks/graphify-out/calendar_check.sql b/fusion_tasks/graphify-out/calendar_check.sql new file mode 100644 index 00000000..2397e1b3 --- /dev/null +++ b/fusion_tasks/graphify-out/calendar_check.sql @@ -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,''), '') 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; diff --git a/fusion_tasks/graphify-out/calendar_check2.sql b/fusion_tasks/graphify-out/calendar_check2.sql new file mode 100644 index 00000000..53bc6c87 --- /dev/null +++ b/fusion_tasks/graphify-out/calendar_check2.sql @@ -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; diff --git a/fusion_tasks/graphify-out/mobility_sync_fix.py b/fusion_tasks/graphify-out/mobility_sync_fix.py new file mode 100644 index 00000000..6fbd56dc --- /dev/null +++ b/fusion_tasks/graphify-out/mobility_sync_fix.py @@ -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') diff --git a/fusion_tasks/graphify-out/sync_evidence.sql b/fusion_tasks/graphify-out/sync_evidence.sql new file mode 100644 index 00000000..44a20b76 --- /dev/null +++ b/fusion_tasks/graphify-out/sync_evidence.sql @@ -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,''), '') 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,''), '') 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; diff --git a/fusion_tasks/graphify-out/sync_evidence_2.sql b/fusion_tasks/graphify-out/sync_evidence_2.sql new file mode 100644 index 00000000..b79fccce --- /dev/null +++ b/fusion_tasks/graphify-out/sync_evidence_2.sql @@ -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; diff --git a/fusion_tasks/graphify-out/sync_verify.sql b/fusion_tasks/graphify-out/sync_verify.sql new file mode 100644 index 00000000..22ead036 --- /dev/null +++ b/fusion_tasks/graphify-out/sync_verify.sql @@ -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,''), '') 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; diff --git a/fusion_tasks/graphify-out/westin_sync_fix.py b/fusion_tasks/graphify-out/westin_sync_fix.py new file mode 100644 index 00000000..74ca051f --- /dev/null +++ b/fusion_tasks/graphify-out/westin_sync_fix.py @@ -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') From 53fd6114e72ebebf431a424b69eb79ff4a87da6d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:42:46 -0400 Subject: [PATCH 05/22] changes --- .../2026-05-21-fusion-claims-dashboard.md | 2403 +++++++++++++++++ fusion_claims/tests/__init__.py | 1 + fusion_claims/tests/test_dashboard.py | 59 + .../__manifest__.py | 2 +- .../models/fp_certificate.py | 33 + .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/fp_job.py | 97 +- .../fusion_plating_logistics/__manifest__.py | 2 +- .../models/fp_delivery.py | 42 + .../views/fp_delivery_views.xml | 11 + 10 files changed, 2624 insertions(+), 28 deletions(-) create mode 100644 fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md create mode 100644 fusion_claims/tests/test_dashboard.py diff --git a/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md b/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md new file mode 100644 index 00000000..aa99ac10 --- /dev/null +++ b/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md @@ -0,0 +1,2403 @@ +# Fusion Claims Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite the existing `fusion.claims.dashboard` TransientModel + view as an action-oriented dashboard with posting-week banner, live deadline countdown, 3 KPI tiles, 8 funder hotlinks, ADP + MOD workflow flag tiles, role-aware filtering, and dual-bundle light/dark themed SCSS. + +**Architecture:** Hybrid — server-rendered Bootstrap-grid form view on a TransientModel with ~36 computed fields, plus one OWL field-widget for the live countdown that ticks every 60s. SCSS palette tokens branch on `$o-webclient-color-scheme` at compile time and the file is registered in both `web.assets_backend` and `web.assets_web_dark`. + +**Tech Stack:** Odoo 19, Python 3.11, OWL 2 (Odoo Web Library), SCSS, Bootstrap 5. + +**Reference spec:** `docs/superpowers/specs/2026-05-21-fusion-claims-dashboard-design.md`. + +**Test environment (per CLAUDE.md §26):** +- Docker container: `odoo-modsdev-app` +- Database: `modsdev` +- Run all module tests: `docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims --stop-after-init` +- Run a specific class: append `:TestFusionClaimsDashboard` to the tag. +- Run a specific test: append `:TestFusionClaimsDashboard.test_xxx`. + +**File structure:** + +``` +fusion_claims/ +├── __manifest__.py (modify) +├── models/dashboard.py (rewrite) +├── views/dashboard_views.xml (rewrite) +├── static/src/scss/_fc_dashboard_tokens.scss (new) +├── static/src/scss/fc_dashboard.scss (new) +├── static/src/js/fc_posting_countdown.js (new) +├── static/src/xml/fc_posting_countdown.xml (new) +└── tests/test_dashboard.py (new) +``` + +**Cross-cutting conventions:** +- All new fields use `x_fc_*` prefix only where they live on existing models (`sale.order`, `account.move`). Fields on the dashboard model itself don't need the prefix because it's a private TransientModel. +- All tests inherit `TransactionCase` and use `@tagged('-at_install', 'post_install', 'fusion_claims')`. +- Test data setup uses `with_context(skip_status_validation=True)` whenever writing controlled statuses, per CLAUDE.md gotcha #25. +- Commits use the existing module convention: `feat(fusion_claims): ...` or `test(fusion_claims): ...` (style matches the project's recent commits like `fix(fusion_schedule): ...`). + +--- + +## Task 1: Scaffold model + role-filter helper + first test + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` (rewrite from scratch) +- Create: `fusion_claims/tests/test_dashboard.py` +- Create: `fusion_claims/tests/__init__.py` if missing (check first) + +- [ ] **Step 1: Confirm tests/__init__.py exists** + +Run: +```bash +cat K:/Github/Odoo-Modules/fusion_claims/tests/__init__.py +``` +Expected: file lists existing test modules. If `from . import test_dashboard` is not yet present, we'll add it in step 5 below. + +- [ ] **Step 2: Write the failing test (test class skeleton)** + +Create `fusion_claims/tests/test_dashboard.py`: + +```python +# -*- 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'}) + + 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) +``` + +- [ ] **Step 3: Register the test in tests/__init__.py** + +Edit `fusion_claims/tests/__init__.py` to add the line (preserve existing imports): + +```python +from . import test_dashboard +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -40 +``` +Expected: tests fail because the new model doesn't have `_role_filter_domain` or `is_manager`. Errors like `AttributeError: 'fusion.claims.dashboard' object has no attribute '_role_filter_domain'`. + +- [ ] **Step 5: Replace models/dashboard.py with the new skeleton** + +Replace the entire contents of `fusion_claims/models/dashboard.py` with: + +```python +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +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) + + # ========================================================================= + # Role-aware filter + # ========================================================================= + is_manager = fields.Boolean(compute='_compute_is_manager') + + 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: + 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')) + ) + + 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)] +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: `5 passed, 0 failed`. + +- [ ] **Step 7: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py fusion_claims/tests/__init__.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): scaffold dashboard model with role filter" +``` + +--- + +## Task 2: Banner fields & `_compute_banner` + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests for banner fields** + +Append to `test_dashboard.py` (inside the existing class): + +```python + def test_banner_posting_period_label_format(self): + dashboard = self.Dashboard.with_user(self.manager).create({}) + label = dashboard.posting_period_label + self.assertIn(' – ', label, + "Label should use en dash separator between start and end") + 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) + # end = start + 14 days (one cycle) + 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") + self.assertEqual(deadline.weekday(), 2, "Deadline should be Wednesday") + self.assertEqual(deadline.hour, 18, "Deadline should be 18:00 (6 PM)") + + def test_is_pre_first_posting_false_when_today_is_past_base_date(self): + # In the test environment, today is past 2026-01-23 (the default base date). + # If this ever runs before the base date, the assertion will need adjusting. + dashboard = self.Dashboard.with_user(self.manager).create({}) + self.assertFalse(dashboard.is_pre_first_posting) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30 +``` +Expected: 4 new tests fail with `AttributeError` for `posting_period_label`, `posting_period_start`, etc. + +- [ ] **Step 3: Add banner fields and `_compute_banner` to the model** + +Insert immediately AFTER the role-filter block in `models/dashboard.py`: + +```python + # ========================================================================= + # 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) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: all 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard banner fields" +``` + +--- + +## Task 3: KPI fields & `_compute_kpis` + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Add test data setup for invoices** + +Append a new helper to `test_dashboard.py` (add inside the class, after `setUpClass`): + +```python + @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', + }) + # Use vals dict so unset fields are False; avoid auto-create of lines + 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, + })], + }) + invoice.action_post() + # Force-set payment_state since it's normally computed + invoice.with_context(skip_sync=True).write({'payment_state': payment_state}) + return invoice +``` + +- [ ] **Step 2: Write the failing tests for KPIs** + +Add to `test_dashboard.py`: + +```python + def test_kpi_ready_counts_waiting_invoices_not_exported(self): + # Create one "ready" invoice owned by manager + 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): + # Build dashboard first to read the current period + 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): + # Manager's invoice; salesrep should NOT see it + 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") +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30 +``` +Expected: 5 new tests fail (`kpi_ready_count` etc. don't exist). + +- [ ] **Step 4: Add KPI fields and `_compute_kpis`** + +Insert after the banner block in `models/dashboard.py`: + +```python + # ========================================================================= + # 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')) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 14 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard KPI tiles (ready/claimed/AR)" +``` + +--- + +## Task 4: Activities + bottlenecks (left column) + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests** + +Add to `test_dashboard.py`: + +```python + 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): + # Approved SO with no POD + 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) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30 +``` +Expected: 4 new tests fail. + +- [ ] **Step 3: Add activities + bottleneck fields and compute methods** + +Insert after the KPI block in `models/dashboard.py`: + +```python + # ========================================================================= + # 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 = ( + '

No activities assigned.

' + ) + continue + rows = [] + from datetime import date + today = date.today() + 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'' + ) + rec.my_activities_html = '\n'.join(rows) + + # ========================================================================= + # Bottlenecks (left column) + # ========================================================================= + bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts') + bottleneck_no_response_count = 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() + + 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), + ]) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 18 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard activities and bottlenecks" +``` + +--- + +## Task 5: Other-funder counts + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests** + +Add to `test_dashboard.py`: + +```python + 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'}) + # ACSD = client_type ACS, regardless of sale_type + 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) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 2 tests fail (missing fields). + +- [ ] **Step 3: Add other-funder count fields** + +Inside `models/dashboard.py`, add these fields under the "Bottlenecks" comment block and **extend** the existing `_compute_secondary_counts` method: + +```python + # ========================================================================= + # Other funders (left column count cards) + # ========================================================================= + 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') +``` + +Then extend `_compute_secondary_counts` — replace the existing method body with the version below (preserves the two bottleneck assignments and adds six more): + +```python + 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')]) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 20 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard other-funder counts" +``` + +--- + +## Task 6: ADP + MOD workflow counts + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests** + +Add to `test_dashboard.py`: + +```python + 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) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30 +``` +Expected: 3 tests fail with `AttributeError` for the new count fields. + +- [ ] **Step 3: Add workflow count fields and `_compute_workflow_counts`** + +Insert after the other-funder counts in `models/dashboard.py`: + +```python + # ========================================================================= + # 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'), + ]) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 23 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard ADP + MOD workflow tile counts" +``` + +--- + +## Task 7: Open-list action methods + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests** + +Add to `test_dashboard.py`: + +```python + 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') +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 4 tests fail. + +- [ ] **Step 3: Add open-list action methods** + +Append the following section to `models/dashboard.py`: + +```python + # ========================================================================= + # Open-list action methods + # ========================================================================= + def _so_list_action(self, name, domain): + return { + 'type': 'ir.actions.act_window', + 'name': name, + 'res_model': 'sale.order', + 'view_mode': 'list,form', + 'domain': self._role_filter_domain() + domain, + 'target': 'current', + } + + # ----- 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_adp_app_received(self): + return self._so_list_action('ADP — Application Received', [ + ('x_fc_adp_application_status', '=', 'application_received'), + ]) + + 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_adp_needs_correction(self): + return self._so_list_action('ADP — Needs Correction', [ + ('x_fc_adp_application_status', '=', 'needs_correction'), + ]) + + # ----- 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_adp_ready_delivery(self): + return self._so_list_action('ADP — Ready for Delivery', [ + ('x_fc_adp_application_status', '=', 'ready_delivery'), + ]) + + 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_adp_on_hold(self): + return self._so_list_action('ADP — On Hold', [ + ('x_fc_adp_application_status', '=', 'on_hold'), + ]) + + # ----- MOD ----- + def action_open_mod_awaiting_funding(self): + return self._so_list_action('MOD — Awaiting Funding', [ + ('x_fc_mod_status', '=', 'awaiting_funding'), + ]) + + 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': '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', + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 27 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard open-list action methods" +``` + +--- + +## Task 8: Create-SO action methods (8 hotlinks) + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests** + +Add to `test_dashboard.py`: + +```python + 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({}) + # Smoke check: every hotlink method returns a valid action dict + 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") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30 +``` +Expected: 4 new tests fail (missing methods). + +- [ ] **Step 3: Add create-SO action methods** + +Append to `models/dashboard.py`: + +```python + # ========================================================================= + # 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'}) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20 +``` +Expected: 31 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard create-SO hotlinks" +``` + +--- + +## Task 9: Form view rewrite + +**Files:** +- Modify: `fusion_claims/views/dashboard_views.xml` (full rewrite) + +This task has no Python TDD — the view is verified by loading the module and rendering the page. Test step is a module-upgrade smoke check. + +- [ ] **Step 1: Rewrite `views/dashboard_views.xml`** + +Replace the entire contents with: + +```xml + + + + fusion.claims.dashboard.form + fusion.claims.dashboard + +
+ + + + + + + + + +
+
+ + Posting Period: + +
+
+ +
+
+ + +
+ Showing your assigned cases only. +
+ + +
+
+ +
+
+ +
+
+ +
+
+ + +
+ + + + + + + + +
+ + +
+ + +
+ + +
+
+ + Your Activities + + + + +
+ +
+ + +
+
+ + Bottlenecks +
+ + +
+ + +
+
Other Funders
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+ + +
+
ADP + Pre-Approval +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
ADP + Post-Approval +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
MOD
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ + + + Dashboard + fusion.claims.dashboard + form + + current + +
+``` + +- [ ] **Step 2: Upgrade the module to load the new view** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -20 +``` +Expected: no `ParseError` or `AttributeError` lines. Last line should be similar to `INFO ... Modules loaded.` + +> Note: the `widget="fc_posting_countdown"` reference does not exist yet — Odoo will render the field with the default datetime widget until Task 11 ships the widget. This is intentional; the rest of the page must load successfully without it. + +- [ ] **Step 3: Run all dashboard tests to confirm no Python regression** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -15 +``` +Expected: 31 tests still pass. + +- [ ] **Step 4: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/views/dashboard_views.xml +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): rewrite dashboard form view with action-oriented layout" +``` + +--- + +## Task 10: SCSS palette tokens + layout + manifest asset registration + +**Files:** +- Create: `fusion_claims/static/src/scss/_fc_dashboard_tokens.scss` +- Create: `fusion_claims/static/src/scss/fc_dashboard.scss` +- Modify: `fusion_claims/__manifest__.py` + +This task is verified by upgrade-and-render rather than unit tests. The acceptance check is comparing the asset bundle URLs. + +- [ ] **Step 1: Create the palette tokens file** + +Create `fusion_claims/static/src/scss/_fc_dashboard_tokens.scss`: + +```scss +// ============================================================================= +// 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; +} +``` + +- [ ] **Step 2: Create the layout file** + +Create `fusion_claims/static/src/scss/fc_dashboard.scss`: + +```scss +// ============================================================================= +// Fusion Claims Dashboard — Layout & Section Styles +// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle). +// ============================================================================= + +.o_fc_dashboard { + // Re-export tokens as CSS custom properties so devtools can inspect them + --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; + } + + .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; } + } + + // 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; } +} +``` + +- [ ] **Step 3: Register both SCSS files in the manifest, in both bundles** + +Open `fusion_claims/__manifest__.py`. Replace the `'assets':` block with: + +```python + 'assets': { + 'web.assets_backend': [ + # Existing module styles + JS — preserve order + 'fusion_claims/static/src/scss/fusion_claims.scss', + 'fusion_claims/static/src/js/document_preview.js', + 'fusion_claims/static/src/js/preview_button_widget.js', + 'fusion_claims/static/src/js/status_selection_filter.js', + 'fusion_claims/static/src/js/gallery_preview.js', + 'fusion_claims/static/src/js/tax_totals_patch.js', + 'fusion_claims/static/src/js/google_address_autocomplete.js', + 'fusion_claims/static/src/js/calendar_store_hours.js', + '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', + # NEW: dashboard tokens MUST load before dashboard layout + 'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', + 'fusion_claims/static/src/scss/fc_dashboard.scss', + ], + 'web.assets_web_dark': [ + # NEW: dark bundle re-compiles 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', + ], + }, +``` + +- [ ] **Step 4: Upgrade the module and inspect for SCSS errors** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -40 +``` +Expected: no `SCSS compile error` lines; final lines show `Modules loaded.` + +- [ ] **Step 5: Verify both bundles compile distinct URLs** + +Run via odoo-shell. Note: this is a one-off verification, not automated: + +```bash +docker exec -i odoo-modsdev-app odoo shell -d modsdev --no-http <<'PY' 2>&1 | tail -10 +backend = env['ir.qweb']._get_asset_bundle('web.assets_backend') +dark = env['ir.qweb']._get_asset_bundle('web.assets_web_dark') +print('LIGHT_URL:', backend.get_files_info()[0] if backend.get_files_info() else None) +print('DARK_URL:', dark.get_files_info()[0] if dark.get_files_info() else None) +PY +``` +Expected: two different URLs printed. If identical, see CLAUDE.md §Asset Cache Busting for fixes (delete `ir.attachment` rows under `/web/assets/%`, restart). + +- [ ] **Step 6: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/static/src/scss/_fc_dashboard_tokens.scss fusion_claims/static/src/scss/fc_dashboard.scss fusion_claims/__manifest__.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard SCSS with dual-bundle theming" +``` + +--- + +## Task 11: OWL countdown widget + +**Files:** +- Create: `fusion_claims/static/src/js/fc_posting_countdown.js` +- Create: `fusion_claims/static/src/xml/fc_posting_countdown.xml` +- Modify: `fusion_claims/__manifest__.py` (register the new JS + XML) + +- [ ] **Step 1: Create the OWL widget JS** + +Create `fusion_claims/static/src/js/fc_posting_countdown.js`: + +```javascript +/** @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, onWillStart, 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 gives 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, +}); +``` + +- [ ] **Step 2: Create the OWL template** + +Create `fusion_claims/static/src/xml/fc_posting_countdown.xml`: + +```xml + + + + + + +``` + +- [ ] **Step 3: Register the JS + XML in the manifest** + +In `fusion_claims/__manifest__.py`, extend the `web.assets_backend` list (add these two lines AFTER the SCSS dashboard files added in Task 10): + +```python + 'web.assets_backend': [ + # ...existing entries plus Task 10 SCSS lines... + 'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', + 'fusion_claims/static/src/scss/fc_dashboard.scss', + # NEW: countdown widget + 'fusion_claims/static/src/js/fc_posting_countdown.js', + 'fusion_claims/static/src/xml/fc_posting_countdown.xml', + ], +``` + +> Do NOT add the JS/XML to `web.assets_web_dark` — Odoo loads JS once from the backend bundle; only SCSS goes in both. + +- [ ] **Step 4: Upgrade the module to register the widget** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -20 +``` +Expected: no template or JS errors. Last line `Modules loaded.` + +- [ ] **Step 5: Manual browser smoke test** + +Open the dashboard menu in the browser (http://localhost:8069 → ADP Claims → Dashboard). Confirm: +- The deadline area now shows text like `3 days to cutoff` (info colour) or `2d 5h to cutoff` (warning) instead of the raw datetime. +- The text colour matches the level (info = banner-text colour, warning = amber, danger = red). +- Leave the page open for ~60 seconds and verify the displayed minutes shift by one (the widget re-renders). + +- [ ] **Step 6: Run all tests to confirm no Python regression** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -15 +``` +Expected: 31 tests still pass. + +- [ ] **Step 7: Commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/static/src/js/fc_posting_countdown.js fusion_claims/static/src/xml/fc_posting_countdown.xml fusion_claims/__manifest__.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add OWL countdown widget for posting deadline" +``` + +--- + +## Task 12: Manifest version bump + end-to-end smoke test + +**Files:** +- Modify: `fusion_claims/__manifest__.py` (version bump) + +- [ ] **Step 1: Bump module version for asset cache-bust** + +In `fusion_claims/__manifest__.py`, change: + +```python + 'version': '19.0.8.0.7', +``` + +to: + +```python + 'version': '19.0.9.0.0', +``` + +Per CLAUDE.md §Asset Cache Busting: bump the minor version so browsers don't serve stale CSS/JS. + +- [ ] **Step 2: Upgrade the module clean** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -10 +``` +Expected: `Modules loaded.` and no errors. + +- [ ] **Step 3: Run the full module test suite (not just our new tests)** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims --stop-after-init 2>&1 | tail -30 +``` +Expected: all `fusion_claims`-tagged tests pass — existing `test_signed_pages_gate.py` (11 tests) + `test_application_received_wizard.py` (17 tests) + new `test_dashboard.py` (31 tests). Total ~59 tests. + +- [ ] **Step 4: Manual end-to-end light-mode smoke test** + +In a browser (http://localhost:8069): +- Log in as admin. +- Navigate ADP Claims → Dashboard. +- Verify visually: + - Banner gradient is purple → pink wash with dark-indigo text. + - 3 KPI tiles render with $ amounts and the small uppercase labels below. + - 8 quick-action pills are green-tinted on white. + - Left column shows yellow-tinted Activities, red-tinted Bottlenecks, white Other Funders section. + - Right column shows three white ADP/MOD sections. + - Two ADP tiles (Waiting App, Needs Correction) and the On Hold tile are red-tinted (urgent). + - Countdown widget text reflects "days to cutoff". +- Click any workflow tile — confirm it opens a filtered SO list with the matching status. +- Click `+ ADP` — confirm a fresh SO form opens with `Sale Type` already set to `adp`. + +- [ ] **Step 5: Manual end-to-end dark-mode smoke test** + +- Click user profile (top right) → Color Scheme → Dark. +- Page reloads. +- Navigate ADP Claims → Dashboard. +- Verify visually: + - Banner is the cool blue monochrome (#1e293b → #1e3a5f) with sky-blue text. + - Card surfaces are #22262d-ish dark, borders subtle. + - Urgent tiles are dark red (#4a1414) with light coral numbers. + - KPI numbers are sky blue, readable on the dark slate KPI tile background. + - No element appears invisible / mid-grey on mid-grey. + +- [ ] **Step 6: Manual role-filter check** + +- Create or use an existing user who is in `fusion_claims.group_fusion_claims_user` but NOT in `fusion_claims.group_fusion_claims_manager`. +- Log in as that user. +- Open the dashboard. +- Verify: + - The "Showing your assigned cases only" alert is visible. + - All tile counts reflect only the SOs where `user_id = this user`. + - Switching back to admin (manager) — alert disappears, counts go back to full. + +- [ ] **Step 7: Final commit** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/__manifest__.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite" +``` + +--- + +## Plan Self-Review Notes + +**Spec coverage check:** + +| Spec section | Implementing task | +|---|---| +| §2 audience / role filter | Task 1, verified across all subsequent count tasks | +| §3 scope — banner | Task 2 | +| §3 scope — 3 KPI tiles | Task 3 | +| §3 scope — 8 hotlinks | Task 8 | +| §3 scope — activities | Task 4 | +| §3 scope — bottlenecks | Task 4 | +| §3 scope — ADP pre/post | Task 6 | +| §3 scope — MOD | Task 6 | +| §3 scope — other funders | Task 5 | +| §3 scope — dark/light SCSS | Task 10 | +| §4.2 file list | Each task creates/modifies its file | +| §5 role filter | Task 1 | +| §6 field inventory (36) | Tasks 1–6 | +| §7 compute clustering (5 methods) | Distributed across Tasks 1–6 | +| §8 action methods (~24) | Tasks 7 + 8 | +| §9 SCSS structure | Task 10 | +| §10 OWL countdown | Task 11 | +| §11 manifest changes | Task 10 (assets) + Task 12 (version) | +| §12 edge cases | §12.1 pre-first-posting handled in Task 2; §12.2 empty system handled by empty-state HTML in Task 4; §12.3 salesrep-no-cases handled by role filter; §12.4 portal user gating preserved by reusing the existing menu item; §12.5 multi-currency limitation documented in the spec, not enforced in code | +| §14 acceptance criteria | Task 12 manual smoke test covers AC #1, #2, #3, #4, #5, #7, #8, #9, #10. AC #6 is exercised by clicking the bottleneck tile in Step 4. | + +**Placeholder scan:** none found. All steps contain runnable code or commands. + +**Type consistency:** field names match across tasks. Method names match between definition (Task 1, 4, 5, 6, 7, 8) and consumers (form view in Task 9). The widget name `fc_posting_countdown` is consistent between Task 9 (use site) and Task 11 (definition). + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md`. diff --git a/fusion_claims/tests/__init__.py b/fusion_claims/tests/__init__.py index 1884e7ad..561d21da 100644 --- a/fusion_claims/tests/__init__.py +++ b/fusion_claims/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_signed_pages_gate from . import test_application_received_wizard +from . import test_dashboard diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py new file mode 100644 index 00000000..9fb6e99c --- /dev/null +++ b/fusion_claims/tests/test_dashboard.py @@ -0,0 +1,59 @@ +# -*- 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'}) + + 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) diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 0ca3e086..b83abd5a 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Certificates', - 'version': '19.0.7.7.0', + 'version': '19.0.7.8.0', 'category': 'Manufacturing/Plating', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'description': """ diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 5db65d09..8c1f2516 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -594,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 diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index eee4a3de..0825fd82 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.10.16.8', + 'version': '19.0.10.16.9', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 251d7e01..b0620816 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -1675,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: @@ -1713,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 diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index fabaa88f..0bc8b75a 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Logistics', - 'version': '19.0.3.9.0', + 'version': '19.0.3.10.0', 'category': 'Manufacturing/Plating', 'summary': ( 'Pickup & delivery for plating shops: vehicle master, driver ' diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index a43a0281..2dba6205 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -260,6 +260,48 @@ class FpDelivery(models.Model): def _fp_parent_counter_field(self): return 'x_fc_pn_delivery_count' + 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-[-NN]) with legacy-sequence diff --git a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml index 7287c37f..29307854 100644 --- a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml +++ b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml @@ -55,6 +55,17 @@ invisible="state in ('delivered','cancelled')"/>
{o.name}{o.partner_id.name or ""}{status_label}${o.amount_total:,.2f}
' - '' - '' + ''.join(rows) + '
OrderClientStatusTotal
' - ) - def action_open_order(self, order_id): - """Open a specific sale order with breadcrumbs.""" - return { - 'type': 'ir.actions.act_window', - 'name': 'Sale Order', - 'res_model': 'sale.order', - 'view_mode': 'form', - 'res_id': order_id, - 'target': 'current', - } + def _role_filter_domain(self): + """Common domain prefix for SO-based counts. - def action_open_adp(self): - return self._open_type_action('adp') - - def action_open_odsp(self): - return self._open_type_action('odsp') - - def action_open_march(self): - return self._open_type_action('march_of_dimes') - - def action_open_hardship(self): - return self._open_type_action('hardship') - - def action_open_acsd(self): - return self._open_type_action('acsd') - - def action_open_muscular(self): - return self._open_type_action('muscular_dystrophy') - - def action_open_insurance(self): - return self._open_type_action('insurance') - - def action_open_wsib(self): - return self._open_type_action('wsib') - - def action_open_profiles(self): - return { - 'type': 'ir.actions.act_window', 'name': 'Client Profiles', - 'res_model': 'fusion.client.profile', 'view_mode': 'list,form', - } - - def _open_type_action(self, type_key): - 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, []), - } + 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)] From d5e79cdc10f8a9af71e896c756b19851ae6e430e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:47:24 -0400 Subject: [PATCH 07/22] feat(fusion_claims): add dashboard banner fields Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 47 +++++++++++++++++++++++++++ fusion_claims/tests/test_dashboard.py | 34 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index ee9e4361..03ab31e3 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -41,3 +41,50 @@ class FusionClaimsDashboard(models.TransientModel): if self.is_manager: return [] return [('user_id', '=', self.env.user.id)] + + # ========================================================================= + # 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) diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index 9fb6e99c..0f88d8c1 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -57,3 +57,37 @@ class TestFusionClaimsDashboard(TransactionCase): 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) From f45883233c000cb89cecc6cbb07df0457f7e1085 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:48:08 -0400 Subject: [PATCH 08/22] feat(fusion_claims): add dashboard KPI tiles (ready/claimed/AR) Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 61 +++++++++++++++++++++++ fusion_claims/tests/test_dashboard.py | 69 +++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index 03ab31e3..dabf506c 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -88,3 +88,64 @@ class FusionClaimsDashboard(models.TransientModel): 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')) diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index 0f88d8c1..278d63bf 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -34,6 +34,36 @@ class TestFusionClaimsDashboard(TransactionCase): 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") @@ -91,3 +121,42 @@ class TestFusionClaimsDashboard(TransactionCase): # 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") From a00c891277d185142205534c4fad26536e26c08d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:48:41 -0400 Subject: [PATCH 09/22] feat(fusion_claims): add dashboard activities and bottlenecks Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 58 +++++++++++++++++++++++++++ fusion_claims/tests/test_dashboard.py | 48 ++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index dabf506c..84f41c2c 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -149,3 +149,61 @@ class FusionClaimsDashboard(models.TransientModel): 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 = ( + '

No activities assigned.

' + ) + 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'' + ) + rec.my_activities_html = '\n'.join(rows) + + # ========================================================================= + # Bottlenecks (left column) + # ========================================================================= + bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts') + bottleneck_no_response_count = 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() + + 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), + ]) diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index 278d63bf..589687e9 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -160,3 +160,51 @@ class TestFusionClaimsDashboard(TransactionCase): 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) From cdc9f864b28528ea6f33fe3a765fbfc1d65d8d6a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:49:10 -0400 Subject: [PATCH 10/22] feat(fusion_claims): add dashboard other-funder counts Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 19 ++++++++++++++- fusion_claims/tests/test_dashboard.py | 34 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index 84f41c2c..24d237a1 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -187,17 +187,25 @@ class FusionClaimsDashboard(models.TransientModel): rec.my_activities_html = '\n'.join(rows) # ========================================================================= - # Bottlenecks (left column) + # 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']), @@ -207,3 +215,12 @@ class FusionClaimsDashboard(models.TransientModel): ('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')]) diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index 589687e9..cf8534b4 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -208,3 +208,37 @@ class TestFusionClaimsDashboard(TransactionCase): }) 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) From 95e0e2d9bd608c2a6d037a88b2b0b7d5ac113e18 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:49:48 -0400 Subject: [PATCH 11/22] feat(fusion_claims): add dashboard ADP + MOD workflow tile counts Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 76 +++++++++++++++++++++++++++ fusion_claims/tests/test_dashboard.py | 57 ++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index 24d237a1..3b6fcfc5 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -224,3 +224,79 @@ class FusionClaimsDashboard(models.TransientModel): 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'), + ]) diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index cf8534b4..08faf6f0 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -242,3 +242,60 @@ class TestFusionClaimsDashboard(TransactionCase): 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) From 1b1e9fdb9eeb1669389ee90fbee0ba3a56919283 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:50:32 -0400 Subject: [PATCH 12/22] feat(fusion_claims): add dashboard open-list action methods Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 195 ++++++++++++++++++++++++++ fusion_claims/tests/test_dashboard.py | 28 ++++ 2 files changed, 223 insertions(+) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index 3b6fcfc5..29386954 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -300,3 +300,198 @@ class FusionClaimsDashboard(models.TransientModel): rec.mod_pod_submitted_count = SO.search_count(base + [ ('x_fc_mod_status', '=', 'pod_submitted'), ]) + + # ========================================================================= + # Open-list action methods + # ========================================================================= + def _so_list_action(self, name, domain): + return { + 'type': 'ir.actions.act_window', + 'name': name, + 'res_model': 'sale.order', + 'view_mode': 'list,form', + 'domain': self._role_filter_domain() + domain, + 'target': 'current', + } + + # ----- 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_adp_app_received(self): + return self._so_list_action('ADP — Application Received', [ + ('x_fc_adp_application_status', '=', 'application_received'), + ]) + + 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_adp_needs_correction(self): + return self._so_list_action('ADP — Needs Correction', [ + ('x_fc_adp_application_status', '=', 'needs_correction'), + ]) + + # ----- 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_adp_ready_delivery(self): + return self._so_list_action('ADP — Ready for Delivery', [ + ('x_fc_adp_application_status', '=', 'ready_delivery'), + ]) + + 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_adp_on_hold(self): + return self._so_list_action('ADP — On Hold', [ + ('x_fc_adp_application_status', '=', 'on_hold'), + ]) + + # ----- MOD ----- + def action_open_mod_awaiting_funding(self): + return self._so_list_action('MOD — Awaiting Funding', [ + ('x_fc_mod_status', '=', 'awaiting_funding'), + ]) + + 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': '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', + } diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index 08faf6f0..d87f4c94 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -299,3 +299,31 @@ class TestFusionClaimsDashboard(TransactionCase): 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') From ace82de88c94e21b6ec95dadb6da3b044f12b5c6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:50:58 -0400 Subject: [PATCH 13/22] feat(fusion_claims): add dashboard create-SO hotlinks Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/models/dashboard.py | 50 +++++++++++++++++++++++++++ fusion_claims/tests/test_dashboard.py | 38 ++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index 29386954..0db3f61a 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -495,3 +495,53 @@ class FusionClaimsDashboard(models.TransientModel): ], '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'}) diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py index d87f4c94..b15003ef 100644 --- a/fusion_claims/tests/test_dashboard.py +++ b/fusion_claims/tests/test_dashboard.py @@ -327,3 +327,41 @@ class TestFusionClaimsDashboard(TransactionCase): 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") From 2bfb1015ea8d35c5b6db26bdbd43d5d77061ca7c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:51:59 -0400 Subject: [PATCH 14/22] feat(fusion_claims): rewrite dashboard form view with action-oriented layout Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/views/dashboard_views.xml | 475 +++++++++++++++++------- 1 file changed, 351 insertions(+), 124 deletions(-) diff --git a/fusion_claims/views/dashboard_views.xml b/fusion_claims/views/dashboard_views.xml index f5fd88d9..51d03315 100644 --- a/fusion_claims/views/dashboard_views.xml +++ b/fusion_claims/views/dashboard_views.xml @@ -4,157 +4,384 @@ fusion.claims.dashboard.form fusion.claims.dashboard -
+ - -
-
- + + + + + + + + +
+
+ + Posting Period: +
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- +
+
- + +
+ Showing your assigned cases only. +
+ +
-
-
Window 1
- +
+
-
-
Window 2
- +
+
-
-
Window 3
- -
-
-
Window 4
- +
+
- -
-
-
-
- -
-
- -
-
-
-
-
-
- -
-
- -
-
-
+ +
+ + + + + + + +
- +
-
-
-
- -
-
- + + +
+ + +
+
+ + Your Activities + + + + +
+ +
+ + +
+
+ + Bottlenecks +
+ + +
+ + +
+
Other Funders
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
-
-
-
- + + +
+ + +
+
ADP + Pre-Approval +
+
+
+ +
+
+ +
+
+ +
+
+ +
-
- +
+ + +
+
ADP + Post-Approval +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
MOD
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
- + Dashboard fusion.claims.dashboard From 1420a5c4451c7e19c18e1e14b99b25be0dfd97e4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:52:57 -0400 Subject: [PATCH 15/22] feat(fusion_claims): add dashboard SCSS with dual-bundle theming Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 9 + .../static/src/scss/_fc_dashboard_tokens.scss | 81 +++++++ .../static/src/scss/fc_dashboard.scss | 212 ++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 fusion_claims/static/src/scss/_fc_dashboard_tokens.scss create mode 100644 fusion_claims/static/src/scss/fc_dashboard.scss diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 2ae7dc53..cd5a9da1 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -175,6 +175,15 @@ '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', + ], + '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'], diff --git a/fusion_claims/static/src/scss/_fc_dashboard_tokens.scss b/fusion_claims/static/src/scss/_fc_dashboard_tokens.scss new file mode 100644 index 00000000..f1f68d4f --- /dev/null +++ b/fusion_claims/static/src/scss/_fc_dashboard_tokens.scss @@ -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; +} diff --git a/fusion_claims/static/src/scss/fc_dashboard.scss b/fusion_claims/static/src/scss/fc_dashboard.scss new file mode 100644 index 00000000..bf3568cc --- /dev/null +++ b/fusion_claims/static/src/scss/fc_dashboard.scss @@ -0,0 +1,212 @@ +// ============================================================================= +// Fusion Claims Dashboard — Layout & Section Styles +// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle). +// ============================================================================= + +.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; + } + + .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; } + } + + // 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; } +} From 07f9bcf79bbe398949dd5b23cb5e35751c0ed22c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:53:18 -0400 Subject: [PATCH 16/22] feat(fusion_claims): add OWL countdown widget for posting deadline Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 3 + .../static/src/js/fc_posting_countdown.js | 63 +++++++++++++++++++ .../static/src/xml/fc_posting_countdown.xml | 7 +++ 3 files changed, 73 insertions(+) create mode 100644 fusion_claims/static/src/js/fc_posting_countdown.js create mode 100644 fusion_claims/static/src/xml/fc_posting_countdown.xml diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index cd5a9da1..955503d4 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -178,6 +178,9 @@ # 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 diff --git a/fusion_claims/static/src/js/fc_posting_countdown.js b/fusion_claims/static/src/js/fc_posting_countdown.js new file mode 100644 index 00000000..40f8c71f --- /dev/null +++ b/fusion_claims/static/src/js/fc_posting_countdown.js @@ -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, +}); diff --git a/fusion_claims/static/src/xml/fc_posting_countdown.xml b/fusion_claims/static/src/xml/fc_posting_countdown.xml new file mode 100644 index 00000000..83c7b599 --- /dev/null +++ b/fusion_claims/static/src/xml/fc_posting_countdown.xml @@ -0,0 +1,7 @@ + + + + + + From b70fff01e144466e81280d1ff02766342d74de29 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:53:25 -0400 Subject: [PATCH 17/22] feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 955503d4..c0e8115e 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.8.0.7', + 'version': '19.0.9.0.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ From 5b6e53c863ca923f1abed994d3953b25d7b74e5c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 04:04:20 -0400 Subject: [PATCH 18/22] fix(fusion_claims): add Dashboard menu item under ADP Claims root The dashboard action existed but no menuitem ever pointed to it (latent bug in the original module). Adding menu_fusion_claims_dashboard as the first child of menu_adp_claims_root so the dashboard becomes the default landing for the Fusion Claims app. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 2 +- fusion_claims/views/dashboard_views.xml | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index c0e8115e..f873963f 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.9.0.0', + 'version': '19.0.9.0.1', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/views/dashboard_views.xml b/fusion_claims/views/dashboard_views.xml index 51d03315..de6e7f81 100644 --- a/fusion_claims/views/dashboard_views.xml +++ b/fusion_claims/views/dashboard_views.xml @@ -381,7 +381,7 @@ - + Dashboard fusion.claims.dashboard @@ -389,4 +389,13 @@ current + + + From 4025789ba0fc1ee0f14cf49f6dd904d3b52af741 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 04:26:25 -0400 Subject: [PATCH 19/22] feat(fusion_claims): expand dashboard with this-month, pipeline, aging, recent exports + full-width Adds 4 new sections: - This Month rollup: submitted/approved/delivered/billed counts MTD - Pipeline $ by stage: pre-submit / submitted / approved / ready-to-bill amounts - Aging buckets: 30-59d, 60-89d, 90+ days - Recent ADP Exports: last 5 with totals Also overrides Odoo's form-sheet max-width on .o_fc_dashboard so the dashboard uses the full browser width. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 2 +- fusion_claims/models/dashboard.py | 219 ++++++++++++++++++ .../static/src/scss/fc_dashboard.scss | 37 +++ fusion_claims/views/dashboard_views.xml | 164 ++++++++++++- 4 files changed, 415 insertions(+), 7 deletions(-) diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index f873963f..12201f20 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.9.0.1', + 'version': '19.0.9.1.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/models/dashboard.py b/fusion_claims/models/dashboard.py index 0db3f61a..1e36d9d6 100644 --- a/fusion_claims/models/dashboard.py +++ b/fusion_claims/models/dashboard.py @@ -42,6 +42,10 @@ class FusionClaimsDashboard(models.TransientModel): return [] return [('user_id', '=', self.env.user.id)] + def _month_start(self): + from datetime import date + return date.today().replace(day=1) + # ========================================================================= # Header banner # ========================================================================= @@ -301,6 +305,139 @@ class FusionClaimsDashboard(models.TransientModel): ('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 = ( + '

No exports yet.

' + ) + 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'
' + f'
' + f'{label}' + f'
{date_str} · {inv_count} inv' + f'
' + f'
${total:,.0f}
' + f'
' + ) + rec.recent_exports_html = '\n'.join(rows) + # ========================================================================= # Open-list action methods # ========================================================================= @@ -545,3 +682,85 @@ class FusionClaimsDashboard(models.TransientModel): 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', + } diff --git a/fusion_claims/static/src/scss/fc_dashboard.scss b/fusion_claims/static/src/scss/fc_dashboard.scss index bf3568cc..655a2cef 100644 --- a/fusion_claims/static/src/scss/fc_dashboard.scss +++ b/fusion_claims/static/src/scss/fc_dashboard.scss @@ -3,6 +3,15 @@ // Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle). // ============================================================================= +// Override Odoo's form-sheet max-width so the dashboard uses the full +// browser width. The selector matches the form (which carries the class) +// and targets the inner sheet element. +.o_fc_dashboard .o_form_sheet, +.o_form_view.o_fc_dashboard .o_form_sheet { + max-width: none; + width: 100%; +} + .o_fc_dashboard { // Re-export tokens as CSS custom properties for devtools inspection --fc-page-bg: #{$_fc-page-bg}; @@ -73,6 +82,13 @@ 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; @@ -197,6 +213,27 @@ &: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; diff --git a/fusion_claims/views/dashboard_views.xml b/fusion_claims/views/dashboard_views.xml index de6e7f81..f846d519 100644 --- a/fusion_claims/views/dashboard_views.xml +++ b/fusion_claims/views/dashboard_views.xml @@ -84,6 +84,102 @@
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ + Aging +
+
+
+ +
+
+ +
+
+ +
+
+
+
Other Funders
-
+
-
+
-
+
-
+
-
+
-
+
+ + +
+
+ + Recent ADP Exports + + + + +
+ +
From 5295aefd8ff70fd6edc09b8cf665c08c57827e44 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 04:30:04 -0400 Subject: [PATCH 20/22] fix(fusion_claims): force full-width dashboard sheet with dedicated class The .o_fc_dashboard .o_form_sheet override wasn't winning specificity against Odoo's default form-sheet constraints. Added a dedicated class o_fc_dashboard_sheet directly on the element + !important overrides on max-width, width, and flex to stretch the sheet to the full container width. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 2 +- .../static/src/scss/fc_dashboard.scss | 22 ++++++++++++++----- fusion_claims/views/dashboard_views.xml | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 12201f20..5aa43d15 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.9.1.0', + 'version': '19.0.9.1.1', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/static/src/scss/fc_dashboard.scss b/fusion_claims/static/src/scss/fc_dashboard.scss index 655a2cef..0865a4d6 100644 --- a/fusion_claims/static/src/scss/fc_dashboard.scss +++ b/fusion_claims/static/src/scss/fc_dashboard.scss @@ -4,12 +4,22 @@ // ============================================================================= // Override Odoo's form-sheet max-width so the dashboard uses the full -// browser width. The selector matches the form (which carries the class) -// and targets the inner sheet element. -.o_fc_dashboard .o_form_sheet, -.o_form_view.o_fc_dashboard .o_form_sheet { - max-width: none; - width: 100%; +// browser width. Class is applied directly to (becomes +// .o_form_sheet .o_fc_dashboard_sheet at runtime) — combined with +// !important to beat Odoo's default + parent flex constraints. +.o_fc_dashboard_sheet, +.o_form_sheet.o_fc_dashboard_sheet, +.o_fc_dashboard .o_form_sheet { + max-width: none !important; + width: 100% !important; + flex: 1 1 auto !important; +} +// Some Odoo themes wrap the sheet in a flex container with its own +// max-width; force the wrapper to stretch as well. +.o_form_sheet_bg:has(> .o_fc_dashboard_sheet), +.o_form_view:has(.o_fc_dashboard_sheet) .o_form_sheet_bg { + max-width: none !important; + width: 100% !important; } .o_fc_dashboard { diff --git a/fusion_claims/views/dashboard_views.xml b/fusion_claims/views/dashboard_views.xml index f846d519..d15eb8ab 100644 --- a/fusion_claims/views/dashboard_views.xml +++ b/fusion_claims/views/dashboard_views.xml @@ -6,7 +6,7 @@
- + From 3440e4b7c6d37d62a04a86f11c9b853ec8bcf842 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 04:38:39 -0400 Subject: [PATCH 21/22] feat(fusion_claims): force full-width sheet + 3-col responsive layout at xl Aggressive sheet override: flex-basis 100%%, !important on width and max-width to beat parent flex/media-query constraints. Also overrides the o_form_sheet_bg wrapper. Layout at xl (>=1200px) now splits into 3 columns: - Col 1 (3/12): Your Activities + Bottlenecks - Col 2 (5/12): ADP Pre + ADP Post + MOD - Col 3 (4/12): Aging + Other Funders + Recent ADP Exports Falls back to 5/7 on lg (Col 3 wraps below as full row) and stacked single column on md and below. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_claims/__manifest__.py | 2 +- .../static/src/scss/fc_dashboard.scss | 47 +++- fusion_claims/views/dashboard_views.xml | 230 +++++++++--------- 3 files changed, 154 insertions(+), 125 deletions(-) diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 5aa43d15..db386ea9 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.9.1.1', + 'version': '19.0.9.2.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/static/src/scss/fc_dashboard.scss b/fusion_claims/static/src/scss/fc_dashboard.scss index 0865a4d6..fdadcb6d 100644 --- a/fusion_claims/static/src/scss/fc_dashboard.scss +++ b/fusion_claims/static/src/scss/fc_dashboard.scss @@ -3,23 +3,46 @@ // Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle). // ============================================================================= -// Override Odoo's form-sheet max-width so the dashboard uses the full -// browser width. Class is applied directly to (becomes -// .o_form_sheet .o_fc_dashboard_sheet at runtime) — combined with -// !important to beat Odoo's default + parent flex constraints. +// ============================================================================= +// Force full-width sheet on the dashboard. The sheet defaults to ~1100-1300px +// max-width via `flex: 1 1 ` 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_fc_dashboard .o_form_sheet { - max-width: none !important; +.o_form_view .o_fc_dashboard_sheet, +.o_form_renderer .o_fc_dashboard_sheet { + max-width: 100% !important; width: 100% !important; - flex: 1 1 auto !important; + min-width: 100% !important; + flex: 1 1 100% !important; + flex-basis: 100% !important; + margin: 0 !important; } -// Some Odoo themes wrap the sheet in a flex container with its own -// max-width; force the wrapper to stretch as well. -.o_form_sheet_bg:has(> .o_fc_dashboard_sheet), -.o_form_view:has(.o_fc_dashboard_sheet) .o_form_sheet_bg { - max-width: none !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 { diff --git a/fusion_claims/views/dashboard_views.xml b/fusion_claims/views/dashboard_views.xml index d15eb8ab..17c736ce 100644 --- a/fusion_claims/views/dashboard_views.xml +++ b/fusion_claims/views/dashboard_views.xml @@ -200,11 +200,11 @@ class="o_fc_pill">+ Private
- +
- -
+ +
@@ -243,117 +243,10 @@
- - -
-
- - Aging -
-
-
- -
-
- -
-
- -
-
-
- - -
-
Other Funders
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- -
+ +
@@ -510,6 +403,119 @@
+
+ + +
+ + +
+
+ + Aging +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
Other Funders
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
From d6d6249857d09d692b58410397420683aee3fd93 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 04:47:45 -0400 Subject: [PATCH 22/22] changes --- fusion_plating/CLAUDE.md | 3 +- .../__manifest__.py | 2 +- .../views/sale_order_views.xml | 74 +-- .../fusion_plating_logistics/__manifest__.py | 2 +- .../models/fp_delivery.py | 52 +++ .../views/fp_delivery_views.xml | 24 + .../fusion_plating_reports/__manifest__.py | 2 +- .../report/report_actions.xml | 36 ++ .../report/report_fp_packing_slip.xml | 442 +++++++++++------- .../report/report_fp_sale.xml | 274 +++++++---- 10 files changed, 610 insertions(+), 301 deletions(-) diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index f1ee281e..81516dde 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -29,7 +29,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and | **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` | | **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 `` elements** (and to wrapper `
`s that wrap whole logical sections like signature blocks), not to ``. On entech wkhtmltopdf, `
`-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 `
` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` | | **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` | -| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix:** size `paperformat.margin_top` to the actual rendered header height, then drop body `padding-top` to a tiny visual gap (~5mm). Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12). Update the right one and don't bleed changes across reports. | `fusion_plating_reports`, `report.paperformat` | +| **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 `` 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 `
` 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 `
` 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 `
` 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 `
` for non-bordered layouts** like a title/barcode strip; use `
` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb ` + + + + + + + + + + + @@ -16,6 +155,25 @@ + + + + + + + + + + + + +
@@ -24,92 +182,67 @@ - -
- - - - - - + +
FROMSHIP TO
- - - - -
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - +
SHIP DATESOURCEOPERATIONCARRIER
- - + + + + + + + + + - -
- - + +
- - - - - + + + - - - - - - - - - - + + + + +
PART NUMBERDESCRIPTIONQTYUOMLOT / SERIAL + Ship ViaMode d'expédition + + Shipping DateDate d'expédition + + Tracking #N° de suivi +
- - - - - - - - -
-
-
-
+ + + + +
+ + + + + + + + + + + +
@@ -118,21 +251,8 @@
- -
-
-
-
-
Shipper (Signature / Date)
-
-
-
-
-
-
Receiver (Signature / Date)
-
-
-
+ +
@@ -149,6 +269,18 @@ + + + + + + + + + + + +
@@ -157,104 +289,67 @@
- - - - - - - - + +
FROMSHIP TO
- -
-
-
+
+ + + + -
-
+
+ + + +
- - + +
- - - - - + + + - - - - + - +
SHIP DATESOURCEOPERATIONCARRIERTRACKING REF + Ship ViaMode d'expédition + + Shipping DateDate d'expédition + + Tracking #N° de suivi +
- - + + + - - - - - - - - +
- - - - - - - - - - - - - - - - - - - - - - - - - - -
PART NUMBERDESCRIPTIONORDEREDDONEUOMLOT / SERIALNOTES
- - - - - - - - - - -
-
-
-
-
+ + + + + + + + + + + @@ -264,21 +359,8 @@
- -
-
-
-
-
Shipper (Signature / Date)
-
-
-
-
-
-
Receiver (Signature / Date)
-
-
-
+ +
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml b/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml index 101b2e80..cb2be321 100644 --- a/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml +++ b/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml @@ -11,28 +11,99 @@ + + +