loreal-utilisation-dept/backend/tests/test_merge.py
DJP cd1c99d5e0 feat: KPI tiles, active/soft booking split, hour-breakdown drill-down, period toggle, forecast line, sync button
Major parity push against the original SPA's bundle-level feature set
(non-architectural items — separate Forecast / ProjectType / TimeLog
views and AI Chat remain TBD).

Backend (40/40 tests, +7):

- merge.py splits booked hours by booking status: active vs soft.
  Active set: Active, Active Booked, Fully Booked, Partially Booked.
  Soft set: Soft Booking, Soft Booked, Soft-Booked. Unknown statuses
  default to active so they're not silently dropped. Existing
  `bookedHours` field is preserved as the sum for back-compat.
- compute_totals(): rolls KPIs across the filtered summary —
  totalBooked, activeBooked, softBooked, totalLogged, totalBillable,
  totalLeave, totalProjects (distinct projectName/projectNumber),
  activePeople (distinct employees with logged>0), allocated,
  allocatedNetOfLeave.
- breakdown_by_project(): drills into a single period+employee (or
  whole-period) and returns per-project logged + booked hours.
- New /api/utilisation/breakdown endpoint. /api/utilisation/summary
  response gains `totals` and accepts `period=week|month`.
- New schemas: UtilisationTotals, BreakdownResponse, plus
  activeBookedHours / softBookedHours on UtilisationSummaryRow.

Frontend (typecheck/lint/build clean):

- KpiTiles component shows Total Booked / Logged / Billable, Total
  projects, Active People (logged), Active bookings, Avg/person/week
  or /month, Allocated (net of leave), Period covered.
- PeriodToggle (Per day / Per week / Per month). Day is rendered
  disabled with an explanatory tooltip — backend only accepts week/
  month.
- HourBreakdown drill-down panel: per-project logged + booked rows,
  shown when a chart bar is clicked; "Upload a time log to see logged
  breakdown" empty-state when no upload yet.
- MonthlyUtilisation: ComposedChart with stacked Active/Soft booked
  bars + forecast Line overlay driven by the existing showForecast
  toggle. onPeriodClick wired into HourBreakdown.
- WeeklyUtilisation, BookingVsActual: same Active/Soft stack treatment.
- Resourcing now passes the timelog hash through to summary so
  loggedHours actually populates there too (was 0 before).
- Sync Airtable button on both Department and Resourcing — force-
  refreshes bookings cache, re-derives summary.
- Tutorial steps re-mapped to the original SPA's chapter titles:
  "Reading the Utilisation Chart", "Hours & Utilisation", "Drill-In",
  "Forecast Line & Filters", "Spotting Resource Issues", "Sync
  Airtable Bookings". Tutorial page heading is now "Interactive
  Walkthrough" with the original copy.
- Defensive coercion in Bookings table totalHoursBooked rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:06:23 -04:00

