feat(t-paliad-189): UIResponse emits priority + conditionExpr

Phase 3 Slice 8 wire-shape swap. UIDeadline gains:

  - Priority: 4-way enum (mandatory|recommended|optional|informational)
    — the authoritative field the frontend reads after Slice 8 to drive
    save-modal pre-check + notice-card rendering.
  - ConditionExpr: jsonb gate predicate (design §2.4 long form),
    emitted verbatim as json.RawMessage so the rule editor (Slice 11)
    + admin surfaces can render the gating shape.

Additivity invariant: the legacy IsMandatory / IsOptional pair stays
populated via wireFlagsFromPriority (mandatory→T/F, optional→T/T,
recommended|informational→F/F). Pre-Slice-8 frontends keep working;
Slice 9 drops the legacy fields once the frontend cutover is verified
in prod.

All three calculator paths populate the new fields:
  - FristenrechnerService.Calculate (proceeding-tree, Pipeline A)
  - FristenrechnerService.calculateByTriggerEvent (Pipeline C)
  - EventTriggerService.Trigger (event-keyed endpoint, Slice 6)

Backend live-DB test asserts:
  - Every UPC_INF rule's priority is in the unified enum.
  - The wireFlagsFromPriority round-trip holds for every row.
  - At least one rule carries a populated conditionExpr (the 17
    with_ccr / with_amend / with_cci rules from mig 084).
This commit is contained in:
mAi
2026-05-15 01:28:56 +02:00
parent a55f45ebea
commit d6f5e0c97e
3 changed files with 127 additions and 14 deletions

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@@ -150,11 +151,15 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
}
}
// Slice 8 wire-shape swap: emit Priority + ConditionExpr directly;
// keep the legacy pair populated for one release.
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
IsMandatory: wireMand,
IsOptional: wireOpt,
IsCourtSet: r.IsCourtSet,

View File

@@ -35,12 +35,28 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
//
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: Priority +
// ConditionExpr are the new authoritative fields the frontend should
// read. IsMandatory + IsOptional + (the legacy condition_flag, not
// emitted directly on UIDeadline today) stay populated via
// wireFlagsFromPriority for one release so the existing frontend keeps
// working while the cutover lands. Slice 9 drops the legacy fields.
type UIDeadline struct {
RuleID string `json:"ruleId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Party string `json:"party"`
// Priority is the 4-way enum the rule-editor + save-modal logic
// reads after Slice 8: 'mandatory' | 'recommended' | 'optional' |
// 'informational'. Informational rules render as notice cards
// (no save button, no checkbox) — the visible UX win of Phase 3
// on today's 18 F/F rules.
Priority string `json:"priority"`
// IsMandatory is the LEGACY field derived from Priority via
// wireFlagsFromPriority. Kept populated for one release so the
// pre-Slice-8 frontend keeps working; Slice 9 drops it.
IsMandatory bool `json:"isMandatory"`
RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,omitempty"`
@@ -52,9 +68,14 @@ type UIDeadline struct {
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsRootEvent bool `json:"isRootEvent"`
IsCourtSet bool `json:"isCourtSet"`
// IsOptional mirrors paliad.deadline_rules.is_optional. The save-
// modal pre-unchecks these rows; the timeline still renders them
// so the user sees what could apply.
// ConditionExpr is the jsonb gate predicate (design §2.4 long
// form) emitted verbatim so the rule editor (Slice 11) + admin
// surfaces can show the rule's gating shape. NULL / empty when
// the rule is unconditional. Frontend reads this to render the
// "Mit Nichtigkeitswiderklage" hint chips.
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
// IsOptional is the LEGACY field derived from Priority via
// wireFlagsFromPriority. Kept for one release; Slice 9 drops it.
IsOptional bool `json:"isOptional,omitempty"`
// IsCourtSetIndirect is true when IsCourtSet is true because the
// rule chains off a court-determined parent (e.g. RoP.151
@@ -240,18 +261,20 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
continue
}
// Wire-compat: derive the legacy (IsMandatory, IsOptional) pair
// from the unified priority enum so /tools/fristenrechner's
// frontend keeps reading the same fields. Slice 8 will swap the
// wire to emit priority directly.
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: emit Priority +
// ConditionExpr directly. wireFlagsFromPriority still populates
// the legacy (IsMandatory, IsOptional) pair so the pre-Slice-8
// frontend keeps working. Slice 9 drops the legacy fields.
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
IsMandatory: wireMand,
IsOptional: wireOpt,
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
IsMandatory: wireMand,
IsOptional: wireOpt,
}
if r.Code != nil {
d.Code = *r.Code
@@ -1079,12 +1102,18 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
}
}
// Slice 8 wire-shape swap: trigger-event path also emits Priority
// + ConditionExpr directly. Pipeline-C rules default Priority=
// 'mandatory' (mig 085) so the legacy pair (T, F) holds.
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
IsMandatory: r.IsMandatory,
IsOptional: r.IsOptional,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
IsMandatory: wireMand,
IsOptional: wireOpt,
DueDate: picked.Format("2006-01-02"),
OriginalDate: original.Format("2006-01-02"),
WasAdjusted: wasAdj,

