diff --git a/core/templates/core/index.html b/core/templates/core/index.html index dab0955..ed5e47d 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -86,7 +86,7 @@
-
Active Loans ({{ active_loans_count }})
+
Active Loans & Advances ({{ active_loans_count }})
R {{ active_loans_balance|floatformat:2 }}
@@ -147,12 +147,14 @@
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,