316 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for services.merge.summarise."""
from __future__ import annotations
from datetime import date
import pytest
from app.services.merge import (
breakdown_by_project,
compute_totals,
iter_periods,
summarise,
)
def test_iter_periods_week():
periods = iter_periods(date(2026, 5, 4), date(2026, 5, 17), "week")
labels = [p.label for p in periods]
# 2026-05-04 is Mon of ISO week 2026-W19; through 2026-05-17 covers W19 and W20.
assert labels == ["2026-W19", "2026-W20"]
def test_iter_periods_month():
periods = iter_periods(date(2026, 5, 1), date(2026, 6, 30), "month")
assert [p.label for p in periods] == ["2026-05", "2026-06"]
def test_empty_inputs():
rows = summarise([], [], [], from_=date(2026, 5, 4), to_=date(2026, 5, 8))
assert rows == []
def test_basic_utilisation(sample_resources, sample_bookings, sample_logged):
# Bhakti: 40h/week avail, 35h logged (5 days × 7h), 38h booked (within W19).
rows = summarise(
sample_logged,
sample_bookings,
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
period="week",
)
bhakti = [r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19"]
assert len(bhakti) == 1
b = bhakti[0]
assert b["availableHours"] == 40.0
assert b["loggedHours"] == 35.0
assert b["bookedHours"] == 38.0
assert b["billableHours"] == 28.0 # 4 of 5 days billable × 7
assert b["nonBillableHours"] == 7.0
assert b["actualUtilisationPct"] == 87.5
assert b["bookedUtilisationPct"] == 95.0
assert b["employmentType"] == "FTE"
def test_booking_spans_two_weeks(sample_resources):
"""A booking that straddles a week boundary must be split by working-day overlap."""
# Booking: 2026-05-07 (Thu W19) .. 2026-05-12 (Tue W20). Total weekdays = 4 (Thu, Fri, Mon, Tue).
# 50/50 split: 2 days in W19, 2 days in W20. totalHoursBooked = 40 → 20/20.
bookings = [{
"id": "bk-span",
"task": "Spanning",
"startDate": date(2026, 5, 7),
"endDate": date(2026, 5, 12),
"resourceName": "Bhakti Doshi",
"projectNumber": "P-1",
"projectName": "Span",
"department": "Creative Team",
"division": "Production",
"hoursSelection": [],
"totalHoursBooked": 40.0,
"bookingStatus": "Active",
"placeholder": False,
}]
rows = summarise(
[],
bookings,
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 17),
period="week",
)
by_period = {r["period"]: r for r in rows if r["employee"] == "Bhakti Doshi"}
assert by_period["2026-W19"]["bookedHours"] == pytest.approx(20.0)
assert by_period["2026-W20"]["bookedHours"] == pytest.approx(20.0)
assert by_period["2026-W19"]["forecastHours"] == pytest.approx(20.0)
def test_employment_type_grouping(sample_resources, sample_logged):
rows = summarise(
sample_logged,
[],
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
period="week",
)
by_emp = {r["employee"]: r for r in rows if r["period"] == "2026-W19"}
assert by_emp["Bhakti Doshi"]["employmentType"] == "FTE"
assert by_emp["Jamie Freelance"]["employmentType"] == "Freelancer"
def test_partial_month_at_boundary(sample_resources):
"""If the requested window starts mid-week, available hours should pro-rate."""
# Window: 2026-05-06 (Wed) .. 2026-05-08 (Fri). 3 weekdays out of 5.
# availHoursPerWeek = 40 → available = 40 * 3/5 = 24.
rows = summarise(
[],
[],
[sample_resources[0]],
from_=date(2026, 5, 6),
to_=date(2026, 5, 8),
period="week",
)
bhakti = [r for r in rows if r["employee"] == "Bhakti Doshi"]
assert len(bhakti) == 1
assert bhakti[0]["availableHours"] == pytest.approx(24.0)
def test_filters_department(sample_resources, sample_logged, sample_bookings):
rows = summarise(
sample_logged,
sample_bookings,
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
filters={"department": "Creative Team"},
)
assert all(r["department"] == "Creative Team" for r in rows)
def test_filters_name(sample_resources):
rows = summarise(
[],
[],
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
filters={"name": "Bhakti"},
)
assert all("Bhakti" in r["employee"] for r in rows)
def test_zero_available_no_division_error(sample_resources):
# Drop avail to 0 → expect 0.0 percentages, not a crash.
resources = [dict(sample_resources[0], availHoursPerWeek=0.0)]
rows = summarise([], [], resources, from_=date(2026, 5, 4), to_=date(2026, 5, 8))
assert rows[0]["availableHours"] == 0.0
assert rows[0]["actualUtilisationPct"] == 0.0
assert rows[0]["bookedUtilisationPct"] == 0.0
def test_leave_hours_split_out_from_non_billable(sample_resources):
"""A row tagged 'Leave Hours' should land in leaveHours, not
nonBillableHours; billable client work stays in billableHours."""
# 8h leave + 32h client-related, all inside W19.
logged = [
{"date": date(2026, 5, 4), "employee": "Bhakti Doshi", "project": "PTO",
"task": "Annual leave", "hours": 8.0, "billable": False,
"billingType": "leave hours"},
{"date": date(2026, 5, 5), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 8.0, "billable": True,
"billingType": "client related"},
{"date": date(2026, 5, 6), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 8.0, "billable": True,
"billingType": "client related"},
{"date": date(2026, 5, 7), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 8.0, "billable": True,
"billingType": "client related"},
{"date": date(2026, 5, 8), "employee": "Bhakti Doshi", "project": "Acme",
"task": "Design", "hours": 8.0, "billable": True,
"billingType": "client related"},
]
rows = summarise(
logged,
[],
sample_resources,
from_=date(2026, 5, 4),
to_=date(2026, 5, 8),
period="week",
)
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
assert bhakti["leaveHours"] == 8.0
assert bhakti["billableHours"] == 32.0
assert bhakti["nonBillableHours"] == 0.0
assert bhakti["loggedHours"] == 40.0
# ---------------------------------------------------------------------------
# Active vs Soft split + totals
# ---------------------------------------------------------------------------
def test_active_vs_soft_split(sample_resources):
"""Bookings tagged 'Soft Booking' must land in softBookedHours; others active."""
bookings = [
{
"id": "bk-active", "task": "A", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-A", "projectName": "Active proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 20.0,
"bookingStatus": "Active", "placeholder": False,
},
{
"id": "bk-soft", "task": "S", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-S", "projectName": "Soft proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 12.0,
"bookingStatus": "Soft Booking", "placeholder": False,
},
]
rows = summarise(
[], bookings, sample_resources,
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
)
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
assert bhakti["activeBookedHours"] == pytest.approx(20.0)
assert bhakti["softBookedHours"] == pytest.approx(12.0)
assert bhakti["bookedHours"] == pytest.approx(32.0)
def test_unknown_status_defaults_to_active(sample_resources):
"""Null/unknown statuses fall through to active (not silently dropped)."""
bookings = [
{
"id": "bk-null", "task": "N", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-N", "projectName": "Null proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 15.0,
"bookingStatus": None, "placeholder": False,
},
{
"id": "bk-junk", "task": "J", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-J", "projectName": "Junk status proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 5.0,
"bookingStatus": "Pencilled In Tentatively", "placeholder": False,
},
]
rows = summarise(
[], bookings, sample_resources,
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
)
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
assert bhakti["activeBookedHours"] == pytest.approx(20.0)
assert bhakti["softBookedHours"] == 0.0
assert bhakti["bookedHours"] == pytest.approx(20.0)
def test_compute_totals(sample_resources, sample_bookings, sample_logged):
rows = summarise(
sample_logged, sample_bookings, sample_resources,
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
)
totals = compute_totals(
rows, sample_bookings,
from_=date(2026, 5, 4), to_=date(2026, 5, 8),
)
assert totals["totalBooked"] == pytest.approx(38.0)
assert totals["activeBooked"] == pytest.approx(38.0)
assert totals["softBooked"] == 0.0
assert totals["totalLogged"] == pytest.approx(35.0)
assert totals["totalBillable"] == pytest.approx(28.0)
assert totals["totalProjects"] == 1 # one distinct projectName
assert totals["activePeople"] == 1 # only Bhakti has loggedHours > 0
assert totals["activeBookingsCount"] == 1
assert totals["periodFrom"] == "2026-05-04"
assert totals["periodTo"] == "2026-05-08"
# allocated net of leave: avail (40 + 40 for two FTEs in W19) - 0 leave = 80
assert totals["allocatedNetOfLeave"] == pytest.approx(80.0)
def test_breakdown_by_project_logged_and_booked(sample_resources, sample_bookings, sample_logged):
out = breakdown_by_project(
sample_logged, sample_bookings,
period="2026-W19", employee="Bhakti Doshi",
)
assert out["period"] == "2026-W19"
assert out["employee"] == "Bhakti Doshi"
assert out["hasLogged"] is True
# Logged side: Acme (28h billable across 4 days) + Internal (7h non-billable)
logged_by_project = {row["project"]: row for row in out["logged"]}
assert logged_by_project["Acme"]["hours"] == pytest.approx(28.0)
assert logged_by_project["Acme"]["billable"] == pytest.approx(28.0)
assert logged_by_project["Internal"]["hours"] == pytest.approx(7.0)
assert logged_by_project["Internal"]["nonBillable"] == pytest.approx(7.0)
# Booked side: one booking on Acme Spring Launch = 38h, all active
booked_by_project = {row["project"]: row for row in out["booked"]}
assert booked_by_project["Acme Spring Launch"]["hours"] == pytest.approx(38.0)
assert booked_by_project["Acme Spring Launch"]["active"] == pytest.approx(38.0)
def test_breakdown_no_timelog(sample_resources, sample_bookings):
"""Empty timelog input: hasLogged should be False so the UI can prompt."""
out = breakdown_by_project([], sample_bookings, period="2026-W19", employee="Bhakti Doshi")
assert out["hasLogged"] is False
assert out["logged"] == []
# Booked side still works
assert any(row["project"] == "Acme Spring Launch" for row in out["booked"])
def test_breakdown_month_period(sample_resources, sample_bookings):
"""Month-level period label resolves to that month's day-range."""
out = breakdown_by_project([], sample_bookings, period="2026-05")
# The single sample booking falls inside May 2026.
assert any(row["project"] == "Acme Spring Launch" for row in out["booked"])
def test_breakdown_invalid_period_returns_empty(sample_bookings):
out = breakdown_by_project([], sample_bookings, period="not-a-period")
assert out["logged"] == [] and out["booked"] == []