feat: per-project Management/Salaried Cost report line + regression & netting guards

This commit is contained in:
Konrad du Plessis 2026-05-15 20:35:11 +02:00
parent 255ec82cef
commit 65b10e74ec
3 changed files with 136 additions and 0 deletions

View File

@ -485,6 +485,46 @@ apples-to-apples when filters are on.
</div>
</div>
{% 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 by Project (selected period) -->
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-user-tie me-2" style="color: var(--accent);"></i>Management / Salaried Cost</h6>
</div>
<div class="card-body p-0">
{% if salaried_cost_by_project %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Project</th>
<th class="text-end" data-bs-toggle="tooltip" title="Salaried (manager) pay attributed to this project. Managers have no work logs, so this cost is shown separately and is NEVER included in the Labour Cost by Project figures above.">
Salaried Cost <i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75rem;"></i>
</th>
</tr>
</thead>
<tbody>
{% for item in salaried_cost_by_project %}
<tr><td>{{ item.project }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No salaried (management) cost data.</p>{% endif %}
</div>
</div>
</div>
</div>
{# === CHAPTER III — Worker Breakdown === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">III</span>Worker Breakdown</h5>
<!-- Worker Breakdown -->

View File

@ -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

View File

@ -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,