From 65b10e74ec4ca688bcf0da2446ca5cf9e103e624 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 15 May 2026 20:35:11 +0200 Subject: [PATCH] feat: per-project Management/Salaried Cost report line + regression & netting guards --- core/templates/core/report.html | 40 +++++++++++++++++++ core/tests.py | 69 +++++++++++++++++++++++++++++++++ core/views.py | 27 +++++++++++++ 3 files changed, 136 insertions(+) diff --git a/core/templates/core/report.html b/core/templates/core/report.html index 9276dc5..9d84975 100644 --- a/core/templates/core/report.html +++ b/core/templates/core/report.html @@ -485,6 +485,46 @@ apples-to-apples when filters are on. +{% comment %} + Management / Salaried Cost by Project (selected period). + Managers are fixed-pay workers with NO WorkLogs, so their pay never + appears in the WorkLog-derived "Labour Cost by Project" table above. + This is a SEPARATE line so management cost is visible + attributed to + the right project, but never merged into the daily-worker numbers. + Mirrors the "Labour Cost by Project" block's card/table markup. +{% endcomment %} + +
+
+
+
+
Management / Salaried Cost
+
+
+ {% if salaried_cost_by_project %} +
+ + + + + + + + + {% for item in salaried_cost_by_project %} + + {% endfor %} + +
Project + Salaried Cost +
{{ item.project }}R {{ item.total|money }}
+
+ {% else %}

No salaried (management) cost data.

{% endif %} +
+
+
+
+ {# === CHAPTER III — Worker Breakdown === #}
IIIWorker Breakdown
diff --git a/core/tests.py b/core/tests.py index d471b0e..ba90088 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3641,3 +3641,72 @@ class ManagerSalariedPaySalaryAdjustmentTests(TestCase): }) self.assertFalse(f.is_valid()) self.assertIn('project', f.errors) + + +# ============================================================================= +# === TASK 6 — MANAGER / SALARIED PAY: REPORT SURFACING + REGRESSION GUARDS === +# Proves managers' salary cost shows as a SEPARATE per-project report line, +# that daily-worker (WorkLog-derived) numbers are byte-for-byte unchanged +# when a manager exists, and that an unpaid Salary nets correctly with a +# same-month Deduction through the real pay flow. +# ============================================================================= + +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): + 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('cost_per_project')), + str(after.get('cost_per_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')) + + def test_unpaid_salary_nets_with_deduction_through_pay_flow(self): + admin = User.objects.create_user('msr_admin', password='x', is_staff=True) + self.client.force_login(admin) + mgr = Worker.objects.create( + name='Net Mgr', id_number='RP-NET', monthly_salary=Decimal('40000'), + pay_type='fixed') + sal = PayrollAdjustment.objects.create( + worker=mgr, type='Salary', amount=Decimal('40000.00'), + date=_date(2026, 5, 25), project=self.proj) # unpaid + ded = PayrollAdjustment.objects.create( + worker=mgr, type='Deduction', amount=Decimal('1000.00'), + date=_date(2026, 5, 25), project=self.proj) # unpaid + resp = self.client.post(reverse('process_payment', args=[mgr.id])) + sal.refresh_from_db() + ded.refresh_from_db() + self.assertIsNotNone(sal.payroll_record) + self.assertEqual(sal.payroll_record, ded.payroll_record) # same PayrollRecord + self.assertEqual(sal.payroll_record.amount_paid, Decimal('39000.00')) # 40000 - 1000 diff --git a/core/views.py b/core/views.py index 1296503..87ee967 100644 --- a/core/views.py +++ b/core/views.py @@ -2514,6 +2514,32 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None) work_logs_qs.filter(team__isnull=False), 'team__name', 'team' ) + # --- Management / Salaried Cost by Project (selected period) --- + # Managers (fixed-pay workers) have NO WorkLogs, so their pay never + # appears in the WorkLog-derived "Labour Cost by Project" figures above. + # We surface their cost as a SEPARATE per-project line so it is visible + # and attributed to the right project, but is NEVER merged into the + # daily-worker numbers. + # + # We REUSE the `adjustments` queryset built earlier (it already applies + # the project filter via the safe FK `project_id__in` subquery — no M2M + # JOIN inflation, per the CLAUDE.md "M2M filter + aggregate inflation" + # note). We do NOT add a chained `.filter().filter()` here. + 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 + ] + # --- ALL TIME: project and team costs since the very first work log --- all_time_logs = WorkLog.objects.all() if project_ids: @@ -2716,6 +2742,7 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None) 'payments_by_date': payments_by_date, 'cost_per_project': cost_per_project, 'cost_per_team': cost_per_team, + 'salaried_cost_by_project': salaried_cost_by_project, 'adjustment_totals': adjustment_totals, 'active_adj_types': active_adj_types, 'active_adj_labels': active_adj_labels,