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>
165 lines
5.7 KiB
Python
165 lines
5.7 KiB
Python
"""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
|