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) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 22:11:58 +02:00
parent e2eb889a29
commit 6be6a09056

View File

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