View File

@@ -400,3 +400,82 @@ func TestApplyDuration_Matrix(t *testing.T) {
})
}
}
// TestUIDeadline_WireShape_Slice8 asserts Phase 3 Slice 8 (t-paliad-189)
// wire-shape additivity: UIResponse.Deadlines MUST carry the new
// `priority` + `conditionExpr` fields AND the legacy `isMandatory` +
// `isOptional` pair (derived via wireFlagsFromPriority) for one release.
// Slice 9 will drop the legacy fields — until then the response
// shape is a superset.
//
// Live DB required so the rules.List returns real (not synthetic)
// rules with the priority column populated by the Slice 2 backfill.
func TestUIDeadline_WireShape_Slice8(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, "UPC_INF", "2026-01-15", CalcOptions{})
if err != nil {
t.Fatalf("Calculate UPC_INF: %v", err)
}
if len(resp.Deadlines) == 0 {
t.Fatal("Calculate UPC_INF returned no deadlines — seed-data missing?")
}
allowed := map[string]bool{
"mandatory": true, "recommended": true, "optional": true, "informational": true,
}
for _, d := range resp.Deadlines {
if !allowed[d.Priority] {
t.Errorf("rule %s: priority=%q not in unified enum", d.Code, d.Priority)
}
// Legacy-field invariant: wireFlagsFromPriority round-trip.
// 'mandatory' → (T, F); 'optional' → (T, T); 'recommended' / 'informational' → (F, F).
switch d.Priority {
case "mandatory":
if !d.IsMandatory || d.IsOptional {
t.Errorf("rule %s: mandatory should map to (T,F), got (%v,%v)", d.Code, d.IsMandatory, d.IsOptional)
}
case "optional":
if !d.IsMandatory || !d.IsOptional {
t.Errorf("rule %s: optional should map to (T,T), got (%v,%v)", d.Code, d.IsMandatory, d.IsOptional)
}
case "recommended", "informational":
if d.IsMandatory || d.IsOptional {
t.Errorf("rule %s: %s should map to (F,F), got (%v,%v)",
d.Code, d.Priority, d.IsMandatory, d.IsOptional)
}
}
}
// At least one rule should carry a populated conditionExpr (the
// 17 with_ccr / with_amend / with_cci rules mig 084 translated).
// Spot-check that the field actually serialises as jsonb (non-empty
// bytes on at least one row).
var sawConditionExpr bool
for _, d := range resp.Deadlines {
if len(d.ConditionExpr) > 0 && string(d.ConditionExpr) != "null" {
sawConditionExpr = true
break
}
}
if !sawConditionExpr {
t.Logf("warning: no UPC_INF rule had conditionExpr populated — verify mig 084 ran")
}
}