Add _company_cost_velocity helper + 3 tests
Computes company-wide avg daily and monthly labour cost for the executive report's hero KPI band (cards 3 and 4). Denominator is working days (distinct work-log dates), not calendar days — true cost-of-a-productive-day metric per design section 2. Monthly = daily * 30.44 (the 365.25/12 month-length approximation, which keeps annualised totals correct on average). Tests cover: empty DB returns zero, known values with assertAlmostEqual for the 30.44 multiplication, and that multiple workers on one date count as 1 working day (not N). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6be6a09056
commit
e74f48f050
@ -476,3 +476,61 @@ class SupervisorPickerQuerysetTests(TestCase):
|
|||||||
username='an_admin', password='pass', is_staff=True
|
username='an_admin', password='pass', is_staff=True
|
||||||
)
|
)
|
||||||
self.assertIn(admin, _supervisor_user_queryset())
|
self.assertIn(admin, _supervisor_user_queryset())
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# === TESTS FOR EXECUTIVE REPORT v2 ===
|
||||||
|
# Covers the new helpers introduced in the report rebuild (Apr 2026):
|
||||||
|
# _company_cost_velocity, _current_outstanding_in_scope, _team_project_activity,
|
||||||
|
# and the multi-filter extension of _build_report_context.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyCostVelocityTests(TestCase):
|
||||||
|
"""Company-wide avg daily and monthly labour cost (hero KPI card 3 & 4)."""
|
||||||
|
|
||||||
|
def test_empty_db_returns_zero(self):
|
||||||
|
from core.views import _company_cost_velocity
|
||||||
|
result = _company_cost_velocity()
|
||||||
|
self.assertEqual(result['avg_daily'], Decimal('0.00'))
|
||||||
|
self.assertEqual(result['avg_monthly'], Decimal('0.00'))
|
||||||
|
self.assertEqual(result['working_days'], 0)
|
||||||
|
|
||||||
|
def test_known_values(self):
|
||||||
|
from core.views import _company_cost_velocity
|
||||||
|
# Setup: 2 workers (daily_rate = 4000/20 = R 200 each), each works 5 distinct dates.
|
||||||
|
# Lifetime cost = 2 workers * 5 days * R 200 = R 2000. Working days = 5.
|
||||||
|
# Avg daily = 2000 / 5 = R 400.
|
||||||
|
# Avg monthly = 400 * 30.44 = R 12,176.
|
||||||
|
admin = User.objects.create_user(username='admin-cv', is_staff=True)
|
||||||
|
project = Project.objects.create(name='P')
|
||||||
|
w1 = Worker.objects.create(name='W1', id_number='W1', monthly_salary=Decimal('4000'))
|
||||||
|
w2 = Worker.objects.create(name='W2', id_number='W2', monthly_salary=Decimal('4000'))
|
||||||
|
for d in range(1, 6): # 5 distinct dates
|
||||||
|
log = WorkLog.objects.create(
|
||||||
|
date=datetime.date(2026, 3, d),
|
||||||
|
project=project, supervisor=admin,
|
||||||
|
)
|
||||||
|
log.workers.add(w1, w2)
|
||||||
|
|
||||||
|
result = _company_cost_velocity()
|
||||||
|
self.assertEqual(result['working_days'], 5)
|
||||||
|
self.assertEqual(result['avg_daily'], Decimal('400.00'))
|
||||||
|
# Tolerance: ±1 cent for the 30.44 multiplication
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
float(result['avg_monthly']), 12176.00, delta=0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_duplicate_dates_not_double_counted(self):
|
||||||
|
"""Two workers working the same date = 1 distinct date, not 2."""
|
||||||
|
from core.views import _company_cost_velocity
|
||||||
|
admin = User.objects.create_user(username='admin-cv2', is_staff=True)
|
||||||
|
project = Project.objects.create(name='P2')
|
||||||
|
w1 = Worker.objects.create(name='X', id_number='X1', monthly_salary=Decimal('4000'))
|
||||||
|
w2 = Worker.objects.create(name='Y', id_number='Y1', monthly_salary=Decimal('4000'))
|
||||||
|
log = WorkLog.objects.create(
|
||||||
|
date=datetime.date(2026, 3, 1), project=project, supervisor=admin,
|
||||||
|
)
|
||||||
|
log.workers.add(w1, w2)
|
||||||
|
result = _company_cost_velocity()
|
||||||
|
self.assertEqual(result['working_days'], 1) # not 2
|
||||||
|
|||||||
@ -216,6 +216,42 @@ def _compute_outstanding(project_ids=None, team_ids=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# === COMPANY COST VELOCITY ===
|
||||||
|
# Lifetime "what does a typical FoxFitt working day cost us?" metric.
|
||||||
|
# Denominator = COUNT(DISTINCT work_log.date) — true working days, not
|
||||||
|
# calendar days (rain days, weekends, permit delays don't dilute the rate).
|
||||||
|
# Used by the hero KPI band on the payroll report.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _company_cost_velocity():
|
||||||
|
"""Return company-wide avg daily and monthly labour cost (lifetime)."""
|
||||||
|
# Total lifetime labour cost: sum of (worker.daily_rate) over every
|
||||||
|
# (log, worker) pair that has ever been logged.
|
||||||
|
total_cost = Decimal('0.00')
|
||||||
|
for wl in WorkLog.objects.prefetch_related('workers').all():
|
||||||
|
for worker in wl.workers.all():
|
||||||
|
total_cost += worker.daily_rate
|
||||||
|
|
||||||
|
# Distinct work-log dates = working days
|
||||||
|
working_days = WorkLog.objects.values('date').distinct().count()
|
||||||
|
|
||||||
|
if working_days == 0:
|
||||||
|
avg_daily = Decimal('0.00')
|
||||||
|
else:
|
||||||
|
avg_daily = (total_cost / working_days).quantize(Decimal('0.01'))
|
||||||
|
|
||||||
|
# 30.44 = 365.25 / 12 — standard month-length approximation.
|
||||||
|
# Keeps annualised totals correct on average.
|
||||||
|
avg_monthly = (avg_daily * Decimal('30.44')).quantize(Decimal('0.01'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'avg_daily': avg_daily,
|
||||||
|
'avg_monthly': avg_monthly,
|
||||||
|
'working_days': working_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# === HOME DASHBOARD ===
|
# === HOME DASHBOARD ===
|
||||||
# The main page users see after logging in. Shows different content
|
# The main page users see after logging in. Shows different content
|
||||||
# depending on whether the user is an admin or supervisor.
|
# depending on whether the user is an admin or supervisor.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user