loreal-utilisation-dept/backend/tests/test_forecast.py
DJP 993e370cea feat: Forecast, Project Type Summary, Time Log Detail, AI Chat, filters v2, stats bar, RBAC
Brings the new app to full parity with the original L'Oréal SPA and
beyond. Backend 59/59 tests (was 40, +19). Frontend typecheck/lint/build
clean. Main entry chunk 15.76 KB gz (budget 30 KB).

Backend — new endpoints + services:
- POST /api/deliverable/parse        — parse Deliverable Summary CSV/XLSX
- POST /api/projectsummary/parse     — parse Project Summary CSV/XLSX
- GET  /api/timelog/rows             — paginated, searchable, sortable view
                                        over the parsed Zoho upload
- GET  /api/forecast                 — 4-week pipeline + capacity decision
- GET  /api/project-types            — hours/asset, duration, concentration
                                        per project type + auto-insights
- POST /api/chat                     — Claude API proxy. 503s gracefully
                                        when ANTHROPIC_API_KEY is unset.
                                        Prompt-cached system prompt;
                                        rate-limited 20/min/IP.
- GET  /api/auth/me now returns role.

Backend — services:
- zoho_parse.py: extracts ~20 fields (brand, division, hub, userRole,
  projectType, assetCount, projectStatus, project start/end dates,
  userAgency, employingCompany, sageJobProfile, …) with back-compat
  aliases so existing callers keep working.
- parse_store.py: in-process TTL-cached registry of parsed uploads keyed
  by content hash. Lets endpoints reference an upload without re-sending it.
- forecast.py: working-day overlap math, exit-rate, weekly throughput
  baseline, capacity decision string mirroring the original wording.
- project_types.py: per-type aggregation + concentration-risk insights.
- timelog_filters.py: server-side filter by brands/divisions/hubs/roles.
- ai_context.py: builds the dashboard context block fed to Claude.

Frontend — new pages + components:
- pages/Forecast.tsx                 — ComposedChart (stacked bars + line)
                                        + capacity-decision banner + table
- pages/ProjectTypeSummary.tsx       — sortable table + small trend chart
- pages/TimeLogDetail.tsx            — virtualised, searchable, sortable
                                        view over all parsed timelog rows
- components/ChatView.tsx            — floating side panel with Claude.
                                        6 preset prompts mirroring the
                                        original. Visible only for roles
                                        with chat access.
- components/ChatToggle.tsx          — bottom-right FAB.
- components/StatsBar.tsx            — always-visible: Time Entries /
                                        People / Projects / Total Hours /
                                        Date Range.
- hooks/useDataContext.tsx           — single source of truth for filter
                                        state + parsed upload + filter
                                        dimensions (brands/divs/hubs/
                                        roles derived from uploads).

Frontend — modified:
- App.tsx, Navbar.tsx                — 7 tabs + role gating per the
                                        original TAB_ACCESS matrix.
- hooks/useAuth.tsx                  — role + canAccess(tab).
- lib/filters.ts, FilterBar.tsx      — Brand / Division / Hub / Role
                                        multiselects added (additive — keep
                                        Department / Name / Billing).
- pages/Department, Resourcing,
  Bookings, Tutorial.tsx             — wired into DataContext; tutorial
                                        is now a single 9-step global tour
                                        mirroring the original's narrative.

Config:
- backend/.env.example: ADMIN_ROLE, ANTHROPIC_API_KEY, ANTHROPIC_MODEL.

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

165 lines
5.7 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.

