feat: per-project Management/Salaried Cost report line + regression & netting guards
This commit is contained in:
parent
255ec82cef
commit
65b10e74ec
@ -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 -->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user