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>
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""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"] == []
|