T6.3/T6.4 - CR-004 and CR-018: picked not typed, and totals that add up

CR-004 and CR-018 are the same change seen from two ends. CR-018 is why the
Acumatica cost code came out rather than being relabelled — the tracking
dimension the team wants is floor and area, not an accounting code — and CR-004
is what makes that dimension exist. Committed together because a rollup keyed on
free text is not a rollup, and structured location with nothing rolling up by it
is a form change nobody asked for.

CR-004 - three dependent dropdowns

  Building filters Floor filters Sector, off the project's own taxonomy from
  T5.4. Clearing a parent clears its children: not doing that is how a package
  ends up filed under a floor that is not in the building it claims.

  PATHS are stored, not names and not bare codes. A floor's own code is not
  unique across buildings; `B-ONE/L1` is. That is what lets the dashboard filter
  by a building and match everything beneath it with a prefix test, and it is
  what CR-018 groups on.

  The list is fetched with include_inactive=true, which is not a contradiction of
  CR-005's "deactivating hides it from new work packages" — they are two
  questions. What may be CHOSEN is active only. What may be SHOWN is everything,
  because a package already referencing a deactivated value still has to render
  its label, and blanking it on open would write the blank back on the next save.
  A deactivated value that IS on the package is offered, labelled "(no longer
  offered)"; on a fresh package it is not offered at all. Both checked.

  X5, checked the way aggregates_check checks its own: localStorage is poisoned
  with a fake building and the dropdown is required to ignore it.

  wp_location survives as a hidden field. A package written before this keeps
  what it said, and the form says so rather than dropping it.

CR-018 - the rollup

  LOCATION_DIMENSIONS is now ("building", "floor", "sector"). T4.1's note said
  "only this tuple and the keys inside each group change - the response shape
  does not", and that held exactly.

  Rolled up at EVERY level, server-side, not just at the leaf. "How many on
  floor 2" is the question CR-018 asks and it is a level above the leaf groups;
  summing them in the browser would be the same per-browser arithmetic B4
  removed. Actual Hours rolls up along the same dimensions - that is the field
  CR-017 retained, and this is why that decision mattered.

  Packages with no location are an explicit "(unassigned)" row, not a gap. The
  reason is arithmetic: a group set that silently omits them does not add up to
  the project total, and a rollup that does not reconcile is decoration. The
  probe checks every level sums to the project total, and to the estimated and
  actual hour totals, using distinct primes so a mis-sum cannot land on the
  right number by luck.

  A package with a building but no floor lands in the floor-level unassigned row
  alongside the one with no location at all - which is the honest answer, and is
  asserted by its hours rather than by its count.

  Free text captured before CR-004 groups under itself as a building rather than
  collapsing into unassigned, one level deep. Pretending free text is a
  hierarchy would file "FAB / LVL 1" under a building called "FAB / LVL 1".

  server/app.py                 dimensions, _location_levels, hours per group
  html/wp-creation-index.html   three selects where the text box was
  html/wp-creation-app.js       the pickers, the filters, the rollup panel
  html/wp-creation-styles.css   .loc-picker, .loc-rollup
  tests/rollup_check.py         new - 63 checks

Done when — CR-004
  [x] all three render as dropdowns populated from project configuration
  [x] dependent filtering works, and clearing a parent clears its children
  [x] values persist as codes; confirmed by reading what collectPackage stored
  [x] the dashboard filters by each of the three
  [x] a work package referencing a deactivated value still renders correctly
  [x] all option data comes from the server - proved by poisoning the cache

Done when — CR-018
  [x] the dashboard groups and totals by Building, Floor and Sector
  [x] totals reconcile against an unfiltered count, at every level
  [x] Actual Hours rolls up along the same dimensions
  [x] grouping is computed server-side - proved by putting nine fake packages in
      localStorage and requiring the panel to show none of them
  [x] work packages with no location appear in an explicit unassigned group

No migration: location lives in the work package's JSON data blob like every
other per-package field. No colour literal added.

Verified one at a time
  rollup_check      63/63  new
  generalinfo_check 49/49
  browser_check     71/71
  pipeline          43/43
  a11y              22/22
  aggregates        16/16
  f_items           F1-F5 FIXED, F6 REPRODUCES (T7.2)

Question for the PR, per CLAUDE.md: the dashboard's location filters and the
rollup both key on the path, so a package saved with free text and no codes is
unreachable by any location filter and sits in its own building-level row. That
is correct and it is also a migration question - whether the existing free-text
locations should be mapped onto the taxonomy once the B100 list arrives, or left
as history. Nothing here decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 00:24:00 -05:00
parent ce2d008897
commit 4d186ea6a0
5 changed files with 871 additions and 13 deletions

View File

