Implement _build_work_log_payroll_context helper + 8 tests

Pure-function helper that classifies each worker on a work log as
Paid / Priced-not-paid / Unpaid, collects log-linked adjustments,
and computes totals + pay-period context. Used by both the AJAX
endpoint and the full-page view so they can't drift.

Bootstraps core/tests.py (was empty); 8 tests cover the three
statuses, totals, log-linked adjustments, and the pay-period branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 14:01:14 +02:00
parent b4c3109c29
commit 385d654082
2 changed files with 187 additions and 6 deletions

View File

@ -1,3 +1,111 @@
from django.test import TestCase
# === TESTS FOR WORK LOG PAYROLL CROSS-LINK ===
# Covers the _build_work_log_payroll_context helper — the core logic that
# determines, for each worker on a log, whether they were paid for it.
# Create your tests here.
import datetime
from decimal import Decimal
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.views import _build_work_log_payroll_context
class WorkLogPayrollContextTests(TestCase):
"""Tests for the helper that builds the payroll-status view of a work log."""
def setUp(self):
# Minimal scenario: 1 admin, 1 project, 1 team, 3 workers, 1 log.
# Worker A has been paid for the log; Worker B is priced-not-paid;
# Worker C is unpaid.
self.admin = User.objects.create_user(username='admin', is_staff=True)
self.project = Project.objects.create(name='Test Project')
self.team = Team.objects.create(name='Team X', supervisor=self.admin)
self.worker_a = Worker.objects.create(name='Alice', id_number='A1', monthly_salary=Decimal('4000'))
self.worker_b = Worker.objects.create(name='Bob', id_number='B1', monthly_salary=Decimal('4000'))
self.worker_c = Worker.objects.create(name='Carol', id_number='C1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=self.project,
team=self.team,
supervisor=self.admin,
)
self.log.workers.add(self.worker_a, self.worker_b, self.worker_c)
# Worker A has a PayrollRecord linking them and this log — "Paid".
self.record_a = PayrollRecord.objects.create(
worker=self.worker_a,
amount_paid=Decimal('200.00'),
date=datetime.date(2026, 4, 15),
)
self.record_a.work_logs.add(self.log)
# Worker B appears in priced_workers but has no PayrollRecord — "Priced, not paid".
self.log.priced_workers.add(self.worker_b)
# Worker C has neither — "Unpaid".
def test_returns_log_and_worker_rows(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['log'], self.log)
self.assertEqual(len(ctx['worker_rows']), 3)
def test_paid_worker_has_payslip_link(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_a.id)
self.assertEqual(row['status'], 'Paid')
self.assertEqual(row['payroll_record'], self.record_a)
self.assertGreater(row['earned'], 0)
def test_priced_but_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_b.id)
self.assertEqual(row['status'], 'Priced, not paid')
self.assertIsNone(row['payroll_record'])
def test_totally_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_c.id)
self.assertEqual(row['status'], 'Unpaid')
self.assertIsNone(row['payroll_record'])
def test_totals(self):
ctx = _build_work_log_payroll_context(self.log)
# Paid = Alice's daily_rate (one record exists for this log+worker).
self.assertEqual(ctx['total_paid'], self.worker_a.daily_rate)
# Outstanding = Bob + Carol each at their daily_rate.
expected = self.worker_b.daily_rate + self.worker_c.daily_rate
self.assertEqual(ctx['total_outstanding'], expected)
def test_adjustments_linked_to_log(self):
adj = PayrollAdjustment.objects.create(
worker=self.worker_a,
project=self.project,
type='Overtime',
amount=Decimal('50.00'),
date=datetime.date(2026, 4, 10),
description='Extra hour',
work_log=self.log,
)
ctx = _build_work_log_payroll_context(self.log)
self.assertIn(adj, ctx['adjustments'])
def test_pay_period_absent_if_no_schedule(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
def test_pay_period_present_when_schedule_configured(self):
self.team.pay_frequency = 'weekly'
self.team.pay_start_date = datetime.date(2026, 1, 5) # A Monday
self.team.save()
ctx = _build_work_log_payroll_context(self.log)
start, end = ctx['pay_period']
self.assertIsNotNone(start)
self.assertIsNotNone(end)
self.assertLessEqual(start, self.log.date)
self.assertGreaterEqual(end, self.log.date)

View File

@ -731,11 +731,84 @@ def work_history(request):
def _build_work_log_payroll_context(log):
"""Return a context dict describing the payroll status of a work log.
Used by both the AJAX modal endpoint and the full-page detail view so
they always show identical data. See Task 2 for the full implementation.
Plain-English summary for future-you:
For the given work log, loop over each worker on it and decide which of
three buckets they fall into:
- "Paid" -> a PayrollRecord links this worker + this log
- "Priced, not paid" -> worker is in log.priced_workers but no record yet
- "Unpaid" -> neither
Also collects any PayrollAdjustments tied to this log (e.g. overtime).
Used by the AJAX endpoint AND the full detail page keep them sharing
this helper so they can never show different data.
"""
# Stub — implemented in Task 2
return {'log': log}
# Prefetch payroll records once, rather than re-querying per worker.
payroll_records = list(
PayrollRecord.objects.filter(work_logs=log).select_related('worker')
)
# Lookup: worker_id -> first PayrollRecord found.
record_by_worker = {r.worker_id: r for r in payroll_records}
# IDs of workers who've been priced on this log but aren't necessarily paid yet.
priced_worker_ids = set(log.priced_workers.values_list('id', flat=True))
worker_rows = []
total_earned = Decimal('0.00')
total_paid = Decimal('0.00')
total_outstanding = Decimal('0.00')
# Loop each worker on the log and classify them into one of three buckets.
for worker in log.workers.all():
record = record_by_worker.get(worker.id)
if record:
status = 'Paid'
earned = worker.daily_rate
total_paid += earned
elif worker.id in priced_worker_ids:
status = 'Priced, not paid'
earned = worker.daily_rate
total_outstanding += earned
else:
status = 'Unpaid'
earned = worker.daily_rate
total_outstanding += earned
total_earned += earned
worker_rows.append({
'worker': worker,
'status': status,
'earned': earned,
'payroll_record': record,
'paid_date': record.date if record else None,
})
# Adjustments tied directly to this log (mostly overtime pricing).
# Reverse accessor is adjustments_by_work_log (see PayrollAdjustment.work_log related_name).
adjustments = list(
log.adjustments_by_work_log
.select_related('worker', 'payroll_record')
.order_by('type', 'id')
)
# Pay-period info (only if the team has a schedule configured).
# Use the log's own date as the reference so we report the period the
# log falls into — not whichever period happens to contain "today".
pay_period = get_pay_period(log.team, reference_date=log.date) if log.team else (None, None)
# Overtime "needs pricing" flag: log has OT hours but no priced_workers yet.
log_overtime = getattr(log, 'overtime', None) or 0
overtime_needs_pricing = log_overtime > 0 and not priced_worker_ids
return {
'log': log,
'worker_rows': worker_rows,
'adjustments': adjustments,
'total_earned': total_earned,
'total_paid': total_paid,
'total_outstanding': total_outstanding,
'pay_period': pay_period,
'overtime_needs_pricing': overtime_needs_pricing,
}
@login_required