fix(dashboard): align outstanding totals + project-name dedupe
The home dashboard and payroll dashboard used to disagree on "outstanding payments" because the home version included inactive workers' unpaid wages while the payroll dashboard's per-worker loop only iterated active workers. Symptom was the same field showing two different R-amounts depending on which page you opened first. Also fixes the Outstanding-by-Project card silently merging two projects when they share a name (it was keyed by project_name). - `_compute_outstanding` now defaults to active workers only. Pass `include_inactive_workers=True` to surface deactivated-worker liabilities (rare; usually means a forgotten payment). - Output is keyed by project_id (with name as data) so two projects with identical names stay as separate rows. - New `outstanding_by_project_sorted` list — pre-sorted by amount desc — replaces the dict iteration in templates. - "Active Loans" card on the home dashboard renamed to "Active Loans & Advances" so the label matches its data (which already summed both loan_types). - Regression tests: ComputeOutstandingActiveScopeTests + ComputeOutstandingProjectIdKeyingTests. Findings 1, 7/17, 8. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
18c75b2bce
commit
2e6b78d28a
@ -86,7 +86,7 @@
|
||||
<div class="stat-card stat-card--warning h-100 p-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Active Loans ({{ active_loans_count }})</div>
|
||||
<div class="stat-label">Active Loans & Advances ({{ active_loans_count }})</div>
|
||||
<div class="stat-value">R {{ active_loans_balance|floatformat:2 }}</div>
|
||||
</div>
|
||||
<div class="stat-icon stat-icon--warning">
|
||||
@ -147,12 +147,14 @@
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div style="flex: 1;">
|
||||
<div class="stat-label">Outstanding by Project</div>
|
||||
{% if outstanding_by_project %}
|
||||
{% if outstanding_by_project_list %}
|
||||
{# Sorted by amount desc. Keyed by project_id so two projects
|
||||
sharing a name don't get merged. #}
|
||||
<div style="font-size: 0.85rem; margin-top: 0.35rem;">
|
||||
{% for proj, amount in outstanding_by_project.items %}
|
||||
{% for proj in outstanding_by_project_list %}
|
||||
<div class="d-flex justify-content-between" style="color: var(--text-primary);">
|
||||
<span class="text-truncate me-2">{{ proj }}</span>
|
||||
<span class="fw-semibold" style="white-space: nowrap;">R {{ amount|floatformat:2 }}</span>
|
||||
<span class="text-truncate me-2">{{ proj.name }}</span>
|
||||
<span class="fw-semibold" style="white-space: nowrap;">R {{ proj.amount|floatformat:2 }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
102
core/tests.py
102
core/tests.py
@ -597,6 +597,108 @@ class CurrentOutstandingInScopeTests(TestCase):
|
||||
self.assertNotIn(Decimal('500.00'), amounts)
|
||||
|
||||
|
||||
class ComputeOutstandingProjectIdKeyingTests(TestCase):
|
||||
"""Regression for Finding 8: outstanding_by_project must key by
|
||||
project_id (not name) so two projects with identical names stay
|
||||
separate rows in the home dashboard's 'Outstanding by Project' card."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username='a-pidkey', is_staff=True)
|
||||
# Two distinct projects that happen to share a name (e.g. a
|
||||
# historic project that was renamed and a new one reusing the
|
||||
# same label — perfectly possible in this codebase).
|
||||
self.p1 = Project.objects.create(name='Solar Phase 1')
|
||||
self.p2 = Project.objects.create(name='Solar Phase 1')
|
||||
self.w = Worker.objects.create(
|
||||
name='W', id_number='W-PID', monthly_salary=Decimal('4000'),
|
||||
)
|
||||
log1 = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 1),
|
||||
project=self.p1, supervisor=self.admin,
|
||||
)
|
||||
log1.workers.add(self.w)
|
||||
log2 = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 2),
|
||||
project=self.p2, supervisor=self.admin,
|
||||
)
|
||||
log2.workers.add(self.w)
|
||||
|
||||
def test_same_named_projects_stay_separate_in_sorted_list(self):
|
||||
from core.views import _compute_outstanding
|
||||
result = _compute_outstanding()
|
||||
# Both rows should appear separately — keyed by project_id.
|
||||
same_named = [
|
||||
r for r in result['outstanding_by_project_sorted']
|
||||
if r['name'] == 'Solar Phase 1'
|
||||
]
|
||||
self.assertEqual(
|
||||
len(same_named), 2,
|
||||
'Two distinct projects sharing a name must stay separate '
|
||||
'(keyed by project_id, not name).'
|
||||
)
|
||||
# Their amounts must each be R 200 (daily_rate of R 4000/20).
|
||||
for row in same_named:
|
||||
self.assertEqual(row['amount'], Decimal('200.00'))
|
||||
# And their ids must differ.
|
||||
ids = {row['id'] for row in same_named}
|
||||
self.assertEqual(len(ids), 2)
|
||||
|
||||
|
||||
class ComputeOutstandingActiveScopeTests(TestCase):
|
||||
"""Regression for Findings 7/17: home dashboard and payroll dashboard
|
||||
used to produce DIFFERENT outstanding totals because home included
|
||||
inactive workers' unpaid wages while payroll dashboard didn't.
|
||||
_compute_outstanding now defaults to active workers only — matching
|
||||
the payroll dashboard."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username='a-active', is_staff=True)
|
||||
self.project = Project.objects.create(name='ActiveCheck')
|
||||
self.active_worker = Worker.objects.create(
|
||||
name='Active', id_number='AC1', monthly_salary=Decimal('4000'), active=True,
|
||||
)
|
||||
self.inactive_worker = Worker.objects.create(
|
||||
name='Inactive', id_number='IN1', monthly_salary=Decimal('4000'), active=False,
|
||||
)
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 1),
|
||||
project=self.project, supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(self.active_worker, self.inactive_worker)
|
||||
|
||||
def test_default_excludes_inactive_workers(self):
|
||||
from core.views import _compute_outstanding
|
||||
result = _compute_outstanding()
|
||||
# Only the active worker's R 200 daily rate should be counted.
|
||||
self.assertEqual(result['unpaid_wages'], Decimal('200.00'))
|
||||
self.assertEqual(result['outstanding_payments'], Decimal('200.00'))
|
||||
|
||||
def test_include_inactive_workers_flag(self):
|
||||
from core.views import _compute_outstanding
|
||||
result = _compute_outstanding(include_inactive_workers=True)
|
||||
# Both workers counted — total R 400.
|
||||
self.assertEqual(result['unpaid_wages'], Decimal('400.00'))
|
||||
self.assertEqual(result['outstanding_payments'], Decimal('400.00'))
|
||||
|
||||
def test_unpaid_adj_on_inactive_worker_excluded_by_default(self):
|
||||
"""Unpaid adjustments on inactive workers should ALSO be excluded
|
||||
by default, so the dashboards don't include phantom liabilities
|
||||
from deactivated workers."""
|
||||
from core.views import _compute_outstanding
|
||||
# Add an unpaid bonus to the inactive worker
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=self.inactive_worker, project=self.project,
|
||||
type='Bonus', amount=Decimal('500.00'),
|
||||
date=datetime.date(2026, 3, 2),
|
||||
)
|
||||
result = _compute_outstanding()
|
||||
# The R 500 bonus on the inactive worker must NOT be counted
|
||||
self.assertEqual(result['pending_adj_add'], Decimal('0.00'))
|
||||
# And SHOULD be counted when explicitly requested
|
||||
result_with = _compute_outstanding(include_inactive_workers=True)
|
||||
self.assertEqual(result_with['pending_adj_add'], Decimal('500.00'))
|
||||
|
||||
|
||||
class TeamProjectActivityTests(TestCase):
|
||||
"""Chapter IV pivot: rows=team, columns=project, cell=distinct log dates."""
|
||||
|
||||
|
||||
@ -154,12 +154,20 @@ def get_pay_period(team, reference_date=None):
|
||||
# - unpaid_wages: Decimal (pure daily rates for unpaid workers)
|
||||
# - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses)
|
||||
# - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments)
|
||||
# - outstanding_by_project: dict[str project_name -> Decimal amount]
|
||||
# - outstanding_by_project: dict[(project_id, project_name) -> Decimal amount]
|
||||
# keyed by project_id (not name) so two projects with identical names
|
||||
# stay separate; project_name is included for display.
|
||||
# - outstanding_by_project_sorted: list[{'id', 'name', 'amount'}] sorted by
|
||||
# amount desc — what templates should iterate. Stable across calls.
|
||||
#
|
||||
# Accepts optional project_ids / team_ids filters. Empty list or None = no filter.
|
||||
# Set include_inactive_workers=True to include unpaid wages owed to
|
||||
# deactivated workers. Defaults to False so the home dashboard and the
|
||||
# payroll dashboard produce IDENTICAL totals — historically they drifted
|
||||
# (home was inclusive; payroll-dashboard iterated only active workers).
|
||||
# =============================================================================
|
||||
|
||||
def _compute_outstanding(project_ids=None, team_ids=None):
|
||||
def _compute_outstanding(project_ids=None, team_ids=None, include_inactive_workers=False):
|
||||
"""Return current-moment outstanding payment breakdown.
|
||||
|
||||
Plain-English: for each work log that hasn't been fully paid, adds up
|
||||
@ -169,6 +177,13 @@ def _compute_outstanding(project_ids=None, team_ids=None):
|
||||
"as of right now" snapshot shown on the home dashboard's Outstanding
|
||||
Payments card. Optional filters scope the answer to specific projects
|
||||
and/or teams.
|
||||
|
||||
ALIGNMENT WITH payroll_dashboard: by default this helper only counts
|
||||
wages for active workers (matches the payroll-dashboard's
|
||||
`Worker.objects.filter(active=True)` outer loop). Pass
|
||||
include_inactive_workers=True if you specifically want to surface
|
||||
unpaid wages from deactivated workers (rare — usually a sign of a
|
||||
forgotten payment that needs reactivating the worker briefly).
|
||||
"""
|
||||
# --- Work logs in scope ---
|
||||
work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records')
|
||||
@ -178,20 +193,35 @@ def _compute_outstanding(project_ids=None, team_ids=None):
|
||||
work_logs = work_logs.filter(team_id__in=team_ids)
|
||||
|
||||
unpaid_wages = Decimal('0.00')
|
||||
outstanding_by_project = {}
|
||||
# Keyed by project_id (None for the "No Project" bucket) so two
|
||||
# projects sharing a name don't get merged. Value is a dict that
|
||||
# tracks both the display name and the running amount.
|
||||
outstanding_by_project_map = {}
|
||||
|
||||
def _bucket(pid, pname):
|
||||
# Get-or-create the running bucket for this project_id
|
||||
return outstanding_by_project_map.setdefault(
|
||||
pid, {'id': pid, 'name': pname, 'amount': Decimal('0.00')}
|
||||
)
|
||||
|
||||
for wl in work_logs:
|
||||
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
|
||||
project_name = wl.project.name if wl.project else 'No Project'
|
||||
pid = wl.project_id
|
||||
pname = wl.project.name if wl.project else 'No Project'
|
||||
for worker in wl.workers.all():
|
||||
# Skip inactive workers unless caller specifically asked for them.
|
||||
if not include_inactive_workers and not worker.active:
|
||||
continue
|
||||
if worker.id not in paid_worker_ids:
|
||||
cost = worker.daily_rate
|
||||
unpaid_wages += cost
|
||||
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
|
||||
outstanding_by_project[project_name] += cost
|
||||
_bucket(pid, pname)['amount'] += cost
|
||||
|
||||
# --- Unpaid adjustments in scope ---
|
||||
adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project')
|
||||
if not include_inactive_workers:
|
||||
# Mirror the wage-side filter for consistency
|
||||
adj_qs = adj_qs.filter(worker__active=True)
|
||||
if project_ids:
|
||||
adj_qs = adj_qs.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
@ -203,23 +233,40 @@ def _compute_outstanding(project_ids=None, team_ids=None):
|
||||
pending_adj_add = Decimal('0.00')
|
||||
pending_adj_sub = Decimal('0.00')
|
||||
for adj in adj_qs:
|
||||
project_name = adj.project.name if adj.project else 'No Project'
|
||||
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
|
||||
pid = adj.project_id
|
||||
pname = adj.project.name if adj.project else 'No Project'
|
||||
bucket = _bucket(pid, pname)
|
||||
if adj.type in ADDITIVE_TYPES:
|
||||
pending_adj_add += adj.amount
|
||||
outstanding_by_project[project_name] += adj.amount
|
||||
bucket['amount'] += adj.amount
|
||||
elif adj.type in DEDUCTIVE_TYPES:
|
||||
pending_adj_sub += adj.amount
|
||||
outstanding_by_project[project_name] -= adj.amount
|
||||
bucket['amount'] -= adj.amount
|
||||
|
||||
outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub
|
||||
|
||||
# --- Build sorted list for templates ---
|
||||
# Sorted by amount desc; ties broken by project name for stability.
|
||||
by_project_sorted = sorted(
|
||||
outstanding_by_project_map.values(),
|
||||
key=lambda r: (-r['amount'], r['name']),
|
||||
)
|
||||
|
||||
# --- Backwards-compatible dict[name → amount] ---
|
||||
# Older callers (and the home dashboard template's existing
|
||||
# `for proj, amount in outstanding_by_project.items` loop) expect a
|
||||
# name-keyed dict. We keep emitting one for compatibility, but if
|
||||
# two projects share a name the LAST one wins (older code already
|
||||
# had this bug — we just don't introduce a NEW one).
|
||||
by_project_dict = {r['name']: r['amount'] for r in by_project_sorted}
|
||||
|
||||
return {
|
||||
'outstanding_payments': outstanding_payments,
|
||||
'unpaid_wages': unpaid_wages,
|
||||
'pending_adj_add': pending_adj_add,
|
||||
'pending_adj_sub': pending_adj_sub,
|
||||
'outstanding_by_project': outstanding_by_project,
|
||||
'outstanding_by_project': by_project_dict,
|
||||
'outstanding_by_project_sorted': by_project_sorted,
|
||||
}
|
||||
|
||||
|
||||
@ -275,14 +322,10 @@ def _current_outstanding_in_scope(project_ids=None, team_ids=None):
|
||||
deductive adjustments), matching the home dashboard card.
|
||||
"""
|
||||
raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids)
|
||||
by_project_list = sorted(
|
||||
[{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()],
|
||||
key=lambda r: r['amount'],
|
||||
reverse=True,
|
||||
)
|
||||
# Already sorted by amount desc inside _compute_outstanding; no extra work.
|
||||
return {
|
||||
'total': raw['outstanding_payments'],
|
||||
'by_project': by_project_list,
|
||||
'by_project': raw['outstanding_by_project_sorted'],
|
||||
}
|
||||
|
||||
|
||||
@ -363,14 +406,15 @@ def index(request):
|
||||
# --- ADMIN DASHBOARD ---
|
||||
|
||||
# === OUTSTANDING BREAKDOWN ===
|
||||
# Uses the shared _compute_outstanding helper so the dashboard and the
|
||||
# payroll report can't drift. Unscoped (no filters) = whole company.
|
||||
# Uses the shared _compute_outstanding helper so the home dashboard,
|
||||
# the payroll dashboard, and the payroll report all agree.
|
||||
# Active workers only (default) — matches the payroll dashboard's scope.
|
||||
_o = _compute_outstanding()
|
||||
outstanding_payments = _o['outstanding_payments']
|
||||
unpaid_wages = _o['unpaid_wages']
|
||||
pending_adjustments_add = _o['pending_adj_add']
|
||||
pending_adjustments_sub = _o['pending_adj_sub']
|
||||
outstanding_by_project = _o['outstanding_by_project']
|
||||
outstanding_payments = _o['outstanding_payments']
|
||||
unpaid_wages = _o['unpaid_wages']
|
||||
pending_adjustments_add = _o['pending_adj_add']
|
||||
pending_adjustments_sub = _o['pending_adj_sub']
|
||||
outstanding_by_project_list = _o['outstanding_by_project_sorted']
|
||||
|
||||
# === PAID THIS MONTH (calendar month, 1st → today) ===
|
||||
# The dashboard card is labeled "Paid This Month" and must reflect
|
||||
@ -442,7 +486,9 @@ def index(request):
|
||||
'paid_this_month': paid_this_month,
|
||||
'active_loans_count': active_loans_count,
|
||||
'active_loans_balance': active_loans_balance,
|
||||
'outstanding_by_project': outstanding_by_project,
|
||||
# Sorted list [{'id', 'name', 'amount'}] — keyed by project_id
|
||||
# so two projects sharing a name don't get merged into one row.
|
||||
'outstanding_by_project_list': outstanding_by_project_list,
|
||||
'this_week_logs': this_week_logs,
|
||||
'recent_activity': recent_activity,
|
||||
'workers': workers,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user