# -*- 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)]
# =========================================================================
# Header banner
# =========================================================================
posting_period_label = fields.Char(compute='_compute_banner')
posting_period_start = fields.Date(compute='_compute_banner')
posting_period_end = fields.Date(compute='_compute_banner')
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
def _compute_banner(self):
from datetime import date, datetime, time, timedelta
import pytz
today = date.today()
for rec in self:
base_date = rec._get_adp_posting_base_date()
rec.is_pre_first_posting = today < base_date
current = rec._get_current_posting_date(today)
nxt = rec._get_next_posting_date(today)
# If we're sitting on a posting date, current == next; treat
# the period as the one starting today.
if current == nxt:
period_start = current
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
else:
period_start = current
period_end = nxt
rec.posting_period_start = period_start
rec.posting_period_end = period_end
if rec.is_pre_first_posting:
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
else:
rec.posting_period_label = (
f"{period_start.strftime('%b %d')} – "
f"{period_end.strftime('%b %d')}"
)
wednesday = rec._get_posting_week_wednesday(nxt)
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
local_deadline = tz.localize(naive_deadline)
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
# =========================================================================
# KPI tiles (3-up)
# =========================================================================
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ready_count = fields.Integer(compute='_compute_kpis')
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ar_count = fields.Integer(compute='_compute_kpis')
def _invoice_role_filter(self):
"""Role filter for invoices — applied through linked SO's user_id."""
self.ensure_one()
if self.is_manager:
return []
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
def _compute_kpis(self):
Move = self.env['account.move'].sudo()
for rec in self:
rec.currency_id = rec.env.company.currency_id
inv_filter = rec._invoice_role_filter()
# KPI 1: Ready to Claim
ready_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
]
ready_invoices = Move.search(ready_domain)
rec.kpi_ready_count = len(ready_invoices)
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
# KPI 2: Claimed This Period
claimed_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', rec.posting_period_start),
]
claimed_invoices = Move.search(claimed_domain)
rec.kpi_claimed_count = len(claimed_invoices)
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
# KPI 3: Total AR (ADP-portion invoices, unpaid)
ar_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
]
ar_invoices = Move.search(ar_domain)
rec.kpi_ar_count = len(ar_invoices)
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
# =========================================================================
# Activities (left column)
# =========================================================================
my_activities_count = fields.Integer(compute='_compute_activities')
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
def _compute_activities(self):
Activity = self.env['mail.activity'].sudo()
domain = [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
]
for rec in self:
activities = Activity.search(domain, order='date_deadline asc', limit=10)
rec.my_activities_count = Activity.search_count(domain)
if not activities:
rec.my_activities_html = (
'
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) + Other funder counts
# =========================================================================
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
count_odsp = fields.Integer(compute='_compute_secondary_counts')
count_wsib = fields.Integer(compute='_compute_secondary_counts')
count_insurance = fields.Integer(compute='_compute_secondary_counts')
count_mdc = fields.Integer(compute='_compute_secondary_counts')
count_hardship = fields.Integer(compute='_compute_secondary_counts')
count_acsd = fields.Integer(compute='_compute_secondary_counts')
def _compute_secondary_counts(self):
from datetime import date, timedelta
SO = self.env['sale.order'].sudo()
cutoff_14d_ago = date.today() - timedelta(days=14)
for rec in self:
base = rec._role_filter_domain()
active = base + [('state', '!=', 'cancel')]
rec.bottleneck_no_pod_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
rec.bottleneck_no_response_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
])
rec.count_odsp = SO.search_count(active + [
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
# =========================================================================
# ADP Pre-Approval (right column, 4 tiles)
# =========================================================================
adp_waiting_app_count = fields.Integer(compute='_compute_workflow_counts')
adp_app_received_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_submit_count = fields.Integer(compute='_compute_workflow_counts')
adp_needs_correction_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# ADP Post-Approval (right column, 4 tiles)
# =========================================================================
adp_approved_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_delivery_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_bill_count = fields.Integer(compute='_compute_workflow_counts')
adp_on_hold_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# MOD (right column, 5 tiles)
# =========================================================================
mod_awaiting_funding_count = fields.Integer(compute='_compute_workflow_counts')
mod_funding_approved_count = fields.Integer(compute='_compute_workflow_counts')
mod_pca_received_count = fields.Integer(compute='_compute_workflow_counts')
mod_project_complete_count = fields.Integer(compute='_compute_workflow_counts')
mod_pod_submitted_count = fields.Integer(compute='_compute_workflow_counts')
def _compute_workflow_counts(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
# ADP Pre-Approval
rec.adp_waiting_app_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
rec.adp_app_received_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'application_received'),
])
rec.adp_ready_submit_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
rec.adp_needs_correction_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
# ADP Post-Approval
rec.adp_approved_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
rec.adp_ready_delivery_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
rec.adp_ready_bill_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
rec.adp_on_hold_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'on_hold'),
])
# MOD
rec.mod_awaiting_funding_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
rec.mod_funding_approved_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'funding_approved'),
])
rec.mod_pca_received_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'contract_received'),
])
rec.mod_project_complete_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'project_complete'),
])
rec.mod_pod_submitted_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# =========================================================================
# 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',
}
# =========================================================================
# 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'})