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:
parent
b4c3109c29
commit
385d654082
112
core/tests.py
112
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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user