feat(litigationplanner): appeal-target synthetic trigger row + appeal-role stamping (t-paliad-307, m/paliad#136)
Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136. Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set, Calculate now prepends a synthetic TimelineEntry to the deadlines slice dated to the trigger date, carrying the per-appeal-target label from TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten- entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked IsRootEvent + IsTriggerEvent + party=court + priority=informational so the frontend renders it as a dimmed anchor card without a save button / choices caret / click-to-edit affordance. Empty Code so it doesn't collide with real rule UUIDs downstream. Bug 1 (engine half) — Side selector dead on appeal. Every appeal filing rule carries primary_party='both' in the catalog, so the column bucketer couldn't distinguish Berufungskläger vs Berufungs- beklagter filings from primary_party alone. Engine now stamps the new TimelineEntry.AppealRole field with appellant/appellee from the rule-semantic AppealFilerRole mapping (appeal_role.go) when an appeal_target is in scope. The frontend half of the fix (next commit) consumes this to route each "both" rule into the user-perspective column once the user picks a side. Mapping covers all 12 appeal filing rules across the three applies_to_target tracks (endentscheidung/schadensbemessung, kostenentscheidung, anordnung/bucheinsicht). Court-issued events (merits.decision, merits.oral, cost.decision, order.order) stay empty — they continue to route on Party='court'. Unmapped submission_codes return empty so a new appeal rule we forgot to map falls through to the bucketer's legacy path rather than silently picking a side. Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole stamped when target is set, (b) no synthetic row + no AppealRole when target is unset (regression guard), (c) unknown target short-circuits to no-op. Existing tests untouched — both behaviours gate on opts.AppealTarget != "". No DB migration — the bugs are calc-side. deadline_rules untouched.
This commit is contained in:
58
pkg/litigationplanner/appeal_role.go
Normal file
58
pkg/litigationplanner/appeal_role.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package litigationplanner
|
||||
|
||||
// AppealRole* are the canonical filer-role slugs used by the unified
|
||||
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
//
|
||||
// Every appeal filing rule carries primary_party='both' in the catalog
|
||||
// (either party could be the appellant, depending on which side lost
|
||||
// downstream), so the static primary_party column can't drive
|
||||
// column-bucketing under a user-perspective `?side=` pick. The
|
||||
// per-rule appeal role fills that gap: "appellant" rules are filed by
|
||||
// the Berufungskläger (the party who lost in the lower instance and
|
||||
// is now appealing); "appellee" rules are filed by the
|
||||
// Berufungsbeklagter (the party defending the lower-instance
|
||||
// decision). The mapping is rule-semantic, not data-driven — we know
|
||||
// from R.224/235 which submission belongs to which side.
|
||||
const (
|
||||
AppealRoleAppellant = "appellant"
|
||||
AppealRoleAppellee = "appellee"
|
||||
)
|
||||
|
||||
// AppealFilerRole returns the appeal-filer role for a submission code
|
||||
// in the unified upc.apl proceeding. Empty string for codes whose role
|
||||
// is not statically known (court-issued events, unmapped codes, or
|
||||
// non-appeal proceedings).
|
||||
//
|
||||
// The engine stamps TimelineEntry.AppealRole with this value when
|
||||
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
|
||||
// route each "both"-party rule into the correct user-perspective
|
||||
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
|
||||
// a side.
|
||||
//
|
||||
// Adding a new appeal rule? Add its submission_code to the matching
|
||||
// branch below. Court-issued events (cost.decision, order.order,
|
||||
// merits.oral, merits.decision) deliberately stay empty — they route
|
||||
// to the court column on primary_party='court'.
|
||||
func AppealFilerRole(submissionCode string) string {
|
||||
switch submissionCode {
|
||||
// Appellant filings — Berufungskläger initiates the appeal +
|
||||
// replies to the cross-appeal.
|
||||
case "upc.apl.merits.notice",
|
||||
"upc.apl.merits.grounds",
|
||||
"upc.apl.merits.cross_a_reply",
|
||||
"upc.apl.cost.leave_app",
|
||||
"upc.apl.order.with_leave",
|
||||
"upc.apl.order.grounds_orders",
|
||||
"upc.apl.order.discretion",
|
||||
"upc.apl.order.cross_reply":
|
||||
return AppealRoleAppellant
|
||||
// Appellee filings — Berufungsbeklagter responds to the appeal +
|
||||
// files the cross-appeal.
|
||||
case "upc.apl.merits.response",
|
||||
"upc.apl.merits.cross_a",
|
||||
"upc.apl.order.response_orders",
|
||||
"upc.apl.order.cross":
|
||||
return AppealRoleAppellee
|
||||
}
|
||||
return ""
|
||||
}
|
||||
192
pkg/litigationplanner/appeal_role_test.go
Normal file
192
pkg/litigationplanner/appeal_role_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestAppealFilerRole pins the rule-semantic mapping that drives
|
||||
// column-bucketing on the unified upc.apl Berufung timeline
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
|
||||
// primary_party='both' in the catalog so the bucketer can't decide
|
||||
// between Berufungskläger and Berufungsbeklagter columns from
|
||||
// primary_party alone — the appeal role fills that gap.
|
||||
func TestAppealFilerRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
// Appellant filings (Berufungskläger initiates / replies to cross).
|
||||
{"upc.apl.merits.notice", AppealRoleAppellant},
|
||||
{"upc.apl.merits.grounds", AppealRoleAppellant},
|
||||
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
|
||||
{"upc.apl.cost.leave_app", AppealRoleAppellant},
|
||||
{"upc.apl.order.with_leave", AppealRoleAppellant},
|
||||
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
|
||||
{"upc.apl.order.discretion", AppealRoleAppellant},
|
||||
{"upc.apl.order.cross_reply", AppealRoleAppellant},
|
||||
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
|
||||
{"upc.apl.merits.response", AppealRoleAppellee},
|
||||
{"upc.apl.merits.cross_a", AppealRoleAppellee},
|
||||
{"upc.apl.order.response_orders", AppealRoleAppellee},
|
||||
{"upc.apl.order.cross", AppealRoleAppellee},
|
||||
// Court-issued events stay empty — they route on party='court'.
|
||||
{"upc.apl.merits.decision", ""},
|
||||
{"upc.apl.merits.oral", ""},
|
||||
{"upc.apl.cost.decision", ""},
|
||||
{"upc.apl.order.order", ""},
|
||||
// Unmapped codes are empty (defensive — never silently picks a
|
||||
// side for a new appeal rule we forgot to map).
|
||||
{"upc.inf.cfi.soc", ""},
|
||||
{"", ""},
|
||||
{"foo.bar", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AppealFilerRole(c.code); got != c.want {
|
||||
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
|
||||
// row the engine prepends when CalcOptions.AppealTarget is set
|
||||
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
|
||||
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
|
||||
// IsTriggerEvent=true, and party=court. Without the appeal_target
|
||||
// filter, no synthetic row is emitted (regression guard).
|
||||
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.apl.unified",
|
||||
Name: "Berufung",
|
||||
NameEN: "Appeal",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
noticeCode := "upc.apl.merits.notice"
|
||||
groundsCode := "upc.apl.merits.grounds"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: ¬iceCode,
|
||||
Name: "Berufungseinlegung",
|
||||
NameEN: "Notice of Appeal",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: &groundsCode,
|
||||
Name: "Berufungsbegründung",
|
||||
NameEN: "Statement of Grounds",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 4,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 1,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
if len(timeline.Deadlines) < 3 {
|
||||
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
|
||||
}
|
||||
// Synthetic row first.
|
||||
first := timeline.Deadlines[0]
|
||||
if !first.IsTriggerEvent {
|
||||
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
|
||||
}
|
||||
if !first.IsRootEvent {
|
||||
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
|
||||
}
|
||||
if first.Name != "Endentscheidung (R.118)" {
|
||||
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
|
||||
}
|
||||
if first.NameEN != "Final decision (R.118)" {
|
||||
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
|
||||
}
|
||||
if first.DueDate != "2026-05-26" {
|
||||
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
|
||||
}
|
||||
if first.Party != PrimaryPartyCourt {
|
||||
t.Errorf("first row Party=%q, want court", first.Party)
|
||||
}
|
||||
// Real rules should carry AppealRole.
|
||||
byCode := map[string]TimelineEntry{}
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("notice AppealRole=%q, want appellant", got)
|
||||
}
|
||||
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("grounds AppealRole=%q, want appellant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
|
||||
opts := CalcOptions{}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
|
||||
}
|
||||
if d.AppealRole != "" {
|
||||
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: "bogus"}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
// IsValidAppealTarget("bogus") = false, so the engine skips
|
||||
// both the rule filter AND the synthetic trigger emission.
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -572,6 +572,21 @@ func Calculate(
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// Stamp AppealRole on every entry when an appeal-target filter is
|
||||
// active so the frontend column-bucketer can route primary_party=
|
||||
// 'both' rules into the user-perspective columns
|
||||
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
|
||||
// — they route on Party='court' regardless. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
for i := range deadlines {
|
||||
if deadlines[i].Code == "" {
|
||||
continue
|
||||
}
|
||||
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore sequence_order on the output slice. The compute walk
|
||||
// re-ordered rules topologically (parent-first) so the parent-state
|
||||
// checks resolved correctly; the wire shape and the linear timeline
|
||||
@@ -594,6 +609,31 @@ func Calculate(
|
||||
// same-group rows. Court-set / conditional rows sort LAST.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
|
||||
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
|
||||
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
|
||||
// it to surface as the first row of the timeline so the chain reads
|
||||
// decision → appeal filings → next decision. Emitted only when an
|
||||
// appeal_target is in play and the helper returns a non-empty label.
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
|
||||
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
|
||||
if nameDE != "" || nameEN != "" {
|
||||
trig := TimelineEntry{
|
||||
Name: nameDE,
|
||||
NameEN: nameEN,
|
||||
Party: PrimaryPartyCourt,
|
||||
Priority: "informational",
|
||||
DueDate: triggerDateStr,
|
||||
OriginalDate: triggerDateStr,
|
||||
IsRootEvent: true,
|
||||
IsTriggerEvent: true,
|
||||
}
|
||||
deadlines = append([]TimelineEntry{trig}, deadlines...)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
|
||||
@@ -441,6 +441,22 @@ type TimelineEntry struct {
|
||||
DurationValue int `json:"durationValue,omitempty"`
|
||||
DurationUnit string `json:"durationUnit,omitempty"`
|
||||
Timing string `json:"timing,omitempty"`
|
||||
|
||||
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1) when the timeline was computed under an
|
||||
// appeal_target filter. One of AppealRoleAppellant /
|
||||
// AppealRoleAppellee, or empty for court events / non-appeal
|
||||
// timelines. The frontend column-bucketer reads this to route
|
||||
// primary_party='both' rules to Berufungskläger vs
|
||||
// Berufungsbeklagter columns once the user picks a side.
|
||||
AppealRole string `json:"appealRole,omitempty"`
|
||||
|
||||
// IsTriggerEvent marks the synthetic root row that represents the
|
||||
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
|
||||
// Distinct from IsRootEvent in that the row carries no real rule
|
||||
// id — it's a UI marker dated to the trigger date with the
|
||||
// per-appeal-target label from TriggerEventLabelForAppealTarget.
|
||||
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
|
||||
Reference in New Issue
Block a user