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:
Konrad du Plessis 2026-05-15 01:55:49 +02:00
parent 18c75b2bce
commit 2e6b78d28a
3 changed files with 180 additions and 30 deletions

View File

@ -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 &amp; 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>

View File

@ -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."""

View File

@ -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,