docs(claude): capture session's new patterns + gotchas

Three additions from this session's work:

1. Django ORM gotcha — PayrollAdjustment project double-attribution.
   Documents the Coalesce pattern that solved the Apr 2026 perf-pass
   double-count bug on Overtime adjustments.

2. Payroll dashboard query-count baselines — target ranges for /
   and the four /payroll/ tabs after the perf pass, plus the
   "spotting a regression" heuristic (>50% jump = N+1 reintroduced).

3. Profiling locally — Django Debug Toolbar — what it is, how it's
   triple-gated, how to use it for N+1 hunting. Flags that the
   package is already in requirements.txt so future sessions don't
   need to install it.

Net: +35 lines, three new sections, no deletions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-24 08:51:23 +02:00
parent 8f495064c3
commit b43892f712

View File

@ -99,6 +99,25 @@ outer queryset JOIN-free. See `_build_report_context` in `core/views.py` and
`ReportContextFilterInflationTests` in `core/tests.py` for the reference
implementation (commit f1e246c, Apr 2026).
## Django ORM gotcha — PayrollAdjustment project double-attribution
`PayrollAdjustment` has TWO project FKs: a direct `adj.project` and an
indirect `adj.work_log.project`. For every **Overtime** adjustment these
always point at the same project (see `price_overtime()` — it sets
BOTH). When rolling up "costs per project" you typically want the
OR-union — "adjustments where either FK points to project P".
- **Correct**: `Q(project_id__in=ids) | Q(work_log__project_id__in=ids)` filter
+ `.annotate(effective_project_id=Coalesce('project_id', 'work_log__project_id'))`
+ `.values('effective_project_id', ...).annotate(total=Sum('amount'))`.
Each row contributes to exactly ONE project.
- **WRONG**: two separate filtered querysets (one per FK) summed in
Python. Any row with BOTH FKs set (every Overtime) gets counted twice.
Bit us during the Apr 2026 perf pass — Coalesce fix is commit
`167c821`. Regression test: `PayrollDashboardAdjustmentAggregationTests`
in `core/tests.py`. See `payroll_dashboard()` in `core/views.py` for
the reference implementation on both the unpaid-outstanding card and
the paid-monthly stacked chart.
## PayrollAdjustment Type Handling
- **Bonus / Deduction** — standalone, require a linked Project
- **New Loan** — creates a `Loan` record (`loan_type='loan'`); has a "Pay Immediately" checkbox (checked by default) that auto-processes the loan (creates PayrollRecord, sends payslip to Spark, marks as paid). When unchecked, the loan sits in Pending Payments for the next pay cycle. Editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
@ -114,6 +133,22 @@ The dashboard's outstanding amount uses **per-worker** checking, not per-log:
- This correctly handles partially-paid logs (e.g., one worker paid, another not)
- Unpaid adjustments: additive types increase outstanding, deductive types decrease it
## Payroll dashboard query-count baselines (post Apr 2026 perf pass)
Target ranges after `payroll_dashboard()` was optimized with batched
aggregates + `Prefetch(to_attr='active_workers_cached')` + Coalesce-based
project attribution (commits `61c485f` + `167c821`):
- `/` (admin dashboard) — ~15 queries
- `/payroll/?status=pending` — ~24
- `/payroll/?status=history` — ~24
- `/payroll/?status=loans` — ~25
- `/payroll/?status=adjustments` — ~32
If any of these jumps meaningfully (>50%) after a future change, an N+1
was reintroduced. Profile with Django Debug Toolbar (see Profiling
section below) to find it. The test suite does NOT have `assertNumQueries`
guards on these views — deliberate YAGNI for now, worth adding if
regressions become a pattern.
## Commands
```bash
# Local development (SQLite)
@ -131,6 +166,22 @@ python manage.py check # System check
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
```
## Profiling locally — Django Debug Toolbar
Installed as a dev-only dependency in `requirements.txt` since Apr 2026.
Triple-gated in `config/settings.py`: only loads when **DEBUG=true AND
USE_SQLITE=true AND NOT running tests**. Never loads in production —
prod has neither flag, and the test-run gate exists because the toolbar
emits an E001 system-check error + breaks template rendering when
DEBUG=false (which Django forces during `manage.py test`).
To profile a page: start the dev server normally (`run_dev.bat` or
inline `USE_SQLITE=true DJANGO_DEBUG=true python manage.py runserver`),
log in as admin, navigate to any URL, click the toolbar tab on the
right edge. The **SQL panel** shows query count + highlights any
duplicate-query groups — the go-to tool for N+1 hunting. See the
"Payroll dashboard query-count baselines" section for expected
numbers on hot pages.
## Development Workflow
- Active development branch: `ai-dev` (PR target: `master`)
- Local dev uses SQLite: set `USE_SQLITE=true` environment variable