feat(adjustments): backend filter branch for ?status=adjustments
Type / worker / team / status / date filters, sort, stats, pagination. Subquery pattern on the team filter avoids M2M JOIN inflation (CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter come later (Tasks 5/6/7).
This commit is contained in:
parent
a20a025d46
commit
10d381e2ae
120
core/tests.py
120
core/tests.py
@ -937,3 +937,123 @@ class TypeSlugFilterTests(TestCase):
|
||||
def test_idempotent_on_already_slugged(self):
|
||||
from core.templatetags.format_tags import type_slug
|
||||
self.assertEqual(type_slug('bonus'), 'bonus')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) ===
|
||||
# Covers the new tab's backend: filters, sort, stats, pagination.
|
||||
# Each test creates its own fresh fixture via setUp.
|
||||
# NOTE: PayrollRecord only accepts worker/date/amount_paid (see core/models.py).
|
||||
# The plan spec used days_worked/total_amount — those do NOT exist. Adapted.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AdjustmentsTabTests(TestCase):
|
||||
"""New Adjustments tab on /payroll/?status=adjustments."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(
|
||||
username='adj-admin', password='pass', is_staff=True, is_superuser=True
|
||||
)
|
||||
self.sup = User.objects.create_user(
|
||||
username='adj-sup', password='pass'
|
||||
)
|
||||
self.w1 = Worker.objects.create(
|
||||
name='Alice', id_number='A1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
self.w2 = Worker.objects.create(
|
||||
name='Bob', id_number='B1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
self.team = Team.objects.create(name='Alpha', supervisor=self.admin)
|
||||
self.team.workers.add(self.w1, self.w2)
|
||||
self.proj = Project.objects.create(name='Site X')
|
||||
# 3 unpaid adjustments — 1 bonus Alice, 1 bonus Bob, 1 deduction Alice
|
||||
self.a1 = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='Bonus',
|
||||
amount=Decimal('500'), date=datetime.date(2026, 4, 10),
|
||||
description='April bonus',
|
||||
)
|
||||
self.a2 = PayrollAdjustment.objects.create(
|
||||
worker=self.w2, project=self.proj, type='Bonus',
|
||||
amount=Decimal('300'), date=datetime.date(2026, 4, 11),
|
||||
description='Project milestone',
|
||||
)
|
||||
self.a3 = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='Deduction',
|
||||
amount=Decimal('100'), date=datetime.date(2026, 3, 28),
|
||||
description='Missing tool',
|
||||
)
|
||||
self.url = reverse('payroll_dashboard') + '?status=adjustments'
|
||||
|
||||
def _login_admin(self):
|
||||
self.client.login(username='adj-admin', password='pass')
|
||||
|
||||
def test_admin_sees_adjustments_tab(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.context['active_tab'], 'adjustments')
|
||||
# All 3 fixture adjustments should be in the listing
|
||||
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
|
||||
|
||||
def test_supervisor_forbidden(self):
|
||||
self.client.login(username='adj-sup', password='pass')
|
||||
resp = self.client.get(self.url)
|
||||
# Existing payroll_dashboard pattern: non-admin is redirected home
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
|
||||
def test_type_multi_filter(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + '&type=Bonus')
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertIn(self.a1.id, ids)
|
||||
self.assertIn(self.a2.id, ids)
|
||||
self.assertNotIn(self.a3.id, ids)
|
||||
|
||||
def test_worker_multi_filter(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + f'&worker={self.w1.id}')
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertIn(self.a1.id, ids)
|
||||
self.assertNotIn(self.a2.id, ids)
|
||||
self.assertIn(self.a3.id, ids)
|
||||
|
||||
def test_team_filter_uses_subquery_no_inflation(self):
|
||||
"""Filtering by team must NOT multiply rows (M2M JOIN inflation
|
||||
would give 6 rows for 3 adjustments x 2 workers on team Alpha)."""
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + f'&team={self.team.id}')
|
||||
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
|
||||
|
||||
def test_status_filter_unpaid(self):
|
||||
self._login_admin()
|
||||
# Mark a1 as paid — PayrollRecord fields are worker/date/amount_paid
|
||||
pr = PayrollRecord.objects.create(
|
||||
worker=self.w1, date=datetime.date(2026, 4, 15),
|
||||
amount_paid=Decimal('4000'),
|
||||
)
|
||||
self.a1.payroll_record = pr
|
||||
self.a1.save()
|
||||
resp = self.client.get(self.url + '&adj_status=unpaid')
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertNotIn(self.a1.id, ids)
|
||||
self.assertIn(self.a2.id, ids)
|
||||
self.assertIn(self.a3.id, ids)
|
||||
|
||||
def test_date_range_filter(self):
|
||||
self._login_admin()
|
||||
# March 1 to March 31 -> only a3 (dated 28 Mar)
|
||||
resp = self.client.get(
|
||||
self.url + '&adj_date_from=2026-03-01&adj_date_to=2026-03-31'
|
||||
)
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertEqual(ids, {self.a3.id})
|
||||
|
||||
def test_stats_scoped_to_filtered_set(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + '&type=Bonus')
|
||||
# 2 bonuses, 0 paid, total R 800 additive, 0 deductive
|
||||
self.assertEqual(resp.context['adj_total_count'], 2)
|
||||
self.assertEqual(resp.context['adj_unpaid_count'], 2)
|
||||
self.assertEqual(resp.context['adj_additive_sum'], Decimal('800.00'))
|
||||
self.assertEqual(resp.context['adj_deductive_sum'], Decimal('0.00'))
|
||||
|
||||
130
core/views.py
130
core/views.py
@ -2896,6 +2896,136 @@ def payroll_dashboard(request):
|
||||
'active_loans_count': active_loans_count,
|
||||
'active_loans_balance': active_loans_balance,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# === ADJUSTMENTS TAB CONTEXT ===
|
||||
# This block only runs when the user is on the Adjustments tab
|
||||
# (i.e. the URL has ?status=adjustments). It builds a filtered, sorted,
|
||||
# paginated list of adjustments plus the little stats cards above it.
|
||||
#
|
||||
# Group-by rendering, bulk-select, and Team->Workers cross-filter
|
||||
# will be added in later tasks — this task just covers the basic data.
|
||||
# =========================================================================
|
||||
if status_filter == 'adjustments':
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
# --- Read the filter choices the user picked from the URL ---
|
||||
# Lists come in as ?type=Bonus&type=Deduction etc.
|
||||
type_filter = request.GET.getlist('type')
|
||||
worker_filter = [
|
||||
int(v) for v in request.GET.getlist('worker') if v.strip().isdigit()
|
||||
]
|
||||
team_filter = [
|
||||
int(v) for v in request.GET.getlist('team') if v.strip().isdigit()
|
||||
]
|
||||
adj_status = request.GET.get('adj_status', '').strip()
|
||||
adj_date_from = request.GET.get('adj_date_from', '').strip()
|
||||
adj_date_to = request.GET.get('adj_date_to', '').strip()
|
||||
sort_col = request.GET.get('sort', 'date').strip()
|
||||
sort_order = request.GET.get('order', 'desc').strip()
|
||||
|
||||
# --- Base queryset with eager-loading of related tables ---
|
||||
# select_related pulls worker/project/payment in the same SQL query
|
||||
# so we don't hit the database once per row later.
|
||||
adjustments = PayrollAdjustment.objects.select_related(
|
||||
'worker', 'project', 'payroll_record'
|
||||
).prefetch_related('worker__teams')
|
||||
|
||||
# --- Apply each filter only if the user actually set one ---
|
||||
if type_filter:
|
||||
adjustments = adjustments.filter(type__in=type_filter)
|
||||
if worker_filter:
|
||||
adjustments = adjustments.filter(worker_id__in=worker_filter)
|
||||
if team_filter:
|
||||
# SUBQUERY PATTERN (CLAUDE.md "M2M filter + aggregate inflation"):
|
||||
# Joining straight on workers__teams would multiply the row count
|
||||
# if a worker is on multiple teams, so we pick the matching worker
|
||||
# IDs in a subquery first and then filter the outer queryset
|
||||
# without any JOIN expansion.
|
||||
adjustments = adjustments.filter(
|
||||
worker__in=Worker.objects.filter(
|
||||
teams__id__in=team_filter
|
||||
).values('id')
|
||||
)
|
||||
if adj_status == 'unpaid':
|
||||
adjustments = adjustments.filter(payroll_record__isnull=True)
|
||||
elif adj_status == 'paid':
|
||||
adjustments = adjustments.filter(payroll_record__isnull=False)
|
||||
if adj_date_from:
|
||||
parsed = parse_date(adj_date_from)
|
||||
if parsed:
|
||||
adjustments = adjustments.filter(date__gte=parsed)
|
||||
if adj_date_to:
|
||||
parsed = parse_date(adj_date_to)
|
||||
if parsed:
|
||||
adjustments = adjustments.filter(date__lte=parsed)
|
||||
|
||||
# --- Sort the results ---
|
||||
# The URL's "sort" value is a short label; translate it to the
|
||||
# actual database column. Unknown values fall back to date.
|
||||
sort_map = {
|
||||
'date': 'date',
|
||||
'worker': 'worker__name',
|
||||
'amount': 'amount',
|
||||
'status': 'payroll_record',
|
||||
}
|
||||
sort_field = sort_map.get(sort_col, 'date')
|
||||
if sort_order == 'desc':
|
||||
sort_field = '-' + sort_field
|
||||
# Secondary key "-id" keeps rows in a stable order when the
|
||||
# main sort key has ties (e.g. two adjustments on the same date).
|
||||
adjustments = adjustments.order_by(sort_field, '-id')
|
||||
|
||||
# --- Stats cards (all computed BEFORE pagination) ---
|
||||
# These numbers always reflect what the current filter produces,
|
||||
# not just what fits on the current page.
|
||||
adj_total_count = adjustments.count()
|
||||
unpaid_qs = adjustments.filter(payroll_record__isnull=True)
|
||||
adj_unpaid_count = unpaid_qs.count()
|
||||
adj_unpaid_sum = unpaid_qs.aggregate(
|
||||
total=Sum('amount')
|
||||
)['total'] or Decimal('0.00')
|
||||
adj_additive_sum = adjustments.filter(
|
||||
type__in=ADDITIVE_TYPES
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
adj_deductive_sum = adjustments.filter(
|
||||
type__in=DEDUCTIVE_TYPES
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
|
||||
# --- Pagination: 50 rows per page ---
|
||||
paginator = Paginator(adjustments, 50)
|
||||
adj_page = paginator.get_page(request.GET.get('page', 1))
|
||||
|
||||
# --- Everything the Adjustments tab template will need ---
|
||||
context.update({
|
||||
'adj_page': adj_page,
|
||||
'adj_total_count': adj_total_count,
|
||||
'adj_unpaid_count': adj_unpaid_count,
|
||||
'adj_unpaid_sum': adj_unpaid_sum,
|
||||
'adj_additive_sum': adj_additive_sum,
|
||||
'adj_deductive_sum': adj_deductive_sum,
|
||||
'adj_filter_values': {
|
||||
'type': type_filter,
|
||||
'worker': worker_filter,
|
||||
'team': team_filter,
|
||||
'adj_status': adj_status,
|
||||
'adj_date_from': adj_date_from,
|
||||
'adj_date_to': adj_date_to,
|
||||
'sort': sort_col,
|
||||
'order': sort_order,
|
||||
},
|
||||
# Flat list of type labels for the Adjustments tab filter dropdown.
|
||||
# Stored under a separate key so we don't clobber the existing
|
||||
# 'adjustment_types' context var (which is TYPE_CHOICES tuples
|
||||
# used by the Add/Edit adjustment modals).
|
||||
'adj_type_choices': list(ADDITIVE_TYPES) + list(DEDUCTIVE_TYPES),
|
||||
'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'),
|
||||
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
|
||||
# Task 4 will use this to decide +/- signs on each row.
|
||||
'additive_types': list(ADDITIVE_TYPES),
|
||||
})
|
||||
|
||||
return render(request, 'core/payroll_dashboard.html', context)
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user