From 385d65408274f60aa3676c15d701e36b487a092a Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Wed, 22 Apr 2026 14:01:14 +0200 Subject: [PATCH] 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) --- core/tests.py | 112 +++++++++++++++++++++++++++++++++++++++++++++++++- core/views.py | 81 ++++++++++++++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/core/tests.py b/core/tests.py index 7ce503c..9d777be 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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) diff --git a/core/views.py b/core/views.py index 12a6cc5..49ba980 100644 --- a/core/views.py +++ b/core/views.py @@ -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