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>
75 lines
3 KiB
Python
75 lines
3 KiB
Python
"""Tests for the Deliverable Summary parser."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from app.services.deliverable_parse import parse
|
|
|
|
|
|
def test_filters_d_rows_only():
|
|
csv = (
|
|
"Component,Project Number,Project Status,Deliverable Number,Deliverable Status,"
|
|
"Project Type (from OMG),Deliverable Start Date,Deliverable End Date,"
|
|
"Project Start Date,Project End Date,Brand,Business Division,"
|
|
"Business Area - Lv 2,Market,Deliverable Title\n"
|
|
"B,PRJ-1,PROGRESS,BR-1,APPROVED,BANNER PUSH,01/05/2026,02/05/2026,"
|
|
"01/04/2026,30/06/2026,LOR,Global,UK,UK,Brief desc\n"
|
|
"D,PRJ-1,PROGRESS,DEL-1,APPROVED,BANNER PUSH,01/05/2026,02/05/2026,"
|
|
"01/04/2026,30/06/2026,LOR,Global,UK,UK,First asset\n"
|
|
"D,PRJ-1,PROGRESS,DEL-2,APPROVED,BANNER PUSH,03/05/2026,04/05/2026,"
|
|
"01/04/2026,30/06/2026,LOR,Global,UK,UK,Second asset\n"
|
|
).encode("utf-8")
|
|
out = parse("deliv.csv", csv)
|
|
rows = out["rows"]
|
|
assert len(rows) == 2
|
|
assert all(r["projectNumber"] == "PRJ-1" for r in rows)
|
|
# Dates ISO-formatted.
|
|
assert rows[0]["deliverableStartDate"] == "2026-05-01"
|
|
assert rows[0]["projectStartDate"] == "2026-04-01"
|
|
assert rows[0]["projectStatus"] == "PROGRESS"
|
|
assert rows[0]["deliverableStatus"] == "APPROVED"
|
|
assert out["content_hash"].startswith("sha256:")
|
|
|
|
|
|
def test_skips_rows_missing_deliverable_dates():
|
|
csv = (
|
|
"Component,Project Number,Deliverable Status,Project Status,Project Type,"
|
|
"Deliverable Start Date,Deliverable End Date,Project Start Date,Project End Date,"
|
|
"Brand,Business Division,Market,Deliverable Title,Deliverable Number,Business Area - Lv 2\n"
|
|
"D,PRJ-A,APPROVED,PROGRESS,T,,,2026-01-01,2026-12-31,B,Glob,UK,Title,D-1,UK\n"
|
|
"D,PRJ-B,APPROVED,PROGRESS,T,2026-05-01,2026-05-31,2026-01-01,2026-12-31,B,Glob,UK,Title,D-2,UK\n"
|
|
).encode("utf-8")
|
|
out = parse("d.csv", csv)
|
|
assert len(out["rows"]) == 1
|
|
assert out["rows"][0]["projectNumber"] == "PRJ-B"
|
|
|
|
|
|
def test_unrecognised_columns_surface():
|
|
csv = (
|
|
"Component,Project Number,Deliverable Start Date,Deliverable End Date,"
|
|
"Wibble Factor\n"
|
|
"D,PRJ-X,2026-05-01,2026-05-15,42\n"
|
|
).encode("utf-8")
|
|
out = parse("u.csv", csv)
|
|
assert "Wibble Factor" in out["unrecognised_columns"]
|
|
|
|
|
|
def test_brand_defaults_to_unknown():
|
|
csv = (
|
|
"Component,Project Number,Deliverable Start Date,Deliverable End Date\n"
|
|
"D,PRJ-Z,2026-05-01,2026-05-15\n"
|
|
).encode("utf-8")
|
|
out = parse("d.csv", csv)
|
|
assert out["rows"][0]["brand"] == "Unknown"
|
|
assert out["rows"][0]["businessDivision"] == "Unknown"
|
|
assert out["rows"][0]["market"] == "Unknown"
|
|
assert out["rows"][0]["projectStatus"] == ""
|
|
|
|
|
|
def test_content_hash_stable():
|
|
csv = (
|
|
"Component,Project Number,Deliverable Start Date,Deliverable End Date\n"
|
|
"D,PRJ-Z,2026-05-01,2026-05-15\n"
|
|
).encode("utf-8")
|
|
a = parse("a.csv", csv)
|
|
b = parse("b.csv", csv)
|
|
assert a["content_hash"] == b["content_hash"]
|