Outstanding by Project
- {% 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. #}
- {% for proj, amount in outstanding_by_project.items %}
+ {% for proj in outstanding_by_project_list %}
- {{ proj }}
- R {{ amount|floatformat:2 }}
+ {{ proj.name }}
+ R {{ proj.amount|floatformat:2 }}
{% endfor %}
diff --git a/core/tests.py b/core/tests.py
index 2969d86..cf74707 100644
--- a/core/tests.py
+++ b/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."""
diff --git a/core/views.py b/core/views.py
index c4b9209..3dfc9a5 100644
--- a/core/views.py
+++ b/core/views.py
@@ -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,