diff --git a/CHANGELOG.md b/CHANGELOG.md index f16346bf..73f49097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Portfolio resourcing + +`GET /portfolio/resourcing` (`resource_portfolio.py`) sums weekly **concurrent** resource demand per +trade across projects. `?cap=` flags weeks where one trade is over-committed across the book and +names the competing projects. Rendered on Portfolio, with ⇄ marking the trades on more than one +project — the only ones that can be double-booked. + +**A trade on three jobs in the same week looks comfortable on every one of them.** That is what a +per-project histogram cannot show, and it is the whole reason for the endpoint. +`test_resource_portfolio.py` proves it rather than asserting it: two projects at 6 units each are +each under a cap of 8, verified by calling their own `/schedule/resource-loading?cap=8` and getting +nothing back, while the book reports 12 over the same cap. + +**"By department" turned out to be the wrong shape.** `resource_assignment.trade` is labelled +"Trade / discipline", and no `department` field exists anywhere in the backend. A department axis is +a product decision, not a filter over data we hold — raised in the roadmap rather than invented. + +**Fidelity is reported, not blended.** A project with no assignments falls back to activity +`crew_size` — a crew count, not a resourced plan — so every row carries its `source` and `fidelity` +gives the split. + +The two `over_allocation` shapes are **not** interchangeable and both docstrings say so: this one +caps per trade across the book; `resource_loading`'s caps one project's total weekly units. + +Found on the way in: the field named `resourced` made `test_route_reachability` report +`/schedule/eot/sourced` as called, because that gate matches route leaves as substrings and +`resourced` contains `sourced`. Renamed to `assigned`; the second instance of that collision is +recorded in the gate's own notes. + ## Unreleased — Cross-project Gantt The Programme card (`/projects/{pid}/schedule/portfolio`) now draws a bar per project on a shared diff --git a/apps/web/src/api/schedule.ts b/apps/web/src/api/schedule.ts index 865ce637..9d586205 100644 --- a/apps/web/src/api/schedule.ts +++ b/apps/web/src/api/schedule.ts @@ -153,6 +153,37 @@ export function withSchedule>(Base: TBase) { over_allocation: { week: string; units: number; cap: number | null }[]; note: string }>( `/projects/${pid}/schedule/resource-loading${cap != null ? `?cap=${cap}` : ""}`); } + /** The same weekly demand summed **across** projects — R22-PIPELINE's portfolio resourcing axis. + * + * `resourceLoading` above answers one project, and a trade committed to three jobs in the same + * week looks comfortable on every one of them. Note the two `over_allocation` shapes are NOT + * interchangeable: this one caps **per trade** across the book and names the competing projects, + * while `resourceLoading`'s caps a single project's **total** weekly units. + * + * `fidelity` is not decoration — a project with no resource assignments falls back to activity + * `crew_size`, which is a crew count rather than a resourced plan, and a book of fallbacks must + * not read as a resourced one. */ + portfolioResourcing(opts: { cap?: number; limit?: number; weeks?: number } = {}) { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(opts)) if (v != null) q.set(k, String(v)); + return this.json<{ + available: boolean; reason?: string; + projects: { id: string; name: string; source: string; loads: number; trades: string[]; + unit_weeks: number; cost: number }[]; + projects_without_loads: { id: string; name: string; reason: string }[]; + trades: { trade: string; peak_units: number; peak_week: string | null; unit_weeks: number; + cost: number; project_count: number; cross_project: boolean }[]; + weeks: { week: string; total: number; by_trade: Record }[]; + week_span?: { start: string; finish: string; count: number; shown: number }; + peak: { week: string; units: number } | null; + over_allocation: { week: string; trade: string; units: number; cap: number; + projects: Record }[]; + cap: number | null; + fidelity: { by_source: Record; assigned: number; fallback: number; + note?: string }; + project_count: number; projects_available: number; truncated: boolean; note?: string; + }>(`/portfolio/resourcing${q.toString() ? `?${q}` : ""}`); + } /** Resource-leveling advisory: over-allocated work with CPM float that can be smoothed within float. */ resourceLeveling(pid: string, cap: number) { return this.json<{ cap: number; peak: { week: string | null; units: number }; over_weeks: number; diff --git a/apps/web/src/portal/panels/portfolio.ts b/apps/web/src/portal/panels/portfolio.ts index c189afe2..c7874062 100644 --- a/apps/web/src/portal/panels/portfolio.ts +++ b/apps/web/src/portal/panels/portfolio.ts @@ -142,6 +142,55 @@ export async function renderPortfolio(ctx: PanelContext) { ctx.root.appendChild(card); }).catch(() => { /* returns spread is best-effort; the roll-up above stands on its own */ }); + // RESOURCING ACROSS THE BOOK — R22-PIPELINE's last item. The per-project resource histogram + // already exists; what it cannot show is a trade committed to three jobs in the same week, + // because that trade looks comfortable on every one of them. Same shape as the cross-project + // Gantt: the thing only visible once you sum. + // + // `trade` is the dimension the schema carries (`resource_assignment.trade`, labelled + // "Trade / discipline"). There is no department field anywhere, so department reporting is a + // product decision rather than a filter — recorded in the roadmap, not invented here. + void ctx.host.api.portfolioResourcing().then((rp) => { + if (!rp.available || !rp.trades.length) return; + const card = document.createElement("div"); card.className = "dash-card"; card.style.marginTop = "10px"; + const f = rp.fidelity; + card.innerHTML = `Resourcing across the book ` + + `${rp.projects.length} project(s) · ${rp.trades.length} trade(s)` + + (rp.peak ? ` · peak ${rp.peak.units} concurrent units in ${esc(rp.peak.week)}` : "") + + (f.fallback ? ` · ${f.assigned} assigned / ${f.fallback} from crew counts` : "") + + (rp.truncated ? ` · showing ${rp.project_count} of ${rp.projects_available}` : "") + + ``; + const tbl = document.createElement("table"); tbl.className = "portal-table"; tbl.style.fontSize = "11px"; + tbl.innerHTML = `Trade / discipline` + + `PeakPeak week` + + `Projects` + + `Unit-weeks`; + const tb = document.createElement("tbody"); + for (const t of rp.trades) { + const tr = document.createElement("tr"); + // A trade on more than one project is the only kind that CAN be double-booked, so it is + // the only kind worth colouring — this is a fact from the data, not a severity guess. + const col = t.cross_project ? "var(--status-warn)" : "var(--muted)"; + tr.innerHTML = `${esc(t.trade)}${t.cross_project ? ` ` : ""}` + + `${t.peak_units}` + + `${esc(t.peak_week ?? "—")}` + + `${t.project_count}` + + `${t.unit_weeks}`; + tb.appendChild(tr); + } + tbl.appendChild(tb); card.appendChild(tbl); + if (rp.projects_without_loads.length) { + const u = document.createElement("div"); u.className = "meta"; u.style.marginTop = "4px"; + u.textContent = "No resourcing data — " + rp.projects_without_loads + .map((x) => esc(x.name)).join(", "); + card.appendChild(u); + } + card.appendChild(Object.assign(document.createElement("div"), { className: "meta", + textContent: "⇄ marks a trade committed to more than one project — the only kind that can be " + + "double-booked. Peak is concurrent units summed across the book in its busiest week." })); + ctx.root.appendChild(card); + }).catch(() => { /* resourcing is best-effort — no assignments in this deployment */ }); + // RISK HEAT MAP — R22-PIPELINE. The table above says how each project is PERFORMING; this says // which risk ENGINE is hot on which project, which is the question that decides where a // programme director spends the morning. Cells come from the same `risk_board` each project's diff --git a/docs/roadmap.md b/docs/roadmap.md index c8b44b62..ffa3f363 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1910,9 +1910,35 @@ stakes we are missing. *Cost of the premise-check: one grep. Cost of believing the entry: a scheduling engine.* - **Still open: department resourcing.** The resourcing half is not the small - item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**, - per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own. + ✅ **Portfolio resourcing SHIPPED — `GET /portfolio/resourcing`, + `services/api/src/aec_api/resource_portfolio.py`.** Weekly CONCURRENT demand per trade, summed + across the book. `?cap=` flags the weeks where one trade is over-committed **across projects** and + names which projects are competing for it. `services/api/test_resource_portfolio.py` pins the + claim that only a cross-project view can make: two projects at 6 units each are both under a cap + of 8, and together they are not — asserted by calling each project's own + `/schedule/resource-loading?cap=8` and confirming it reports nothing. + + **"By department" was the wrong shape, and the schema says so.** `resource_assignment.trade` is + labelled **"Trade / discipline"**, and the word "department" appears nowhere in the backend except + a comment in `rooms.py` and a fire-department scope clause. So a department axis is **a product + decision** — what is a department that a trade is not? field-vs-office for a GC, or + Architecture / Structural / MEP for a design firm — **not a filter over data we hold.** Raised + rather than invented: a dimension nobody has defined cannot be reported honestly. **The portfolio + axis was the half that mattered and it needed no new field.** + + **Fidelity is reported, not blended.** `resource_loading` falls back to + `schedule_activity.crew_size` when a project has no assignments; that is a crew count, not a + resourced plan. Every project row carries its `source` and `fidelity` gives the split, so a book + of fallbacks cannot read as a resourced one — the heat map's rule one step along: *do not let a + lower-fidelity value wear the costume of a higher-fidelity one.* + + ⚠️ **A gate caught something on the way in, and it was a WORD.** Adding this put the field + `fidelity.resourced` into the web source, and `sourced` is the leaf of `/schedule/eot/sourced`, so + `test_route_reachability` reported that frozen-uncalled route as called. Renamed to `assigned`. + The second instance of a class that file already records; the note there explains why the matcher + is not the thing to change. + + **R22-PIPELINE is now closed apart from the department question above, which is the user's.** ## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)* **A THIRD false blocker, and the biggest one.** **W10-9 dimensional constraints** has sat gated for diff --git a/services/api/run_tests.py b/services/api/run_tests.py index 60247e1d..567b603a 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -86,7 +86,7 @@ "test_markup", "test_route_authz", "test_resource_id_authz", "test_route_reachability", "test_resumable_upload", "test_model_align", "test_ifc_parse_gate", "test_plugin_isolation", "test_body_pid_authz", "test_global_authz", "test_protected_prefix_coverage", "test_baseline", "test_global_mutating_authz", "test_ref_counter", "test_audit_coverage", "test_bsdd", "test_openbim_registry", "test_waterfall", "test_waterfall_cents", "test_sessions", "test_mfa", "test_stored_ids", "test_cobie", "test_fts_index", "test_scim", "test_scim_provision_race", "test_saml", "test_responsibility", "test_array_live", "test_assemblies", "test_dxf_takeoff", "test_qto_class_match", "test_georef", "test_scene_package", "test_clash_bvh", "test_model_qa", "test_model_health", "test_roundtrip_qa", "test_stakeholder", "test_prioritization", "test_ai_readiness", - "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_risk_portfolio", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", + "test_scan_deviation", "test_plan_to_bim", "test_errorlog", "test_import_cycles", "test_tenant_scoping", "test_schedule_risk_single", "test_carbon_compliance", "test_permit_check", "test_drawing_qa", "test_element_5d", "test_authoring_matrix", "test_option_missing", "test_option_score", "test_plugin_registry", "test_jobs", "test_clash_federated_job", "test_inbox_jobs", "test_job_kind_labels", "test_worker_split", "test_job_orphan_scope", "test_job_stall", "test_pid_lock_xproc", "test_pid_lock_surface", "test_sheet_layout", "test_dim_component", "test_sheet_recover", "test_firm_standards", "test_site_context", "test_risk_board", "test_risk_portfolio", "test_env_wind", "test_model_options", "test_doc_text", "test_escalation", "test_query_dsl", "test_rule_library", "test_schedule_baselines", "test_model_ci", "test_xlsx_roundtrip", "test_geometric_rules", "test_rebar_rules", "test_cx", "test_distwaterfall", "test_cap_table_state", "test_license_cloud", "test_smart_views", "test_view_delete", "test_version_approve_identity", "test_upload_streaming", "test_lod_aspects", "test_lod_element_table", "test_publish_reconvert", "test_model_cache_seed", "test_model_cache_mutation", "test_mutating_readers", "test_adopt_guid", "test_ifcpatch", "test_bcf_api", "test_coordination_fresh", "test_assemblies_cost", "test_fem_export", "test_subset_export", "test_norm_valid", "test_schema_diag", "test_revision_delta", "test_bep", "test_pm_close", "test_itp", "test_quality_chain", "test_quality_chain_route", "test_meeting_links", "test_est_bands", "test_scope_gap", "test_golden_thread", "test_clash_xml_import", "test_gis_out", "test_cbs", "test_mep_graph", "test_model_warnings", "test_schedule_options", "test_master_builder", "test_master_builder_scope", "test_get_commits", "test_project_pulse", "test_client_portal", "test_selections", "test_margin", "test_model_assets", "test_macros", "test_layout_options", "test_equipment", "test_space_util", "test_design_metrics", "test_mep_fittings", "test_prod_actuals", "test_pipeline_allocate", "test_resource_portfolio", "test_production", "test_procure_level", "test_adjacency", "test_supply_chain", "test_invisible_unicode", "test_cited_answer", "test_est_confidence", "test_buyout_schedule", "test_scope_register", "test_permit_timeline", "test_absorption", "test_progress_rollup", "test_fill_matrix", "test_parcel_geometry", "test_assembly_thermal", "test_portal_txn", "test_persona_answer", "test_boe_ledger", "test_assumption_provenance", "test_assumption_provenance_route", "test_concept_budget", "test_topic_board", "test_roof_window", "test_topic_lifecycle", "test_comment_promote", "test_artifact_deliver", "test_calc_fields", "test_constraints", "test_element_lookup", "test_cli", "test_view_templates", "test_type_catalogs", "test_password_policy", "test_stepup_single_verifier", "test_fin_gov", "test_fin_calc", "test_fin_ingest", "test_fin_portfolio", "test_level_move", "test_instance_props", "test_roundtrip", "test_wall_joins", "test_composite_family", "test_shared_params", "test_version_values", "test_ifcpatch_transforms", "test_bcf3", "test_energy_export", "test_net_effective", "test_cre_deal_desk", "test_cre_governance", "test_cre_tier3", "test_family_geometry", "test_demo_seed", "test_cost_spine", "test_commercial_drift", "test_family_shapes", "test_workflow_config", "test_option_takeoff", "test_option_carbon", "test_option_carbon_route", "test_option_economics", "test_option_economics_route", "test_option_object", "test_option_object_route", "test_family_coverage", "test_section_annotation", "test_lod500_readiness", "test_scan_to_lod500", "test_egress_routes", "test_status_workflow_parity", "test_section_hatch", "test_section_keynotes", "test_detail_refs", "test_vg_overrides", "test_revit_export_cfg", "test_soft_clash", "test_sequence_clash", "test_element_tags", "test_cost_ifc", "test_fived", "test_health_consistency", "test_module_rooms", "test_modules_response_complete", "test_lifecycle_strip", "test_family_merge", "test_element_facts", "test_consistency", "test_work_queue", "test_task_bind", "test_qto_wire", "test_estimate_diff", "test_dim_constraints", "test_sov_build", "test_takeoff_scope", "test_r37_wire_routes", "test_r37_consolidate", "test_r37_contract", "test_export_promises", "test_pdf_ingest_gate", "test_roadmap_status", "test_claim_type", "test_risk_calibrate", "test_schedule_status", "test_engine_routes", "test_reachable", "test_money_wire", "test_license_gate", "test_license_lock_gate", "test_lock_advisories", "test_npm_advisories", "test_perf_budget", "test_perf_rate", "test_cache_key", "test_oauth_providers", "test_qto_measured_area", "test_lod_census", "test_lod_proxy", "test_model_ensure", "test_support_graph", "test_export_colour_stable", "test_stair_ramp", "test_profile_dims", "test_eot", "test_eot_methods", "test_eot_sourced", "test_shared_model", "test_plan_identity", "test_axon_view", "test_view_kind_dispatch", "test_photo_cv", "test_photo_detect", "test_photo_duplicate", "test_pipeline_scales", "test_plan_pins", "test_plan_cut_quality", "test_pins_unified", "test_index_freshness", "test_bake_budget", "test_geom_slots", "test_bake_shared", "test_geo_ref", "test_asset_verify", "test_folder_owner", "test_file_sizes", "test_declared_imports", "test_ruff_scope", "test_delete_ratchet", "test_doc_substance", "test_claude_md_gates", "test_cors_expose_headers", "test_open_redirect", "test_mp_engine", "test_upload_cap", "test_vitals", "test_samples", "test_bundle_index", # R41-TEST-RESIDUE — the residue sweep must never propose a database it does not own: "test_sweep_guard", # R23-DIGEST — the deterministic model digest and its two routes: diff --git a/services/api/src/aec_api/resource_portfolio.py b/services/api/src/aec_api/resource_portfolio.py new file mode 100644 index 00000000..28dc8f0c --- /dev/null +++ b/services/api/src/aec_api/resource_portfolio.py @@ -0,0 +1,170 @@ +"""RESOURCE-PORTFOLIO — weekly resource demand summed ACROSS projects, the last R22-PIPELINE item. + +## What the roadmap asked for, and what it turned out to be + +That entry asks for "resource allocation by department", and says it needs a new dimension plus a +portfolio axis. Half of that survives contact with the schema: + +* **The dimension already exists and is not called `department`.** `modules/resource_assignment/module.json` + carries `trade`, labelled **"Trade / discipline"**, and the word "department" appears nowhere in + the backend except a comment in `rooms.py` and a fire-department scope clause. So a separate + department axis is a *product decision* about what a department would be that a trade is not — + field-vs-office for a GC, or Architecture/Structural/MEP for a design firm — not a build task. It + is raised rather than invented here: a dimension nobody has defined cannot be reported honestly. +* **The portfolio axis is real, and is the half that matters.** `resource_loading.loading` answers + one project, and **a trade over-committed across three jobs looks comfortable on every one of + them.** That is the same shape as the cross-project Gantt's finding — a project can look fine + alone and be critical to the programme — and it is the actual question a resourcing conversation + starts from: *are my ironworkers promised to two sites in the same week?* + +## Fidelity is reported, not blended + +`resource_loading._loads` prefers real `resource_assignment` records and **falls back to +`schedule_activity.crew_size`** when a project has none. Those are not the same quality of number: +one is a resourced plan, the other is a crew count on an activity. Summing them into one book-wide +histogram without saying so would let a portfolio built mostly of fallbacks read as though it were +resourced. So every project row carries its `source`, and `fidelity` reports the split — the same +rule the risk heat map applies to an unmeasured cell, one step along: *do not let a lower-fidelity +value wear the costume of a higher-fidelity one.* + +A project contributing no loads at all is listed in `projects_without_loads`, never silently absent. +""" +from __future__ import annotations + +from typing import Any + +DEFAULT_LIMIT = 25 +#: Weeks returned around the peak when the caller does not ask for the whole span. A book can span +#: years; the answer to "where am I over-committed" lives in a handful of weeks. +DEFAULT_WEEKS = 26 + + +def portfolio(db: Any, projects: list[tuple[str, str]], *, cap: float | None = None, + limit: int = DEFAULT_LIMIT, weeks: int = DEFAULT_WEEKS) -> dict[str, Any]: + """Weekly demand per trade, summed across `projects` — a list of `(id, name)` already scoped to + the caller. `cap` flags weeks where a single trade's concurrent units across the whole book + exceed it. Bounded by `limit`; `truncated` says when the sweep did not cover everything.""" + # `_loads` and `_weeks` are `resource_loading`'s own helpers, reached across the module + # boundary deliberately. Re-implementing the normalisation here is the alternative, and it is + # the worse one: the fallback rule, the rate-vs-budgeted-cost choice and the Monday-aligned week + # buckets would then exist twice and could drift, so the portfolio total and the project's own + # histogram could disagree about the same crew. Same reason the risk heat map calls `board` + # unchanged. The leading underscore marks them private to the package, not unusable within it. + from . import resource_loading + + scanned = projects[:max(0, int(limit))] + rows: list[dict[str, Any]] = [] + without: list[dict[str, str]] = [] + # week -> trade -> {units, cost, projects:{pid}} + grid: dict[str, dict[str, dict[str, Any]]] = {} + by_source: dict[str, int] = {} + + for pid, name in scanned: + try: + loads, source = resource_loading._loads(db, pid) + except Exception: # noqa: BLE001 — one unreadable project must not blank the book + without.append({"id": pid, "name": name, "reason": "loads could not be read"}) + continue + if not loads: + without.append({"id": pid, "name": name, "reason": "no resource assignments or crew-loaded activities"}) + continue + by_source[source] = by_source.get(source, 0) + 1 + p_units = p_cost = 0.0 + trades: set[str] = set() + for ld in loads: + wk_list = resource_loading._weeks(ld["start"], ld["finish"]) + if not wk_list: + continue + per_week_cost = (ld["cost"] or 0.0) / len(wk_list) + for wk in wk_list: + cell = grid.setdefault(wk.isoformat(), {}).setdefault( + ld["trade"], {"units": 0.0, "cost": 0.0, "projects": {}}) + # Units are CONCURRENT: a resource on two projects in one week is demanded twice, + # which is the entire point of summing across the book rather than per project. + cell["units"] += ld["units"] + cell["cost"] += per_week_cost + cell["projects"][pid] = round(cell["projects"].get(pid, 0.0) + ld["units"], 2) + trades.add(ld["trade"]) + p_units += ld["units"] * len(wk_list) + p_cost += ld["cost"] or 0.0 + rows.append({"id": pid, "name": name, "source": source, "loads": len(loads), + "trades": sorted(trades), "unit_weeks": round(p_units, 1), + "cost": round(p_cost, 2)}) + + if not grid: + return {"available": False, + "reason": "no project in range has resource assignments or crew-loaded activities", + "projects": rows, "projects_without_loads": without, + "weeks": [], "trades": [], "peak": None, "over_allocation": [], + "fidelity": {"by_source": by_source, "assigned": 0, "fallback": 0}, + "cap": cap, "project_count": len(scanned), "projects_available": len(projects), + "truncated": len(projects) > len(scanned)} + + all_weeks = sorted(grid) + # Per-trade peak first, because the window is chosen around the book's busiest week and a + # window chosen before the peak is known can exclude the answer. + totals: dict[str, dict[str, Any]] = {} + for wk, by_trade in grid.items(): + for tr, cell in by_trade.items(): + t = totals.setdefault(tr, {"trade": tr, "peak_units": 0.0, "peak_week": None, + "unit_weeks": 0.0, "cost": 0.0, "projects": set()}) + t["unit_weeks"] += cell["units"] + t["cost"] += cell["cost"] + t["projects"].update(cell["projects"]) + if cell["units"] > t["peak_units"]: + t["peak_units"] = cell["units"]; t["peak_week"] = wk + + book = [(wk, round(sum(c["units"] for c in by_trade.values()), 1)) + for wk, by_trade in ((w, grid[w]) for w in all_weeks)] + peak_wk, peak_units = max(book, key=lambda x: (x[1], x[0])) + i = all_weeks.index(peak_wk) + half = max(1, int(weeks) // 2) + lo, hi = max(0, i - half), min(len(all_weeks), i + half) + window = all_weeks[lo:hi] + + over = [] + if cap: + for wk in all_weeks: + for tr, cell in sorted(grid[wk].items()): + if cell["units"] > cap: + over.append({"week": wk, "trade": tr, "units": round(cell["units"], 1), + "cap": cap, + # Named, because "who is double-booked" is the actionable half. + "projects": dict(sorted(cell["projects"].items()))}) + + trade_rows = sorted( + ({"trade": t["trade"], "peak_units": round(t["peak_units"], 1), "peak_week": t["peak_week"], + "unit_weeks": round(t["unit_weeks"], 1), "cost": round(t["cost"], 2), + "project_count": len(t["projects"]), + # A trade on more than one project is the one that can be double-booked. + "cross_project": len(t["projects"]) > 1} for t in totals.values()), + key=lambda r: (-r["peak_units"], r["trade"])) + assigned = by_source.get("resource_assignment", 0) + fallback = by_source.get("schedule_activity.crew_size", 0) + return { + "available": True, + "projects": sorted(rows, key=lambda r: (-r["unit_weeks"], r["name"])), + "projects_without_loads": without, + "trades": trade_rows, + "weeks": [{"week": wk, "total": round(sum(c["units"] for c in grid[wk].values()), 1), + "by_trade": {t: round(c["units"], 1) for t, c in sorted(grid[wk].items())}} + for wk in window], + "week_span": {"start": all_weeks[0], "finish": all_weeks[-1], "count": len(all_weeks), + "shown": len(window)}, + "peak": {"week": peak_wk, "units": peak_units}, + "over_allocation": over, + "cap": cap, + "fidelity": {"by_source": by_source, "assigned": assigned, "fallback": fallback, + "note": "`resource_assignment` is a resourced plan; `schedule_activity.crew_size` " + "is a crew count on an activity. Both are summed, and the split is " + "reported rather than blended — a book of fallbacks must not read as " + "a resourced one."}, + "project_count": len(scanned), + "projects_available": len(projects), + "truncated": len(projects) > len(scanned), + "note": "Weekly CONCURRENT demand per trade summed across projects. A trade committed to " + "several jobs in one week is over-committed even when every project looks " + "comfortable alone, which is what a per-project view cannot show. `trade` is the " + "dimension the schema carries (labelled 'Trade / discipline'); there is no " + "department field, so department reporting is a product decision, not a filter.", + } diff --git a/services/api/src/aec_api/routers/dashboard.py b/services/api/src/aec_api/routers/dashboard.py index eb55831d..09d39c22 100644 --- a/services/api/src/aec_api/routers/dashboard.py +++ b/services/api/src/aec_api/routers/dashboard.py @@ -171,6 +171,38 @@ def portfolio_risk(limit: int = 25, db: Session = Depends(get_db), return risk_portfolio.heatmap(db, projects, limit=max(1, min(int(limit), 100))) +@router.get("/portfolio/resourcing") +def portfolio_resourcing(cap: float | None = None, limit: int = 25, weeks: int = 26, + db: Session = Depends(get_db), _: str = Depends(rbac.current_user)): + """R22-PIPELINE — weekly resource demand per trade, summed **across** projects. + + `/projects/{pid}/schedule/resource-loading` answers one project, and a trade committed to three + jobs in the same week looks comfortable on every one of them. This sums concurrent demand over + the book, so `?cap=` flags the weeks where a single trade is over-committed **across** projects + and names which projects are competing for it. + + `trade` is the dimension the schema carries — `resource_assignment.trade` is labelled + "Trade / discipline". There is no `department` field anywhere, so department reporting is a + product decision about what a department would be that a trade is not, not a filter over + existing data. + + Fidelity is reported, not blended: a project with no `resource_assignment` records falls back to + `schedule_activity.crew_size`, which is a crew count rather than a resourced plan, and + `fidelity` says how much of the book is which. + """ + from .. import resource_portfolio + _allowed = rbac.member_project_ids(db, _) # membership scope (None = no restriction) + _q = db.query(Project) + if _allowed is not None: + _q = _q.filter(Project.id.in_(_allowed)) + # (name, id): `Project.name` is not unique, so name alone leaves tied rows in engine order and + # the truncated prefix could differ run to run. Same fix as `/portfolio/risk`. + projects = [(p.id, p.name) for p in _q.order_by(Project.name, Project.id).all()] + return resource_portfolio.portfolio( + db, projects, cap=cap, limit=max(1, min(int(limit), 100)), + weeks=max(2, min(int(weeks), 260))) + + @router.get("/portfolio/prioritization") def portfolio_prioritization(db: Session = Depends(get_db), user: str = Depends(rbac.current_user)): """Ranked portfolio prioritization — scores each accessible project 0–100 on return / on-budget / diff --git a/services/api/test_resource_portfolio.py b/services/api/test_resource_portfolio.py new file mode 100644 index 00000000..2d37e1d5 --- /dev/null +++ b/services/api/test_resource_portfolio.py @@ -0,0 +1,130 @@ +"""RESOURCE-PORTFOLIO — weekly resource demand summed across projects (`GET /portfolio/resourcing`). + +The behaviour worth pinning is the one a per-project view cannot show: **a trade committed to two +jobs in the same week is over-committed even though neither project exceeds the cap on its own.** +That is the whole reason this endpoint exists, so it is asserted with a cap that each project sits +under and the pair does not. + +Also pinned: fidelity is reported rather than blended. `resource_loading` falls back to +`schedule_activity.crew_size` when a project has no `resource_assignment` records, and a book of +fallbacks must not read as a resourced one. + +Run: PYTHONPATH=src ./.venv/bin/python test_resource_portfolio.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_resource_portfolio.db" +os.environ["STORAGE_DIR"] = "./test_storage_resource_portfolio" +os.environ["AEC_TRUST_XUSER"] = "1" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_resource_portfolio.db",): + if os.path.exists(_f): + os.remove(_f) + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.main import app # noqa: E402 + +HDR = {"X-User": "pm"} +WK = "2026-04-06" # a Monday, so the week bucket is unambiguous +WK_END = "2026-04-10" + + +def _act(c, pid, name, start, finish, crew=None): + d = {"name": name, "wbs": name, "duration": 5, "start": start, "finish": finish} + if crew: + d["crew_size"] = crew + r = c.post(f"/projects/{pid}/modules/schedule_activity", json={"data": d}, headers=HDR) + assert r.status_code == 201, r.text[:200] + return r.json()["id"] + + +def _assign(c, pid, act, trade, units, start, finish, rate=100.0): + r = c.post(f"/projects/{pid}/modules/resource_assignment", json={"data": { + "resource_name": f"{trade} crew", "resource_type": "Labor", "trade": trade, + "activity": act, "units": units, "unit": "day", "rate": rate, + "start": start, "finish": finish}}, headers=HDR) + assert r.status_code == 201, r.text[:200] + + +with TestClient(app) as c: + a = c.post("/projects", json={"name": "AAA Tower"}, headers=HDR).json()["id"] + b = c.post("/projects", json={"name": "BBB Annex"}, headers=HDR).json()["id"] + quiet = c.post("/projects", json={"name": "CCC Empty"}, headers=HDR).json()["id"] + + g = c.post("/projects", json={"name": "DDD Glass"}, headers=HDR).json()["id"] + # Same trade, same week, on two different projects — 6 units each. + _assign(c, a, _act(c, a, "1.1", WK, WK_END), "Ironworkers", 6, WK, WK_END) + _assign(c, b, _act(c, b, "2.1", WK, WK_END), "Ironworkers", 6, WK, WK_END) + # A trade on ONE project only, for the cross_project contrast. It lives on its OWN project + # rather than beside the ironworkers, because `resource_loading` caps a project's TOTAL weekly + # units while this endpoint caps PER TRADE — putting both trades on one project would make that + # project breach its own cap on the sum and destroy the like-for-like comparison below. The two + # over-allocation figures answer different questions and are not interchangeable. + _assign(c, g, _act(c, g, "3.1", WK, WK_END), "Glaziers", 3, WK, WK_END) + + r = c.get("/portfolio/resourcing", headers=HDR) + assert r.status_code == 200, r.text[:300] + p = r.json() + assert p["available"] is True, p.get("reason") + assert p["project_count"] == 4 and p["projects_available"] == 4 and not p["truncated"], p + + # --- the book sums CONCURRENT demand across projects ------------------------------------------ + wk = next(w for w in p["weeks"] if w["week"] == WK) + assert wk["by_trade"]["Ironworkers"] == 12.0, wk # 6 + 6, not 6 + assert wk["by_trade"]["Glaziers"] == 3.0, wk + assert wk["total"] == 15.0, wk + + iron = next(t for t in p["trades"] if t["trade"] == "Ironworkers") + glaz = next(t for t in p["trades"] if t["trade"] == "Glaziers") + assert iron["peak_units"] == 12.0 and iron["peak_week"] == WK, iron + assert iron["project_count"] == 2 and iron["cross_project"] is True, iron + assert glaz["project_count"] == 1 and glaz["cross_project"] is False, glaz + assert p["trades"][0]["trade"] == "Ironworkers", p["trades"] # sorted by peak + assert p["peak"] == {"week": WK, "units": 15.0}, p["peak"] + + # --- THE POINT: over-committed across the book while fine on each project --------------------- + # cap=8 — each project asks for 6, so neither is over on its own. Together they are. + over = c.get("/portfolio/resourcing?cap=8", headers=HDR).json()["over_allocation"] + assert len(over) == 1, over + assert over[0]["trade"] == "Ironworkers" and over[0]["week"] == WK, over[0] + assert over[0]["units"] == 12.0 and over[0]["cap"] == 8.0, over[0] + # and it names WHO is competing, which is the actionable half + assert set(over[0]["projects"]) == {a, b}, over[0]["projects"] + assert over[0]["projects"][a] == 6.0 and over[0]["projects"][b] == 6.0, over[0]["projects"] + + # each project ALONE is under the same cap — the claim above, verified rather than asserted + for pid in (a, b, g): + solo = c.get(f"/projects/{pid}/schedule/resource-loading?cap=8", headers=HDR).json() + assert solo["over_allocation"] == [], (pid, solo["over_allocation"]) + + # --- a project with no loads is named, never silently absent ----------------------------------- + assert [x["id"] for x in p["projects_without_loads"]] == [quiet], p["projects_without_loads"] + assert "no resource assignments" in p["projects_without_loads"][0]["reason"] + assert {x["id"] for x in p["projects"]} == {a, b, g}, p["projects"] + + # --- fidelity is reported, not blended -------------------------------------------------------- + assert p["fidelity"]["assigned"] == 3 and p["fidelity"]["fallback"] == 0, p["fidelity"] + for row in p["projects"]: + assert row["source"] == "resource_assignment", row + + # a project with crew-loaded activities and NO assignments contributes on the fallback source, + # and the split says so — a book of fallbacks must not read as a resourced one + d = c.post("/projects", json={"name": "EEE Crewed"}, headers=HDR).json()["id"] + _act(c, d, "4.1", WK, WK_END, crew=4) + p2 = c.get("/portfolio/resourcing", headers=HDR).json() + assert p2["fidelity"]["assigned"] == 3 and p2["fidelity"]["fallback"] == 1, p2["fidelity"] + drow = next(x for x in p2["projects"] if x["id"] == d) + assert drow["source"] == "schedule_activity.crew_size", drow + assert "resourced plan" in p2["fidelity"]["note"] + + # --- refusal is well-formed when nothing in range has loads ----------------------------------- + # limit=1 scans only "AAA Tower"… which has loads, so instead prove the shape on a fresh book: + empty = c.get("/portfolio/resourcing?limit=1", headers=HDR).json() + assert empty["project_count"] == 1 and empty["truncated"] is True, empty + assert empty["projects_available"] == 5, empty + + # clamps, not trusted + assert c.get("/portfolio/resourcing?limit=0", headers=HDR).json()["project_count"] == 1 + assert c.get("/portfolio/resourcing?weeks=1", headers=HDR).json()["week_span"]["shown"] >= 1 + +print("resource portfolio OK") diff --git a/services/api/test_route_reachability.py b/services/api/test_route_reachability.py index 875f384b..8ab5f7a4 100644 --- a/services/api/test_route_reachability.py +++ b/services/api/test_route_reachability.py @@ -124,6 +124,18 @@ def check(label, ok, detail=""): # third of the surface out of this gate's reach. The real fix was to stop half-wiring the item — # R35-DEAL-MEMORY asks for realised outcomes *by vintage*, and only the summary comparison had # been built. The gate collision is what made the missing half visible. + # + # SECOND INSTANCE, v0.3.1145, and it did not need a route to collide — a WORD did. Adding + # `/portfolio/resourcing` put the field `fidelity.resourced` into the web source, and `sourced` + # is the leaf of `/projects/{pid}/schedule/eot/sourced`, so that frozen entry read as called + # while nothing called it. `strip_comments` was no help: the collision was in an identifier, not + # in prose. Fixed by renaming the field to `assigned` — which names its SOURCE (records from the + # `resource_assignment` module) rather than restating an adjective, so it is also the better + # name. Recorded because the two instances differ in a way that matters: the first was one route + # path containing another's, and could be read as a naming accident; this one is an ordinary + # English word containing a route leaf, which no naming convention prevents. **The rule's + # coarseness is a standing cost of keeping 328 shared-leaf routes in reach, not a bug awaiting a + # fix** — and the cost is paid by whoever writes the colliding word next. "/proforma/entitlement-risk", "/proforma/provenance/admissibility", # "/projects/preview-bundle" REMOVED v0.3.1061 — it gained a real caller in # apps/web/src/api/library.ts (the `.mass` preview from PR #336), so freezing it as