@@ -1731,18 +1731,69 @@ PROGRESS_WEIGHT = {
# when it does, only this tuple and the keys inside each group change — the response
# shape does not, which is what T4.1 means by "can be grouped by location without a
# schema change". CR-018's rollup consumes `by_location.groups` either way.
LOCATION_DIMENSIONS = ("location",)
# CR-004 landed at T6.3, so this is now what it was designed to become. T4.1's
# note said "only this tuple and the keys inside each group change — the response
# shape does not", and that held: CR-018's rollup consumes `by_location.groups`
# exactly as it did when there was one free-text dimension.
LOCATION_DIMENSIONS = ("building", "floor", "sector")
# A package with no location is not dropped from the rollup. `CR-018`'s own
# done-when says so, and the reason is arithmetic: a group set that silently
# omits the unlocated packages does not add up to the project total, and a rollup
# that does not reconcile is worse than no rollup.
LOCATION_UNSET = "(unassigned)"
def _location_key(data: dict) -> dict:
"""The location dimensions of one package, as a dict keyed by dimension name.
Structured fields win when they exist, so this keeps working unchanged the day
CR-004 lands; until then it falls back to the free-text field."""
structured = {d: (data.get(d) or "").strip() for d in ("building", "floor", "sector")}
Structured fields win; the free-text `location` a package captured before
CR-004 is kept as its own dimension value so those packages group together
under what they actually said rather than all collapsing into one bucket."""
structured = {d: (data.get(d) or "").strip() for d in LOCATION_DIMENSIONS}
if any(structured.values()):
return {k: v or "(unset)" for k, v in structured.items()}
return {"location": (data.get("location") or "").strip() or "(unset)"}
return {k: v or LOCATION_UNSET for k, v in structured.items()}
legacy = (data.get("location") or "").strip()
if legacy:
# One dimension deep, deliberately: free text is not a hierarchy and
# pretending it is would put "FAB / LVL 1" under a building called
# "FAB / LVL 1".
return {"building": legacy, "floor": LOCATION_UNSET, "sector": LOCATION_UNSET}
return {d: LOCATION_UNSET for d in LOCATION_DIMENSIONS}
def _location_levels(loc_groups: dict) -> dict:
"""Totals at each level of the hierarchy, not only at the leaf.
`by_location.groups` is one row per distinct (building, floor, sector). The
question CR-018 asks — "what is on floor 2" — is a level above that, and
every level has to reconcile against the project total or the rollup is
decoration. Each level therefore sums EVERY package, including the ones whose
value at that level is unassigned."""
out: dict[str, list] = {}
for dim in LOCATION_DIMENSIONS:
buckets: dict[str, dict] = {}
for g in loc_groups.values():
# Grouped by the value AT THIS LEVEL alone, not by the tuple of levels
# above it. Each stored value is already a full path — a floor is
# `B-ONE/L1`, not `L1` — so it carries its own ancestry and is unique
# across buildings without being re-qualified. Only the unassigned
# bucket is shared, which is what it should be: "these have no floor
# recorded" is one answer, not one answer per building.
path = g["key"].get(dim, LOCATION_UNSET) or LOCATION_UNSET
slot = buckets.setdefault(path, {
"dimension": dim,
"path": path,
"total": 0, "release_ready": 0, "on_hold": 0, "overdue": 0,
"est_hours": 0.0, "actual_hours": 0.0,
})
for f in ("total", "release_ready", "on_hold", "overdue", "est_hours", "actual_hours"):
slot[f] += g[f]
out[dim] = [
{**b, "est_hours": round(b["est_hours"]), "actual_hours": round(b["actual_hours"])}
for b in sorted(buckets.values(), key=lambda b: b["path"])
]
return out
@app.get("/api/wps/metrics")
@@ -1833,7 +1884,8 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
key = _location_key(data)
kt = tuple(key.get(d, "(unset)") for d in LOCATION_DIMENSIONS) if len(key) == len(LOCATION_DIMENSIONS) else tuple(sorted(key.items()))
slot = loc_groups.setdefault(kt, {"key": key, "total": 0, "release_ready": 0,
"on_hold": 0, "overdue": 0, "by_status": {}})
"on_hold": 0, "overdue": 0, "by_status": {},
"est_hours": 0.0, "actual_hours": 0.0})
slot["total"] += 1
slot["by_status"][w.status] = slot["by_status"].get(w.status, 0) + 1
if not blocked and w.status not in ("Closed", "Issue"):
@@ -1842,6 +1894,17 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
slot["on_hold"] += 1
if is_overdue:
slot["overdue"] += 1
# CR-018: hours roll up along the same dimensions. Actual Hours is the one
# CR-017 retained and is the reason that decision mattered — it is what
# makes a floor's real cost visible.
try:
slot["est_hours"] += float(data.get("hours") or 0)
except (TypeError, ValueError):
pass
try:
slot["actual_hours"] += float(data.get("actualHrs") or 0)
except (TypeError, ValueError):
pass
dimensions = list(LOCATION_DIMENSIONS)
if loc_groups:
@@ -1862,8 +1925,20 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
],
},
"gating": sorted(gating, key=lambda g: (g["number"] or "", g["id"])),
"by_location": {"dimensions": dimensions, "groups": sorted(
loc_groups.values(), key=lambda g: tuple(str(v) for v in g["key"].values()))},
"by_location": {
"dimensions": dimensions,
"unassigned_key": LOCATION_UNSET,
"groups": [
{**g, "est_hours": round(g["est_hours"]), "actual_hours": round(g["actual_hours"])}
for g in sorted(loc_groups.values(),
key=lambda g: tuple(str(v) for v in g["key"].values()))
],
# Rolled up one level at a time as well as by the full triple, because
# "how many on floor 2" is the question CR-018 is actually about and
# summing the leaf groups in the browser would be the same per-browser
# arithmetic B4 removed.
"levels": _location_levels(loc_groups),
},
"generated_at": models.utcnow().isoformat(),
}