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="stat-card stat-card--warning h-100 p-3">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <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 class="stat-value">R {{ active_loans_balance|floatformat:2 }}</div>
</div> </div>
<div class="stat-icon stat-icon--warning"> <div class="stat-icon stat-icon--warning">
@ -147,12 +147,14 @@
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div style="flex: 1;"> <div style="flex: 1;">
<div class="stat-label">Outstanding by Project</div> <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;"> <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);"> <div class="d-flex justify-content-between" style="color: var(--text-primary);">
<span class="text-truncate me-2">{{ proj }}</span> <span class="text-truncate me-2">{{ proj.name }}</span>
<span class="fw-semibold" style="white-space: nowrap;">R {{ amount|floatformat:2 }}</span> <span class="fw-semibold" style="white-space: nowrap;">R {{ proj.amount|floatformat:2 }}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@ -597,6 +597,108 @@ class CurrentOutstandingInScopeTests(TestCase):
self.assertNotIn(Decimal('500.00'), amounts) 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): class TeamProjectActivityTests(TestCase):
"""Chapter IV pivot: rows=team, columns=project, cell=distinct log dates.""" """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) # - unpaid_wages: Decimal (pure daily rates for unpaid workers)
# - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses) # - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses)
# - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments) # - 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. # 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. """Return current-moment outstanding payment breakdown.
Plain-English: for each work log that hasn't been fully paid, adds up 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 "as of right now" snapshot shown on the home dashboard's Outstanding
Payments card. Optional filters scope the answer to specific projects Payments card. Optional filters scope the answer to specific projects
and/or teams. 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 in scope ---
work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records') 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) work_logs = work_logs.filter(team_id__in=team_ids)
unpaid_wages = Decimal('0.00') 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: for wl in work_logs:
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()} 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(): 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: if worker.id not in paid_worker_ids:
cost = worker.daily_rate cost = worker.daily_rate
unpaid_wages += cost unpaid_wages += cost
outstanding_by_project.setdefault(project_name, Decimal('0.00')) _bucket(pid, pname)['amount'] += cost
outstanding_by_project[project_name] += cost
# --- Unpaid adjustments in scope --- # --- Unpaid adjustments in scope ---
adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project') 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: if project_ids:
adj_qs = adj_qs.filter(project_id__in=project_ids) adj_qs = adj_qs.filter(project_id__in=project_ids)
if team_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_add = Decimal('0.00')
pending_adj_sub = Decimal('0.00') pending_adj_sub = Decimal('0.00')
for adj in adj_qs: for adj in adj_qs:
project_name = adj.project.name if adj.project else 'No Project' pid = adj.project_id
outstanding_by_project.setdefault(project_name, Decimal('0.00')) pname = adj.project.name if adj.project else 'No Project'
bucket = _bucket(pid, pname)
if adj.type in ADDITIVE_TYPES: if adj.type in ADDITIVE_TYPES:
pending_adj_add += adj.amount pending_adj_add += adj.amount
outstanding_by_project[project_name] += adj.amount bucket['amount'] += adj.amount
elif adj.type in DEDUCTIVE_TYPES: elif adj.type in DEDUCTIVE_TYPES:
pending_adj_sub += adj.amount 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 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 { return {
'outstanding_payments': outstanding_payments, 'outstanding_payments': outstanding_payments,
'unpaid_wages': unpaid_wages, 'unpaid_wages': unpaid_wages,
'pending_adj_add': pending_adj_add, 'pending_adj_add': pending_adj_add,
'pending_adj_sub': pending_adj_sub, '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. deductive adjustments), matching the home dashboard card.
""" """
raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids) raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids)
by_project_list = sorted( # Already sorted by amount desc inside _compute_outstanding; no extra work.
[{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()],
key=lambda r: r['amount'],
reverse=True,
)
return { return {
'total': raw['outstanding_payments'], '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 --- # --- ADMIN DASHBOARD ---
# === OUTSTANDING BREAKDOWN === # === OUTSTANDING BREAKDOWN ===
# Uses the shared _compute_outstanding helper so the dashboard and the # Uses the shared _compute_outstanding helper so the home dashboard,
# payroll report can't drift. Unscoped (no filters) = whole company. # the payroll dashboard, and the payroll report all agree.
# Active workers only (default) — matches the payroll dashboard's scope.
_o = _compute_outstanding() _o = _compute_outstanding()
outstanding_payments = _o['outstanding_payments'] outstanding_payments = _o['outstanding_payments']
unpaid_wages = _o['unpaid_wages'] unpaid_wages = _o['unpaid_wages']
pending_adjustments_add = _o['pending_adj_add'] pending_adjustments_add = _o['pending_adj_add']
pending_adjustments_sub = _o['pending_adj_sub'] pending_adjustments_sub = _o['pending_adj_sub']
outstanding_by_project = _o['outstanding_by_project'] outstanding_by_project_list = _o['outstanding_by_project_sorted']
# === PAID THIS MONTH (calendar month, 1st → today) === # === PAID THIS MONTH (calendar month, 1st → today) ===
# The dashboard card is labeled "Paid This Month" and must reflect # The dashboard card is labeled "Paid This Month" and must reflect
@ -442,7 +486,9 @@ def index(request):
'paid_this_month': paid_this_month, 'paid_this_month': paid_this_month,
'active_loans_count': active_loans_count, 'active_loans_count': active_loans_count,
'active_loans_balance': active_loans_balance, '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, 'this_week_logs': this_week_logs,
'recent_activity': recent_activity, 'recent_activity': recent_activity,
'workers': workers, 'workers': workers,