docs: add Manager / Salaried Pay implementation plan (8 TDD tasks)
Task-by-task TDD plan: Worker.pay_type + migration, Salary additive type, attendance/absence picker exclusions, add_adjustment Salary branch, per-project salaried-cost report line + byte-for-byte daily-numbers regression guard, UI, docs. Ends with a HARD STOP before any push for Konrad's local verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
325c59d4a1
commit
4dadb7cf23
801
docs/plans/2026-05-15-manager-salaried-pay-plan.md
Normal file
801
docs/plans/2026-05-15-manager-salaried-pay-plan.md
Normal file
@ -0,0 +1,801 @@
|
||||
# Manager / Salaried Pay Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or
|
||||
> superpowers:subagent-driven-development if running in-session) to implement
|
||||
> this plan task-by-task.
|
||||
|
||||
**Goal:** Let Konrad log fixed monthly pay for managers/salaried staff (people
|
||||
with no daily work logs, e.g. Fitz) so it lands in payroll records + project
|
||||
costs and can carry loans/advances — without changing anything about how
|
||||
daily-rated workers are logged, paid, or costed.
|
||||
|
||||
**Architecture:** Approach A — a `Worker.pay_type` discriminator
|
||||
(`'daily'` default | `'fixed'`). Managers are excluded *only* from the
|
||||
attendance/absence pickers so they can never reach a `WorkLog` (which makes
|
||||
all WorkLog-derived money math provably unchanged). Their pay is a new
|
||||
additive `Salary` `PayrollAdjustment` type, project-attributed, logged through
|
||||
the existing `add_adjustment` rails (a new branch modelled on the proven
|
||||
`New Loan` branch). Loans/advances/deductions/payslips/history work for them
|
||||
with zero new code because those are all `Worker`-FK'd.
|
||||
|
||||
**Tech Stack:** Django 5.2.7, Python 3.13, SQLite (local dev) / MySQL (prod),
|
||||
Bootstrap 5, Django test framework.
|
||||
|
||||
---
|
||||
|
||||
## Context the implementing engineer MUST read first
|
||||
|
||||
- **Design doc:** `docs/plans/2026-05-15-manager-salaried-pay-design.md`
|
||||
(committed locally as `325c59d` on `ai-dev`, NOT pushed). Read it in full.
|
||||
- **Project guide:** `CLAUDE.md` at repo root. Especially the
|
||||
"UI-vs-DB naming drift" section (Path-A pattern) and the multi-line
|
||||
Django comment gotcha.
|
||||
- **Baseline:** branch `ai-dev`, **173/173 tests passing**. Do not regress.
|
||||
- **Test command (Git Bash):**
|
||||
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`
|
||||
- Run a single test class:
|
||||
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayModelTests -v 2`
|
||||
- On `cmd.exe` use `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests`
|
||||
- **Test gotcha (already bit a prior session):** in `core/tests.py` use
|
||||
`datetime.date.today()` / `datetime.date(...)`, NOT `timezone.now().date()`.
|
||||
`tests.py` imports `datetime` (and a `_date` alias around line 1693); it does
|
||||
NOT import `timezone` in many test classes. Match the surrounding style of
|
||||
the absence test classes.
|
||||
- **Template comment gotcha:** Django `{# ... #}` is SINGLE-LINE only. After
|
||||
ANY template edit run:
|
||||
`grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}'`
|
||||
— every match is a broken multi-line comment rendering as visible text.
|
||||
Use `{% comment %}...{% endcomment %}` for multi-line.
|
||||
- **Add new test classes at the END of `core/tests.py`** (after the last
|
||||
existing class) unless told otherwise. Name them as specified per task so the
|
||||
spec reviewer can find them.
|
||||
|
||||
## Exact code locations (verified during planning)
|
||||
|
||||
| What | Location |
|
||||
|---|---|
|
||||
| `Worker` model | `core/models.py:37` (note `daily_rate` property at `:92-95`) |
|
||||
| `PayrollAdjustment.TYPE_CHOICES` | `core/models.py:289` |
|
||||
| `ADDITIVE_TYPES` / `DEDUCTIVE_TYPES` | top of `core/views.py` — locate with `grep -n "^ADDITIVE_TYPES" core/views.py` |
|
||||
| `AttendanceLogForm` worker queryset — supervisor branch | `core/forms.py:106-109` |
|
||||
| `AttendanceLogForm` worker queryset — admin branch | `core/forms.py:114` |
|
||||
| `PayrollAdjustmentForm.clean` `project_required_types` | `core/forms.py:179` (worker queryset `:170` — **must KEEP managers**) |
|
||||
| `WorkerForm` | `core/forms.py:248` (fields `:256-263`, widgets `:264-288`) |
|
||||
| `AbsenceLogForm.workers` queryset — admin default | `core/forms.py:662` |
|
||||
| `AbsenceLogForm.workers` queryset — supervisor branch | `core/forms.py:696-702` |
|
||||
| `AbsenceEditForm.worker` queryset — admin / supervisor | `core/forms.py:842` / `:851` |
|
||||
| `_build_team_workers_map` worker prefetch | `core/views.py:588` |
|
||||
| attendance cost-estimate JS rates loop | `core/views.py:791` |
|
||||
| `add_adjustment` | `core/views.py:4204` (`project_required_types` `:4247`, `New Loan` branch `:4280-4312`, generic fall-through `:4388-4398`) |
|
||||
| `_process_single_payment` | `core/views.py:3696` (NOT modified — regression only) |
|
||||
| `_build_report_context` | `core/views.py:2416` (adjustments filtered by project `:2484-2490`, labour-cost-by-project `~:2502`) |
|
||||
|
||||
## CRITICAL invariants (the spec reviewer will check these)
|
||||
|
||||
1. The `pay_type='fixed'` exclusion is applied **ONLY** to attendance + absence
|
||||
pickers (Tasks 3 & 4). It must **NEVER** be added to `PayrollAdjustmentForm`
|
||||
(`forms.py:170`), the Team edit form (`forms.py:427`), or any payroll modal
|
||||
worker picker — managers MUST remain selectable there.
|
||||
2. `_process_single_payment` (`views.py:3696`) logic is **not modified**. The
|
||||
only money-path edits are: adding `'Salary'` to `ADDITIVE_TYPES`, and a new
|
||||
`Salary` branch in `add_adjustment`.
|
||||
3. The migration defaults `pay_type` to `'daily'` for every existing worker.
|
||||
4. **Pay-Immediately Salary nuance (intentional):** the immediate path creates
|
||||
an isolated `PayrollRecord` exactly like the existing `New Loan`
|
||||
pay-immediately sub-branch — it does NOT call `_process_single_payment`.
|
||||
The UNPAID path falls through to the generic pending-adjustment creation and
|
||||
is netted by `_process_single_payment` later when the admin clicks Pay on
|
||||
the dashboard. This satisfies the design intent ("pay now → PayrollRecord +
|
||||
payslip"; "unchecked → pending, nets with deductions/loan-repayments at
|
||||
pay time"). Do not "fix" this to route immediate through
|
||||
`_process_single_payment`.
|
||||
5. No DB rename — table/model stays `Worker`. UI label only ("Manager /
|
||||
Salaried"). This is a Path-A display-only entry.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Worker.pay_type` field + `is_salaried` property + migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/models.py:37` (Worker class; add field near the other simple
|
||||
fields, add property next to `daily_rate` at `:92-95`)
|
||||
- Create: `core/migrations/0016_worker_pay_type.py` (auto-generated; the latest
|
||||
existing migration is `0015_absence_project`)
|
||||
- Test: `core/tests.py` (new class `ManagerSalariedPayModelTests` at end of file)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
class ManagerSalariedPayModelTests(TestCase):
|
||||
"""Worker.pay_type discriminator + is_salaried convenience property."""
|
||||
|
||||
def test_pay_type_defaults_to_daily(self):
|
||||
w = Worker.objects.create(
|
||||
name='Daily Dan', id_number='PT-D1', monthly_salary=Decimal('6000.00'),
|
||||
)
|
||||
self.assertEqual(w.pay_type, 'daily')
|
||||
self.assertFalse(w.is_salaried)
|
||||
|
||||
def test_pay_type_fixed_is_salaried(self):
|
||||
m = Worker.objects.create(
|
||||
name='Manager Fitz', id_number='PT-M1',
|
||||
monthly_salary=Decimal('40000.00'), pay_type='fixed',
|
||||
)
|
||||
self.assertEqual(m.pay_type, 'fixed')
|
||||
self.assertTrue(m.is_salaried)
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayModelTests -v 2`
|
||||
Expected: FAIL — `Worker() got unexpected keyword 'pay_type'` / `AttributeError: is_salaried`.
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
In `core/models.py`, inside `class Worker`, add (place the choices + field
|
||||
with the other simple fields, e.g. just after `active = models.BooleanField(...)`):
|
||||
|
||||
```python
|
||||
# === PAY TYPE ===
|
||||
# 'daily' = normal field worker, paid per logged work day (daily_rate).
|
||||
# 'fixed' = manager / salaried staff: paid a fixed monthly amount via a
|
||||
# 'Salary' PayrollAdjustment, never logged on a WorkLog. See
|
||||
# CLAUDE.md "Manager / Salaried pay" + the Path-A naming note.
|
||||
PAY_TYPE_CHOICES = [
|
||||
('daily', 'Daily-rated'),
|
||||
('fixed', 'Fixed salary'),
|
||||
]
|
||||
pay_type = models.CharField(
|
||||
max_length=10, choices=PAY_TYPE_CHOICES, default='daily',
|
||||
help_text='Daily-rated workers are logged per day. Fixed-salary '
|
||||
'staff (managers) are paid a set monthly amount and are '
|
||||
'never added to a work log.',
|
||||
)
|
||||
```
|
||||
|
||||
Add the property next to `daily_rate` (after it, ~line 96):
|
||||
|
||||
```python
|
||||
@property
|
||||
def is_salaried(self):
|
||||
# True for managers / fixed-salary staff (pay_type='fixed').
|
||||
return self.pay_type == 'fixed'
|
||||
```
|
||||
|
||||
**Step 4: Make + run the migration, then run tests**
|
||||
|
||||
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py makemigrations core`
|
||||
Expected: creates `core/migrations/0016_worker_pay_type.py` adding `pay_type`
|
||||
with `default='daily'`. Open it and confirm `default='daily'` is present.
|
||||
|
||||
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ManagerSalariedPayModelTests -v 2`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/models.py core/migrations/0016_worker_pay_type.py core/tests.py
|
||||
git commit -m "feat: add Worker.pay_type discriminator + is_salaried property"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Register the `Salary` adjustment type as additive
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/models.py:289` (`PayrollAdjustment.TYPE_CHOICES`)
|
||||
- Modify: `core/views.py` (`ADDITIVE_TYPES` — find with
|
||||
`grep -n "^ADDITIVE_TYPES" core/views.py`)
|
||||
- Test: `core/tests.py` (`ManagerSalariedPayTypeRegistrationTests`)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
class ManagerSalariedPayTypeRegistrationTests(TestCase):
|
||||
def test_salary_is_a_valid_adjustment_type(self):
|
||||
from core.models import PayrollAdjustment
|
||||
type_values = [c[0] for c in PayrollAdjustment.TYPE_CHOICES]
|
||||
self.assertIn('Salary', type_values)
|
||||
|
||||
def test_salary_is_additive(self):
|
||||
from core.views import ADDITIVE_TYPES
|
||||
self.assertIn('Salary', ADDITIVE_TYPES)
|
||||
```
|
||||
|
||||
**Step 2: Run — Expected:** FAIL (`'Salary'` not in list).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
`core/models.py` — append to `PayrollAdjustment.TYPE_CHOICES` (DB value ==
|
||||
label, so NO UI-vs-DB drift — see CLAUDE.md):
|
||||
|
||||
```python
|
||||
('Salary', 'Salary'),
|
||||
```
|
||||
|
||||
`core/views.py` — add `'Salary'` to the `ADDITIVE_TYPES` list (it increases
|
||||
net pay). Leave `DEDUCTIVE_TYPES` untouched. Add a short inline comment:
|
||||
`# 'Salary' = manager / fixed-salary monthly pay (additive).`
|
||||
|
||||
**Step 4: Run — Expected:** PASS (2 tests). Then run the FULL suite to confirm
|
||||
no existing test hard-codes the choices length:
|
||||
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`
|
||||
Expected: still green (175 now).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/models.py core/views.py core/tests.py
|
||||
git commit -m "feat: register 'Salary' PayrollAdjustment type as additive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Exclude managers from the daily ATTENDANCE pickers
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/forms.py:106-109` (AttendanceLogForm supervisor branch),
|
||||
`core/forms.py:114` (admin branch)
|
||||
- Modify: `core/views.py:588` (`_build_team_workers_map` prefetch queryset)
|
||||
- Modify: `core/views.py:791` (attendance cost-estimate JS rates loop)
|
||||
- Test: `core/tests.py` (`ManagerSalariedAttendanceExclusionTests`)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Mirror the existing attendance/team-filter test setup (see
|
||||
`WorkHistoryTeamFilterTests` / `AbsenceLogViewTests` for fixtures patterns —
|
||||
create an admin user, a supervisor user, a Team, a daily worker, a fixed
|
||||
worker).
|
||||
|
||||
```python
|
||||
class ManagerSalariedAttendanceExclusionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user('msa_admin', password='x', is_staff=True)
|
||||
self.sup = User.objects.create_user('msa_sup', password='x')
|
||||
self.daily = Worker.objects.create(
|
||||
name='Daily Del', id_number='MSA-D', monthly_salary=Decimal('6000'))
|
||||
self.mgr = Worker.objects.create(
|
||||
name='Mgr Mo', id_number='MSA-M', monthly_salary=Decimal('40000'),
|
||||
pay_type='fixed')
|
||||
self.team = Team.objects.create(name='MSA Team', supervisor=self.sup)
|
||||
self.team.workers.add(self.daily, self.mgr)
|
||||
|
||||
def test_attendance_admin_picker_excludes_fixed(self):
|
||||
from core.forms import AttendanceLogForm
|
||||
qs = AttendanceLogForm(user=self.admin).fields['workers'].queryset
|
||||
self.assertIn(self.daily, qs)
|
||||
self.assertNotIn(self.mgr, qs)
|
||||
|
||||
def test_attendance_supervisor_picker_excludes_fixed(self):
|
||||
from core.forms import AttendanceLogForm
|
||||
qs = AttendanceLogForm(user=self.sup).fields['workers'].queryset
|
||||
self.assertIn(self.daily, qs)
|
||||
self.assertNotIn(self.mgr, qs)
|
||||
|
||||
def test_team_workers_map_excludes_fixed(self):
|
||||
from core.views import _build_team_workers_map
|
||||
m = _build_team_workers_map(self.admin)
|
||||
ids = m.get(self.team.id) or m.get(str(self.team.id)) or []
|
||||
self.assertIn(self.daily.id, ids)
|
||||
self.assertNotIn(self.mgr.id, ids)
|
||||
```
|
||||
|
||||
**Step 2: Run — Expected:** FAIL (`mgr` present in querysets/map).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
Add `.exclude(pay_type='fixed')` to each queryset:
|
||||
|
||||
- `core/forms.py:106-109` (supervisor branch):
|
||||
```python
|
||||
self.fields['workers'].queryset = Worker.objects.filter(
|
||||
active=True,
|
||||
teams__in=supervised_teams,
|
||||
).exclude(pay_type='fixed').distinct()
|
||||
```
|
||||
- `core/forms.py:114` (admin branch):
|
||||
```python
|
||||
self.fields['workers'].queryset = Worker.objects.filter(
|
||||
active=True).exclude(pay_type='fixed')
|
||||
```
|
||||
- `core/views.py:588` — the `Prefetch('workers', queryset=Worker.objects
|
||||
.filter(active=True), to_attr='active_workers_cached')`: change the inner
|
||||
queryset to `Worker.objects.filter(active=True).exclude(pay_type='fixed')`.
|
||||
Add a comment: `# Managers (pay_type='fixed') never go on a WorkLog.`
|
||||
- `core/views.py:791` — `for w in Worker.objects.filter(active=True):` →
|
||||
`for w in Worker.objects.filter(active=True).exclude(pay_type='fixed'):`
|
||||
|
||||
**Step 4: Run — Expected:** PASS (3 tests).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/forms.py core/views.py core/tests.py
|
||||
git commit -m "feat: exclude fixed-salary managers from attendance pickers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Exclude managers from the ABSENCE pickers
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/forms.py:662` (`AbsenceLogForm.workers` admin default),
|
||||
`core/forms.py:696-702` (supervisor branch)
|
||||
- Modify: `core/forms.py:842` (`AbsenceEditForm.worker` admin),
|
||||
`core/forms.py:851` (supervisor branch)
|
||||
- Test: `core/tests.py` (`ManagerSalariedAbsenceExclusionTests`)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
class ManagerSalariedAbsenceExclusionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user('msab_admin', password='x', is_staff=True)
|
||||
self.sup = User.objects.create_user('msab_sup', password='x')
|
||||
self.daily = Worker.objects.create(
|
||||
name='Abs Daily', id_number='MSAB-D', monthly_salary=Decimal('6000'))
|
||||
self.mgr = Worker.objects.create(
|
||||
name='Abs Mgr', id_number='MSAB-M', monthly_salary=Decimal('40000'),
|
||||
pay_type='fixed')
|
||||
self.team = Team.objects.create(name='MSAB Team', supervisor=self.sup, active=True)
|
||||
self.team.workers.add(self.daily, self.mgr)
|
||||
|
||||
def test_absencelog_admin_excludes_fixed(self):
|
||||
from core.forms import AbsenceLogForm
|
||||
qs = AbsenceLogForm(user=self.admin).fields['workers'].queryset
|
||||
self.assertIn(self.daily, qs)
|
||||
self.assertNotIn(self.mgr, qs)
|
||||
|
||||
def test_absencelog_supervisor_excludes_fixed(self):
|
||||
from core.forms import AbsenceLogForm
|
||||
qs = AbsenceLogForm(user=self.sup).fields['workers'].queryset
|
||||
self.assertIn(self.daily, qs)
|
||||
self.assertNotIn(self.mgr, qs)
|
||||
|
||||
def test_absenceedit_admin_excludes_fixed(self):
|
||||
from core.forms import AbsenceEditForm
|
||||
qs = AbsenceEditForm(user=self.admin).fields['worker'].queryset
|
||||
self.assertNotIn(self.mgr, qs)
|
||||
|
||||
def test_absenceedit_supervisor_excludes_fixed(self):
|
||||
from core.forms import AbsenceEditForm
|
||||
qs = AbsenceEditForm(user=self.sup).fields['worker'].queryset
|
||||
self.assertNotIn(self.mgr, qs)
|
||||
```
|
||||
|
||||
> If `AbsenceEditForm(user=...)` needs an `instance=` to construct, mirror how
|
||||
> the existing absence-edit tests instantiate it (check
|
||||
> `AbsenceEditDeleteTests` in `core/tests.py`) and adjust the two
|
||||
> `AbsenceEditForm` tests accordingly — the assertion (mgr not in queryset)
|
||||
> stays the same.
|
||||
|
||||
**Step 2: Run — Expected:** FAIL.
|
||||
|
||||
**Step 3: Implement** — add `.exclude(pay_type='fixed')` to each of the four
|
||||
querysets (`forms.py:662`, `:696-702`, `:842`, `:851`), preserving any existing
|
||||
`.distinct()` (keep `.exclude(pay_type='fixed')` before `.distinct()`).
|
||||
|
||||
**Step 4: Run — Expected:** PASS (4 tests).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/forms.py core/tests.py
|
||||
git commit -m "feat: exclude fixed-salary managers from absence pickers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `add_adjustment` Salary branch + project-required validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/views.py:4247` (add `'Salary'` to `project_required_types`)
|
||||
- Modify: `core/views.py` — new `Salary` branch placed AFTER the
|
||||
`Advance Payment` branch (ends ~`:4386`) and BEFORE the generic
|
||||
"ALL OTHER TYPES" block (`:4388`)
|
||||
- Modify: `core/forms.py:179` (`PayrollAdjustmentForm.clean`
|
||||
`project_required_types` tuple — add `'Salary'`)
|
||||
- Test: `core/tests.py` (`ManagerSalariedPaySalaryAdjustmentTests`)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
class ManagerSalariedPaySalaryAdjustmentTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user('mss_admin', password='x', is_staff=True)
|
||||
self.client.force_login(self.admin)
|
||||
self.proj = Project.objects.create(name='MSS Project')
|
||||
self.mgr = Worker.objects.create(
|
||||
name='Sal Mgr', id_number='MSS-M', monthly_salary=Decimal('40000'),
|
||||
pay_type='fixed')
|
||||
|
||||
def _post(self, **over):
|
||||
data = {
|
||||
'workers': [self.mgr.id], 'type': 'Salary',
|
||||
'amount': '40000.00', 'description': 'Salary - May 2026',
|
||||
'date': _date.today().strftime('%Y-%m-%d'),
|
||||
'project': self.proj.id,
|
||||
}
|
||||
data.update(over)
|
||||
return self.client.post(reverse('add_adjustment'), data)
|
||||
|
||||
def test_salary_requires_project(self):
|
||||
resp = self._post(project='')
|
||||
self.assertFalse(
|
||||
PayrollAdjustment.objects.filter(worker=self.mgr, type='Salary').exists())
|
||||
|
||||
def test_salary_unpaid_creates_pending_adjustment(self):
|
||||
self._post() # no pay_immediately
|
||||
adj = PayrollAdjustment.objects.get(worker=self.mgr, type='Salary')
|
||||
self.assertIsNone(adj.payroll_record)
|
||||
self.assertEqual(adj.project, self.proj)
|
||||
self.assertEqual(adj.amount, Decimal('40000.00'))
|
||||
|
||||
def test_salary_pay_immediately_creates_payroll_record(self):
|
||||
self._post(pay_immediately='1')
|
||||
adj = PayrollAdjustment.objects.get(worker=self.mgr, type='Salary')
|
||||
self.assertIsNotNone(adj.payroll_record)
|
||||
self.assertEqual(adj.payroll_record.amount_paid, Decimal('40000.00'))
|
||||
|
||||
def test_salary_required_in_payrolladjustmentform_clean(self):
|
||||
from core.forms import PayrollAdjustmentForm
|
||||
f = PayrollAdjustmentForm(data={
|
||||
'type': 'Salary', 'worker': self.mgr.id, 'amount': '40000.00',
|
||||
'date': _date.today().strftime('%Y-%m-%d'), 'description': 'x',
|
||||
})
|
||||
self.assertFalse(f.is_valid())
|
||||
self.assertIn('project', f.errors)
|
||||
```
|
||||
|
||||
> `_date` is the `datetime.date` alias already imported in `core/tests.py`
|
||||
> (~line 1693). If your new class is above that import, add
|
||||
> `from datetime import date as _date` locally or use `datetime.date`.
|
||||
|
||||
**Step 2: Run — Expected:** FAIL (Salary not project-required; no branch).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
`core/forms.py:179` — add `'Salary'`:
|
||||
```python
|
||||
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary')
|
||||
```
|
||||
|
||||
`core/views.py:4247` — add `'Salary'`:
|
||||
```python
|
||||
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary')
|
||||
```
|
||||
|
||||
`core/views.py` — insert this branch AFTER the Advance Payment block's
|
||||
`continue` (~`:4386`), BEFORE the generic `# === ALL OTHER TYPES ===` block:
|
||||
|
||||
```python
|
||||
# === SALARY — manager / fixed-salary monthly payment ===
|
||||
# Mirrors the New Loan pay-immediately pattern (isolated
|
||||
# PayrollRecord). If "Pay Immediately" is unchecked the row falls
|
||||
# through to the generic pending-adjustment creation below and is
|
||||
# netted with any deductions/loan-repayments by
|
||||
# _process_single_payment when the admin clicks Pay on the dashboard.
|
||||
if adj_type == 'Salary':
|
||||
pay_immediately = request.POST.get('pay_immediately') == '1'
|
||||
if pay_immediately:
|
||||
salary_adj = PayrollAdjustment.objects.create(
|
||||
worker=worker,
|
||||
type='Salary',
|
||||
amount=amount,
|
||||
date=adj_date,
|
||||
description=description,
|
||||
project=project,
|
||||
)
|
||||
payroll_record = PayrollRecord.objects.create(
|
||||
worker=worker,
|
||||
amount_paid=amount,
|
||||
date=adj_date,
|
||||
)
|
||||
salary_adj.payroll_record = payroll_record
|
||||
salary_adj.save()
|
||||
_send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00'))
|
||||
created_count += 1
|
||||
continue # Skip the generic PayrollAdjustment creation below
|
||||
# else: fall through to generic pending creation (keeps project)
|
||||
```
|
||||
|
||||
(The generic block at `:4388-4398` already sets `project=project`, so an
|
||||
unpaid Salary needs no extra code there.)
|
||||
|
||||
**Step 4: Run — Expected:** PASS (4 tests). Then full suite:
|
||||
`USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2` — green.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/views.py core/forms.py core/tests.py
|
||||
git commit -m "feat: add_adjustment Salary branch (project-required, pay-now/pending)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Report — per-project "Management / Salaried Cost" + regression guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/views.py:2416` (`_build_report_context`) — add a
|
||||
`salaried_cost_by_project` rollup from `Salary`-typed adjustments
|
||||
- Modify: `core/templates/core/report.html` — render the new line in the
|
||||
per-project section (find the existing "Labour Cost by Project" block)
|
||||
- Test: `core/tests.py` (`ManagerSalariedPayReportTests`)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
The regression test is the important one — it proves daily-worker numbers are
|
||||
byte-for-byte unchanged when a manager + Salary adjustment exist.
|
||||
|
||||
```python
|
||||
class ManagerSalariedPayReportTests(TestCase):
|
||||
def setUp(self):
|
||||
self.proj = Project.objects.create(name='Rep Proj')
|
||||
self.daily = Worker.objects.create(
|
||||
name='Rep Daily', id_number='RP-D', monthly_salary=Decimal('6000'))
|
||||
self.start = _date(2026, 5, 1)
|
||||
self.end = _date(2026, 5, 31)
|
||||
wl = WorkLog.objects.create(date=_date(2026, 5, 4), project=self.proj)
|
||||
wl.workers.add(self.daily)
|
||||
|
||||
def _ctx(self):
|
||||
from core.views import _build_report_context
|
||||
return _build_report_context(self.start, self.end)
|
||||
|
||||
def test_daily_numbers_unchanged_when_manager_added(self):
|
||||
before = self._ctx()
|
||||
mgr = Worker.objects.create(
|
||||
name='Rep Mgr', id_number='RP-M', monthly_salary=Decimal('40000'),
|
||||
pay_type='fixed')
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=mgr, type='Salary', amount=Decimal('40000.00'),
|
||||
date=_date(2026, 5, 25), project=self.proj)
|
||||
after = self._ctx()
|
||||
# WorkLog-derived figures must be identical with a manager present.
|
||||
self.assertEqual(
|
||||
before.get('total_worker_days'), after.get('total_worker_days'))
|
||||
self.assertEqual(
|
||||
str(before.get('labour_cost_by_project')),
|
||||
str(after.get('labour_cost_by_project')))
|
||||
|
||||
def test_salaried_cost_by_project_exposed(self):
|
||||
mgr = Worker.objects.create(
|
||||
name='Rep Mgr2', id_number='RP-M2', monthly_salary=Decimal('40000'),
|
||||
pay_type='fixed')
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=mgr, type='Salary', amount=Decimal('40000.00'),
|
||||
date=_date(2026, 5, 25), project=self.proj)
|
||||
ctx = self._ctx()
|
||||
self.assertIn('salaried_cost_by_project', ctx)
|
||||
total = sum(
|
||||
(row.get('total') or Decimal('0'))
|
||||
for row in ctx['salaried_cost_by_project'])
|
||||
self.assertEqual(total, Decimal('40000.00'))
|
||||
```
|
||||
|
||||
> Adjust the exact context-key names in the assertions to match what
|
||||
> `_build_report_context` actually returns for the labour-cost-by-project
|
||||
> structure (inspect `views.py:~2502` and the keys the function returns near
|
||||
> its `return {...}`). Keep the **intent**: (a) WorkLog-derived keys identical
|
||||
> before/after a manager exists; (b) a new `salaried_cost_by_project` key whose
|
||||
> Salary amounts sum to 40000.
|
||||
|
||||
**Step 2: Run — Expected:** FAIL (`salaried_cost_by_project` missing).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
In `_build_report_context`, after the existing adjustments queryset is built
|
||||
(it is already filtered by `project_id` when `project_ids` is set, ~`:2484-2490`)
|
||||
and near the "Labour Cost by Project" computation (~`:2502`), add a salaried
|
||||
rollup that does NOT touch the WorkLog-derived `labour_cost_by_project`:
|
||||
|
||||
```python
|
||||
# --- Management / Salaried Cost by Project (selected period) ---
|
||||
# Managers have no WorkLogs, so their pay never appears in the
|
||||
# WorkLog-derived labour cost above. Surface it as a SEPARATE line so
|
||||
# it is visible + attributed but never merged into daily-worker numbers.
|
||||
salaried_rows = (
|
||||
adjustments.filter(type='Salary')
|
||||
.values('project__id', 'project__name')
|
||||
.annotate(total=Sum('amount'))
|
||||
.order_by('-total')
|
||||
)
|
||||
salaried_cost_by_project = [
|
||||
{
|
||||
'project_id': r['project__id'],
|
||||
'project': r['project__name'] or 'Unassigned',
|
||||
'total': r['total'] or Decimal('0.00'),
|
||||
}
|
||||
for r in salaried_rows
|
||||
]
|
||||
```
|
||||
|
||||
Add `salaried_cost_by_project` to the context dict that the function returns
|
||||
(find its `return {...}` / `context = {...}` and add the key).
|
||||
|
||||
`core/templates/core/report.html` — in the per-project section that renders
|
||||
"Labour Cost by Project", add a sibling block iterating
|
||||
`salaried_cost_by_project` titled **"Management / Salaried Cost"**, mirroring
|
||||
the existing block's markup/classes. Keep money formatting consistent
|
||||
(`{{ row.total|money }}` — the project uses the `money` filter, see
|
||||
`format_tags.py`). If you write any multi-line explanatory comment in the
|
||||
template use `{% comment %}...{% endcomment %}`, never multi-line `{# #}`.
|
||||
|
||||
**Step 4: Run — Expected:** PASS (2 tests). Then:
|
||||
- Full suite green.
|
||||
- Template sanity check:
|
||||
`grep -rn "^\s*{#" core/templates/ | awk -F: '$0 !~ /#}/ {print}'`
|
||||
Expected: **no output**.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/views.py core/templates/core/report.html core/tests.py
|
||||
git commit -m "feat: per-project Management/Salaried Cost line + regression guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: UI — WorkerForm pay_type, Type indicator, Pay-Salary entry, badge CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/forms.py:248` (`WorkerForm` — add `pay_type` to `fields` +
|
||||
a Select widget)
|
||||
- Modify: worker templates — `core/templates/core/workers/edit.html`
|
||||
(render the `pay_type` field in the "Personal & Pay" section),
|
||||
`core/templates/core/workers/list.html` and `.../detail.html`
|
||||
(a "Type" indicator: "Daily" vs "Manager / Salaried")
|
||||
- Modify: the Add-Adjustment modal template (the payroll dashboard modal —
|
||||
locate with `grep -rn "name=\"pay_immediately\"\|id=\"adjustmentModal\"\|Add Adjustment" core/templates`) — add a "Salary" `<option>` to the type
|
||||
`<select>` and ensure the project field + a "Pay Salary" entry point
|
||||
(a button that opens the modal pre-set to type=Salary) exist
|
||||
- Modify: `static/css/custom.css` — add `--badge-salary-bg/-fg` (dark +
|
||||
light theme blocks) and a `.badge-type-salary` rule, matching the existing
|
||||
`--badge-*` token pattern (search `--badge-bonus`)
|
||||
- Test: `core/tests.py` (`ManagerSalariedPayUITests`)
|
||||
|
||||
**Step 1: Write the failing tests** (light, server-side only)
|
||||
|
||||
```python
|
||||
class ManagerSalariedPayUITests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user('msu_admin', password='x', is_staff=True)
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
def test_workerform_has_pay_type(self):
|
||||
from core.forms import WorkerForm
|
||||
self.assertIn('pay_type', WorkerForm().fields)
|
||||
|
||||
def test_worker_edit_page_renders_pay_type(self):
|
||||
resp = self.client.get(reverse('worker_edit', args=['new']) if False else '/workers/new/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, 'pay_type')
|
||||
|
||||
def test_worker_list_shows_manager_label_for_fixed(self):
|
||||
Worker.objects.create(
|
||||
name='UI Mgr', id_number='MSU-M', monthly_salary=Decimal('40000'),
|
||||
pay_type='fixed')
|
||||
resp = self.client.get('/workers/?status=all')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, 'Manager') # the "Manager / Salaried" label
|
||||
```
|
||||
|
||||
> Confirm the worker-new URL (the design/CLAUDE.md routes table says
|
||||
> `/workers/new/` → `worker_edit`). Adjust the second test's path/reverse to
|
||||
> the project's actual pattern if `reverse('worker_edit', ...)` differs; keep
|
||||
> the assertion that the page renders and contains the `pay_type` control.
|
||||
> If `/workers/` requires a status param to show inactive/all, keep `?status=all`.
|
||||
|
||||
**Step 2: Run — Expected:** FAIL.
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
- `WorkerForm.Meta.fields`: add `'pay_type'` (put it right after
|
||||
`'monthly_salary'` so it reads naturally in the Personal & Pay section).
|
||||
Add to `widgets`:
|
||||
`'pay_type': forms.Select(attrs={'class': 'form-select'}),`
|
||||
- `workers/edit.html`: render `{{ form.pay_type }}` with its label in the
|
||||
Personal & Pay block next to monthly salary (match the surrounding
|
||||
field-group markup).
|
||||
- `workers/list.html` + `workers/detail.html`: where the row/profile shows
|
||||
worker attributes, add a small indicator —
|
||||
`{% if worker.is_salaried %}Manager / Salaried{% else %}Daily{% endif %}`
|
||||
(use a badge span consistent with the page's existing chip styling).
|
||||
- Add-Adjustment modal: add `<option value="Salary">Salary</option>` to the
|
||||
type select; ensure the project field shows for Salary (the modal already
|
||||
toggles project visibility per type — extend that JS list to include
|
||||
`Salary`, mirroring `Bonus`). Add a "Pay Salary" button (e.g. on the
|
||||
payroll dashboard near "Add Adjustment") that opens the same modal with
|
||||
type preset to `Salary` — reuse the existing modal-open JS pattern; do not
|
||||
build a new modal.
|
||||
- `static/css/custom.css`: add tokens mirroring `--badge-bonus-*`:
|
||||
```css
|
||||
--badge-salary-bg: <pick a distinct hue, e.g. teal>;
|
||||
--badge-salary-fg: <readable fg>;
|
||||
```
|
||||
in BOTH the `:root` (dark) and `[data-theme="light"]` blocks, plus a
|
||||
`.badge-type-salary { background: var(--badge-salary-bg); color: var(--badge-salary-fg); }`
|
||||
rule next to the other `.badge-type-*` rules. (`type_slug` turns
|
||||
`'Salary'` → `salary` automatically.)
|
||||
|
||||
**Step 4: Run — Expected:** PASS (3 tests). Then:
|
||||
- Full suite green.
|
||||
- Template sanity check (same grep as Task 6) — no output.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/forms.py core/templates static/css/custom.css core/tests.py
|
||||
git commit -m "feat: pay_type UI (worker form/list/detail), Salary modal option + badge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Documentation + full-suite gate + HARD STOP
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/plans/parked-work.md`
|
||||
- No test code; this task is docs + the final gate.
|
||||
|
||||
**Step 1: Update `CLAUDE.md`**
|
||||
|
||||
- "Key Models" → `Worker` line: note the new `pay_type`
|
||||
(`'daily'` default | `'fixed'` = manager/salaried; `is_salaried` property).
|
||||
- `Absence` / Payroll Constants area: add `'Salary'` to the documented
|
||||
`ADDITIVE_TYPES` list so it stays accurate.
|
||||
- Add a short subsection "Manager / Salaried pay" summarising: managers are
|
||||
`Worker(pay_type='fixed')`; excluded from attendance/absence pickers ONLY
|
||||
(NOT payroll modal pickers); paid via the `Salary` adjustment type through
|
||||
`add_adjustment`; immediate path mirrors New Loan (isolated PayrollRecord),
|
||||
unpaid path nets via `_process_single_payment`; "Manager / Salaried" is a
|
||||
Path-A display-only label (model/table stay `Worker`).
|
||||
- Add to the UI-vs-DB / Path-A naming section: "Manager / Salaried" is a
|
||||
display label; the model is `Worker`, the discriminator is `pay_type`.
|
||||
|
||||
**Step 2: Update `docs/plans/parked-work.md`**
|
||||
|
||||
Move/append a "Recently shipped" entry (or, if not yet pushed, a
|
||||
"⏸ Paused — implemented locally, awaiting Konrad's verification" entry)
|
||||
describing the Manager/Salaried Pay feature, the design+plan commits, and the
|
||||
**HARD STOP before push** state.
|
||||
|
||||
**Step 3: Full suite gate**
|
||||
|
||||
Run: `USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2`
|
||||
Expected: **ALL green** (≈173 baseline + ~18 new ≈ 191; exact count may vary).
|
||||
Run the template sanity grep one final time — no output.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/plans/parked-work.md
|
||||
git commit -m "docs: document Manager/Salaried pay; park pending Konrad local verify"
|
||||
```
|
||||
|
||||
**Step 5: 🛑 HARD STOP — DO NOT PUSH OR DEPLOY**
|
||||
|
||||
Stop here. Do **not** `git push`, do **not** touch the Flatlogic VM, do **not**
|
||||
run any deploy step. Report completion to Konrad and hand him the manual
|
||||
verification checklist from the design doc
|
||||
(`docs/plans/2026-05-15-manager-salaried-pay-design.md` → "Verification
|
||||
(manual, local — Konrad)"). Only after Konrad runs it locally and explicitly
|
||||
says "push it" does anything leave the local machine. All commits from Tasks
|
||||
1–8 remain **local-only on `ai-dev`**, consistent with the existing local-only
|
||||
doc commits.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
- ~18 new tests added, full suite green locally, no template-comment breakage.
|
||||
- `pay_type` migration defaults every existing worker to `'daily'`.
|
||||
- Managers excluded from attendance + absence pickers only; still selectable in
|
||||
all payroll modals.
|
||||
- `Salary` type works (project-required; pay-now isolated PR like New Loan;
|
||||
unpaid → pending → nets via `_process_single_payment`).
|
||||
- Regression test proves daily-worker report numbers are byte-for-byte
|
||||
unchanged with a manager present.
|
||||
- `_process_single_payment` logic untouched.
|
||||
- Docs updated. **Nothing pushed.** Konrad verifies locally before deploy.
|
||||
Loading…
x
Reference in New Issue
Block a user