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:
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user