"""Tests for the forecast pipeline."""
from __future__ import annotations
from datetime import date
import pytest
from app.services.forecast import build_forecast
def _log(d: str, **kw) -> dict:
base = {
"date": date.fromisoformat(d),
"submitter": "Alice",
"submitterEmail": "alice@example.com",
"hoursLogged": 8.0,
"projectTitle": kw.get("project", "P1"),
"projectNumber": kw.get("projectNumber", "PRJ-1"),
"projectType": kw.get("projectType", "BANNER PUSH"),
"projectStartDate": kw.get("ps"),
"projectEndDate": kw.get("pe"),
"projectStatus": kw.get("status", "PROGRESS"),
"assetCount": kw.get("assets"),
}
# Allow kw to override defaults explicitly.
base.update({k: v for k, v in kw.items()
if k not in {"project", "projectNumber", "projectType", "ps", "pe", "status", "assets"}})
return base
def test_forecast_empty_inputs():
out = build_forecast(logs=[], weeks_ahead=4, from_date=date(2026, 5, 18))
assert len(out["weeks"]) == 4
assert all(w["activeAssets"] == 0.0 for w in out["weeks"])
assert out["totals"]["weeklyThroughput"] == 0.0
assert out["decision"].lower().startswith("no")
def test_forecast_active_assets_pro_rated():
"""A 4-asset project spanning all four forecast weeks should appear
in active count for each week, pro-rated by weekday overlap."""
logs = [
_log("2026-05-04", projectNumber="PRJ-1", project="P1",
ps="2026-05-18", pe="2026-06-12", assets=4.0),
]
out = build_forecast(
logs=logs,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=2,
)
assert len(out["weeks"]) == 4
# 4 weeks × 5 weekdays = 20 working days. Each forecast week has 5/20 = 25% share = 1 asset.
for w in out["weeks"]:
assert w["activeAssets"] == pytest.approx(1.0, abs=0.05)
def test_forecast_exiting_assets_only_in_exit_week():
"""Project ending in week 2 should show in exitingAssets only that week."""
logs = [
_log("2026-05-04", projectNumber="PRJ-2", project="P2",
ps="2026-05-18", pe="2026-05-29", assets=8.0),
]
out = build_forecast(
logs=logs,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=2,
)
# The project ends 2026-05-29 (Fri of W22). week 2 in the forecast is W22.
weeks = out["weeks"]
assert weeks[0]["exitingAssets"] == 0.0
# Find the week whose range contains 2026-05-29.
exit_week = [w for w in weeks if w["weekStart"] <= "2026-05-29" <= w["weekEnd"]]
assert len(exit_week) == 1
assert exit_week[0]["exitingAssets"] == 8.0
def test_forecast_decision_thresholds():
"""The decision string should reflect ratio of active vs capacity."""
# Low load → "OK to take on small briefs".
logs_light = [
_log(f"2026-04-{6+i:02d}", projectNumber=f"PR-{i}",
project=f"L{i}", ps=f"2026-04-{6+i:02d}", pe=f"2026-04-{10+i:02d}",
assets=10.0)
for i in range(8)
]
out = build_forecast(
logs=logs_light,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=10,
)
# No active projects in the future window → ratio = 0 → "OK".
assert "OK" in out["decision"] or "small briefs" in out["decision"]
def test_forecast_uses_project_summary_when_provided():
"""When projectSummary is supplied it wins over logs-derived data."""
logs = [
# Time log says project ends 2026-12-31 with 1 asset.
_log("2026-05-04", projectNumber="PRJ-9",
ps="2026-05-04", pe="2026-12-31", assets=1.0),
]
project_summary = [{
"projectNumber": "PRJ-9",
"projectTitle": "P9",
"projectType": "BANNER PUSH",
"projectStatus": "PROGRESS",
"projectStartDate": "2026-05-18",
"projectEndDate": "2026-05-22",
"assetCount": 99.0,
"division": "Local",
"brand": "X",
}]
out = build_forecast(
logs=logs,
project_summary=project_summary,
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=2,
)
# First week ends 2026-05-24 → contains all of the 2026-05-18..22 window.
first = out["weeks"][0]
# All 5 weekdays of project fit in 5 weekdays of week → 100% share = 99 assets.
assert first["activeAssets"] == pytest.approx(99.0, abs=0.5)
assert first["exitingAssets"] == 99.0
def test_forecast_dept_capacity_uses_hours_per_asset():
"""deptCapacityAssetsPerWeek = headcount × 40h / avgHoursPerAsset."""
# Project with 10 assets and 100 hours logged → 10h/asset.
logs = [
# 10h logged on 10 different days = 100h total against 10-asset project.
_log(f"2026-05-{4+i:02d}", projectNumber="PRJ-X",
project="X", ps="2026-05-04", pe="2026-05-15",
assets=10.0, hoursLogged=10.0)
for i in range(10)
]
out = build_forecast(
logs=logs,
weeks_ahead=4,
from_date=date(2026, 6, 1),
headcount=2,
)
# 2 people × 40h = 80h / 10h-per-asset = 8 assets/week.
assert out["totals"]["deptCapacityAssetsPerWeek"] == pytest.approx(8.0, abs=0.1)
for w in out["weeks"]:
assert w["deptCapacityAssetsPerWeek"] == pytest.approx(8.0, abs=0.1)
def test_forecast_can_take_on_clamped_zero():
"""canTakeOn = max(0, capacity - active). Never negative."""
out = build_forecast(
logs=[
_log("2026-05-04", projectNumber="P", ps="2026-05-18",
pe="2026-06-30", assets=1000.0, hoursLogged=1.0)
],
weeks_ahead=4,
from_date=date(2026, 5, 18),
headcount=1,
)
for w in out["weeks"]:
assert w["canTakeOn"] >= 0.0