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