From 6be6a0905649489c28904757d8098b4ee0537845 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Wed, 22 Apr 2026 22:11:58 +0200 Subject: [PATCH] Extract _compute_outstanding helper from index() (refactor) Pure refactor: the ~45 lines of outstanding-payment math inside index() (computing unpaid_wages + pending_adj_add - pending_adj_sub, with a per-project breakdown) move into a standalone _compute_outstanding() helper. index() now calls it with no arguments for unchanged behaviour. The helper accepts optional project_ids / team_ids for Task 3. No tests changed; 28/28 still pass. Dashboard Outstanding Payments card shows the same value before and after. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/views.py | 133 +++++++++++++++++++++++++++++++------------------- 1 file changed, 84 insertions(+), 49 deletions(-) diff --git a/core/views.py b/core/views.py index 8287a48..9aa9fb7 100644 --- a/core/views.py +++ b/core/views.py @@ -140,6 +140,82 @@ def get_pay_period(team, reference_date=None): return (None, None) +# ============================================================================= +# === OUTSTANDING PAYMENTS — SHARED HELPER === +# Used by the home dashboard AND the payroll report. Computes: +# - outstanding_payments: Decimal total (unpaid wages + net unpaid adjustments) +# - 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] +# +# Accepts optional project_ids / team_ids filters. Empty list or None = no filter. +# ============================================================================= + +def _compute_outstanding(project_ids=None, team_ids=None): + """Return current-moment outstanding payment breakdown. + + Plain-English: for each work log that hasn't been fully paid, adds up + each unpaid worker's daily rate. Then adds unpaid additive adjustments + (bonuses, overtime, new loans, advances) and subtracts unpaid deductive + adjustments (deductions, loan/advance repayments). Results are the + "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. + """ + # --- Work logs in scope --- + work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records') + if project_ids: + work_logs = work_logs.filter(project_id__in=project_ids) + if team_ids: + work_logs = work_logs.filter(team_id__in=team_ids) + + unpaid_wages = Decimal('0.00') + outstanding_by_project = {} + + 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' + for worker in wl.workers.all(): + 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 + + # --- Unpaid adjustments in scope --- + adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project') + if project_ids: + adj_qs = adj_qs.filter(project_id__in=project_ids) + if team_ids: + # worker__teams is M2M — use subquery pattern (see CLAUDE.md Django ORM gotcha) + adj_qs = adj_qs.filter( + worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id') + ) + + 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')) + if adj.type in ADDITIVE_TYPES: + pending_adj_add += adj.amount + outstanding_by_project[project_name] += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + pending_adj_sub += adj.amount + outstanding_by_project[project_name] -= adj.amount + + outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub + + 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, + } + + # === HOME DASHBOARD === # The main page users see after logging in. Shows different content # depending on whether the user is an admin or supervisor. @@ -151,56 +227,15 @@ def index(request): if is_admin(user): # --- ADMIN DASHBOARD --- - # Calculate total value of unpaid work and break it down by project. - # A WorkLog is "unpaid for worker X" if no PayrollRecord links BOTH - # that log AND that worker. This handles partially-paid logs where - # some workers have been paid but others haven't. - all_worklogs = WorkLog.objects.select_related( - 'project' - ).prefetch_related('workers', 'payroll_records') - # === OUTSTANDING BREAKDOWN === - # Track unpaid wages and adjustments separately so the dashboard - # can show a clear breakdown of what makes up the total. - unpaid_wages = Decimal('0.00') # Pure daily rates for unpaid workers - pending_adjustments_add = Decimal('0.00') # Unpaid additive adjustments (bonuses, overtime, etc.) - pending_adjustments_sub = Decimal('0.00') # Unpaid deductive adjustments (deductions, repayments) - outstanding_by_project = {} - - for wl in all_worklogs: - # Get the set of worker IDs that have been paid for this log - paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()} - project_name = wl.project.name - - for worker in wl.workers.all(): - if worker.id not in paid_worker_ids: - cost = worker.daily_rate - unpaid_wages += cost - if project_name not in outstanding_by_project: - outstanding_by_project[project_name] = Decimal('0.00') - outstanding_by_project[project_name] += cost - - # Also include unpaid payroll adjustments (bonuses, deductions, etc.) - # Additive types (Bonus, Overtime, New Loan) increase outstanding. - # Deductive types (Deduction, Loan Repayment, Advance Repayment) decrease it. - unpaid_adjustments = PayrollAdjustment.objects.filter( - payroll_record__isnull=True - ).select_related('project') - - for adj in unpaid_adjustments: - project_name = adj.project.name if adj.project else 'No Project' - if project_name not in outstanding_by_project: - outstanding_by_project[project_name] = Decimal('0.00') - - if adj.type in ADDITIVE_TYPES: - pending_adjustments_add += adj.amount - outstanding_by_project[project_name] += adj.amount - elif adj.type in DEDUCTIVE_TYPES: - pending_adjustments_sub += adj.amount - outstanding_by_project[project_name] -= adj.amount - - # Net total = wages + additions - deductions (same result as before, just tracked separately) - outstanding_payments = unpaid_wages + pending_adjustments_add - pending_adjustments_sub + # Uses the shared _compute_outstanding helper so the dashboard and the + # payroll report can't drift. Unscoped (no filters) = whole company. + _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'] # Sum total paid out in the last 60 days sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)