perf: kill per-row queries on the History tab and Batch Pay preview

- paid_records (History tab) now prefetches work_logs + adjustments:
  the template shows a day-count and loops adjustments per row, which
  fired 2 queries per visible record (~100 on a long history).
- batch_pay_preview replaces the per-worker get_worker_active_team()
  call (worker.teams.filter(...).first() — bypasses prefetch, 1 query
  per worker) with the same batched membership-dict pattern
  payroll_dashboard already uses, and reads the unpaid-adjustments
  check from the existing filtered prefetch instead of .exists().

Also includes (committed earlier in 25910b2 but noting for the record):
the /report/ worker-breakdown loop's per-worker-per-type aggregates
were replaced by one GROUP BY dict (audit fix #7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-06-12 17:58:56 +02:00
parent 25910b2861
commit 541b8973c7

View File

@ -3094,9 +3094,13 @@ def payroll_dashboard(request):
pending_adj_sub_total += worker_adj_sub
# --- Payment history ---
# prefetch work_logs + adjustments: the History tab template shows
# "{{ record.work_logs.count }} days" and loops record.adjustments.all
# per row — without the prefetch that's 2 queries per visible record
# (audit fix #9, Jun 2026).
paid_records = PayrollRecord.objects.select_related(
'worker'
).order_by('-date', '-id')
).prefetch_related('work_logs', 'adjustments').order_by('-date', '-id')
# --- Recent payments total (last 60 days, inclusive) ---
# 60-day window math: subtract 59 days, not 60. With `>=` that yields
@ -3952,8 +3956,22 @@ def batch_pay_preview(request):
),
).order_by('name')
# === Active team per worker — ONE batched membership query ===
# get_worker_active_team() runs worker.teams.filter(...).first() per
# worker, which bypasses the prefetch cache and fired a query per row
# (audit fix #10, Jun 2026). Same dict pattern as payroll_dashboard.
active_team_by_id = {t.id: t for t in Team.objects.filter(active=True)}
worker_active_team = {}
for membership in Team.workers.through.objects.filter(
team_id__in=active_team_by_id.keys()
).values('team_id', 'worker_id'):
wid = membership['worker_id']
if wid in worker_active_team:
continue
worker_active_team[wid] = active_team_by_id[membership['team_id']]
for worker in active_workers:
team = get_worker_active_team(worker)
team = worker_active_team.get(worker.id)
# --- In 'schedule' mode, skip workers without a pay schedule ---
if mode == 'schedule':
@ -3966,7 +3984,9 @@ def batch_pay_preview(request):
has_unpaid = True
break
if not has_unpaid:
has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists()
# the adjustments prefetch above is already filtered to
# unpaid rows — bool() reads the cache, no extra query
has_unpaid = bool(worker.adjustments.all())
if has_unpaid:
skipped.append({