227 lines
10 KiB
Python
227 lines
10 KiB
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)]
|
||
|
||
# =========================================================================
|
||
# Header banner
|
||
# =========================================================================
|
||
posting_period_label = fields.Char(compute='_compute_banner')
|
||
posting_period_start = fields.Date(compute='_compute_banner')
|
||
posting_period_end = fields.Date(compute='_compute_banner')
|
||
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
|
||
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
|
||
|
||
def _compute_banner(self):
|
||
from datetime import date, datetime, time, timedelta
|
||
import pytz
|
||
|
||
today = date.today()
|
||
for rec in self:
|
||
base_date = rec._get_adp_posting_base_date()
|
||
rec.is_pre_first_posting = today < base_date
|
||
|
||
current = rec._get_current_posting_date(today)
|
||
nxt = rec._get_next_posting_date(today)
|
||
# If we're sitting on a posting date, current == next; treat
|
||
# the period as the one starting today.
|
||
if current == nxt:
|
||
period_start = current
|
||
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
|
||
else:
|
||
period_start = current
|
||
period_end = nxt
|
||
|
||
rec.posting_period_start = period_start
|
||
rec.posting_period_end = period_end
|
||
|
||
if rec.is_pre_first_posting:
|
||
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
|
||
else:
|
||
rec.posting_period_label = (
|
||
f"{period_start.strftime('%b %d')} – "
|
||
f"{period_end.strftime('%b %d')}"
|
||
)
|
||
|
||
wednesday = rec._get_posting_week_wednesday(nxt)
|
||
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
|
||
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
|
||
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
|
||
local_deadline = tz.localize(naive_deadline)
|
||
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
|
||
|
||
# =========================================================================
|
||
# KPI tiles (3-up)
|
||
# =========================================================================
|
||
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
|
||
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
|
||
currency_field='currency_id')
|
||
kpi_ready_count = fields.Integer(compute='_compute_kpis')
|
||
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
|
||
currency_field='currency_id')
|
||
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
|
||
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
|
||
currency_field='currency_id')
|
||
kpi_ar_count = fields.Integer(compute='_compute_kpis')
|
||
|
||
def _invoice_role_filter(self):
|
||
"""Role filter for invoices — applied through linked SO's user_id."""
|
||
self.ensure_one()
|
||
if self.is_manager:
|
||
return []
|
||
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
|
||
|
||
def _compute_kpis(self):
|
||
Move = self.env['account.move'].sudo()
|
||
for rec in self:
|
||
rec.currency_id = rec.env.company.currency_id
|
||
|
||
inv_filter = rec._invoice_role_filter()
|
||
|
||
# KPI 1: Ready to Claim
|
||
ready_domain = inv_filter + [
|
||
('move_type', '=', 'out_invoice'),
|
||
('state', '=', 'posted'),
|
||
('x_fc_adp_billing_status', '=', 'waiting'),
|
||
('adp_exported', '=', False),
|
||
]
|
||
ready_invoices = Move.search(ready_domain)
|
||
rec.kpi_ready_count = len(ready_invoices)
|
||
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
|
||
|
||
# KPI 2: Claimed This Period
|
||
claimed_domain = inv_filter + [
|
||
('move_type', '=', 'out_invoice'),
|
||
('state', '=', 'posted'),
|
||
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
|
||
('adp_export_date', '>=', rec.posting_period_start),
|
||
]
|
||
claimed_invoices = Move.search(claimed_domain)
|
||
rec.kpi_claimed_count = len(claimed_invoices)
|
||
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
|
||
|
||
# KPI 3: Total AR (ADP-portion invoices, unpaid)
|
||
ar_domain = inv_filter + [
|
||
('move_type', '=', 'out_invoice'),
|
||
('state', '=', 'posted'),
|
||
('x_fc_invoice_type', '=', 'adp'),
|
||
('payment_state', 'in', ['not_paid', 'partial']),
|
||
]
|
||
ar_invoices = Move.search(ar_domain)
|
||
rec.kpi_ar_count = len(ar_invoices)
|
||
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
|
||
|
||
# =========================================================================
|
||
# Activities (left column)
|
||
# =========================================================================
|
||
my_activities_count = fields.Integer(compute='_compute_activities')
|
||
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
|
||
|
||
def _compute_activities(self):
|
||
Activity = self.env['mail.activity'].sudo()
|
||
domain = [
|
||
('user_id', '=', self.env.user.id),
|
||
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
|
||
]
|
||
for rec in self:
|
||
activities = Activity.search(domain, order='date_deadline asc', limit=10)
|
||
rec.my_activities_count = Activity.search_count(domain)
|
||
if not activities:
|
||
rec.my_activities_html = (
|
||
'<p class="o_fc_empty">No activities assigned.</p>'
|
||
)
|
||
continue
|
||
from datetime import date
|
||
today = date.today()
|
||
rows = []
|
||
for act in activities:
|
||
overdue = act.date_deadline and act.date_deadline < today
|
||
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
|
||
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else '—'
|
||
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
|
||
rows.append(
|
||
f'<div class="{row_class}">'
|
||
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
|
||
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
|
||
f'</div>'
|
||
)
|
||
rec.my_activities_html = '\n'.join(rows)
|
||
|
||
# =========================================================================
|
||
# Bottlenecks (left column) + Other funder counts
|
||
# =========================================================================
|
||
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
|
||
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
|
||
|
||
count_odsp = fields.Integer(compute='_compute_secondary_counts')
|
||
count_wsib = fields.Integer(compute='_compute_secondary_counts')
|
||
count_insurance = fields.Integer(compute='_compute_secondary_counts')
|
||
count_mdc = fields.Integer(compute='_compute_secondary_counts')
|
||
count_hardship = fields.Integer(compute='_compute_secondary_counts')
|
||
count_acsd = fields.Integer(compute='_compute_secondary_counts')
|
||
|
||
def _compute_secondary_counts(self):
|
||
from datetime import date, timedelta
|
||
SO = self.env['sale.order'].sudo()
|
||
cutoff_14d_ago = date.today() - timedelta(days=14)
|
||
for rec in self:
|
||
base = rec._role_filter_domain()
|
||
active = base + [('state', '!=', 'cancel')]
|
||
|
||
rec.bottleneck_no_pod_count = SO.search_count(base + [
|
||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||
('x_fc_proof_of_delivery', '=', False),
|
||
])
|
||
rec.bottleneck_no_response_count = SO.search_count(base + [
|
||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
|
||
])
|
||
|
||
rec.count_odsp = SO.search_count(active + [
|
||
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
|
||
])
|
||
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
|
||
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
|
||
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
|
||
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
|
||
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
|