# -*- 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'
' f'{act.summary or act.activity_type_id.name or "Activity"}' f'{deadline_text}' 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'})