feat(iace): surface OSHA distance anchor in Maßnahmen tab (name-resolved)

Makes the OSHA minimum-distance anchor visible per measure in a project
without a DB schema change or re-seed: persisted mitigations store the
measure NAME verbatim (not the catalog ID), and measure names are unique
across the 578-entry library (pinned by test), so a name→ID resolver
bridges the gap.

Backend: MeasureIDByName + MinimumDistancesForMeasureName/LinksForMeasureName;
/iace/minimum-distances now accepts ?measure_name=; link table enriched with
measure_name for one-request UI matching.
Frontend: useMinimumDistances loads the link table once and keys it by name;
OshaDistanceNote renders the anchor (value/CFR/license/EU-hint/relation) on the
matching measure group in the Maßnahmen tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-11 13:39:48 +02:00
parent 76be96556d
commit 6b41eec176
6 changed files with 226 additions and 8 deletions
@@ -200,6 +200,7 @@ func GetMinimumDistanceByID(id string) (MinimumDistance, bool) {
// OSHA entry is offered as the public-domain pendant for independent check.
type MeasureDistanceLink struct {
MeasureID string `json:"measure_id"`
MeasureName string `json:"measure_name,omitempty"` // resolved from the library for name-based UI matching
DistanceIDs []string `json:"distance_ids"`
Relation string `json:"relation"`
Note string `json:"note,omitempty"`
@@ -216,7 +217,7 @@ const (
// ISO value (e.g. M340 robot teach speed, M368 air-receiver wall) are NOT
// linked — that would imply a public-domain anchor that does not exist.
func AllMeasureDistanceLinks() []MeasureDistanceLink {
return []MeasureDistanceLink{
links := []MeasureDistanceLink{
{
MeasureID: "M600",
DistanceIDs: []string{"MD_OSHA_217_PSDI"},
@@ -236,6 +237,18 @@ func AllMeasureDistanceLinks() []MeasureDistanceLink {
Note: "OSHA §1910.212(a)(5) Luefterschutz (max. 12 mm Spaltoeffnung) als Public-Domain-Pendant zu ISO 13857.",
},
}
// Enrich each link with the measure name so a name-based UI (persisted
// mitigations carry the name, not the ID) can match without a second lookup.
lib := GetProtectiveMeasureLibrary()
for i := range links {
for _, m := range lib {
if m.ID == links[i].MeasureID {
links[i].MeasureName = m.Name
break
}
}
}
return links
}
// LinksForMeasure returns the distance links declared for one measure.
@@ -263,3 +276,33 @@ func MinimumDistancesForMeasure(measureID string) []MinimumDistance {
}
return out
}
// MeasureIDByName resolves a protective-measure name to its catalog ID. Measure
// names are unique across the library (verified by test), so a PERSISTED
// mitigation — which stores the measure name verbatim but NOT the catalog ID —
// can still be joined to its OSHA distance link without a DB schema change or a
// re-seed. This is the bridge that lets a project's measures surface the anchor.
func MeasureIDByName(name string) (string, bool) {
for _, m := range GetProtectiveMeasureLibrary() {
if m.Name == name {
return m.ID, true
}
}
return "", false
}
// MinimumDistancesForMeasureName resolves OSHA distances by measure NAME.
func MinimumDistancesForMeasureName(name string) []MinimumDistance {
if id, ok := MeasureIDByName(name); ok {
return MinimumDistancesForMeasure(id)
}
return nil
}
// LinksForMeasureName resolves distance links by measure NAME.
func LinksForMeasureName(name string) []MeasureDistanceLink {
if id, ok := MeasureIDByName(name); ok {
return LinksForMeasure(id)
}
return nil
}
@@ -113,6 +113,38 @@ func TestMeasureDistanceLinks_ValueSourceProseConsistency(t *testing.T) {
}
}
// The name→ID resolver underpins surfacing OSHA anchors on persisted
// mitigations (which store the name, not the catalog ID). It is only safe if
// measure names are unique — pin that, and that the resolver round-trips for
// every linked measure.
func TestMeasureNames_UniqueAndResolvable(t *testing.T) {
idByName := map[string]string{}
nameByID := map[string]string{}
for _, m := range GetProtectiveMeasureLibrary() {
if prev, dup := idByName[m.Name]; dup {
t.Errorf("duplicate measure name %q (IDs %s, %s) — name→ID resolver unsafe",
m.Name, prev, m.ID)
}
idByName[m.Name] = m.ID
nameByID[m.ID] = m.Name
}
for _, l := range AllMeasureDistanceLinks() {
name := nameByID[l.MeasureID]
if name == "" {
t.Errorf("linked measure %q not found in library", l.MeasureID)
continue
}
gotID, ok := MeasureIDByName(name)
if !ok || gotID != l.MeasureID {
t.Errorf("MeasureIDByName(%q) = %q,%v; want %q", name, gotID, ok, l.MeasureID)
}
if len(MinimumDistancesForMeasureName(name)) != len(MinimumDistancesForMeasure(l.MeasureID)) {
t.Errorf("name vs id resolution mismatch for %q", l.MeasureID)
}
}
}
// proseMentionsDistance reports whether the (dot-stripped, lowercased) measure
// text contains a numeric form of the distance's value, formula or recommended mm.
func proseMentionsDistance(text string, md MinimumDistance) bool {