diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 1bf73f3..844dd50 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "time" "github.com/google/uuid" @@ -867,6 +868,16 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t deadlines = append(deadlines, d) } + // t-paliad-296: within consecutive runs of rules sharing the same + // trigger group (parent_id + trigger_event_id), reorder by duration + // ascending so optional events following the same anchor render in + // their likely-sequence order (a 1-month rule before a 2-month rule + // chained off the same decision). Different trigger groups keep + // their proceeding-sequence position — the chunk walk only sorts + // adjacent same-group rows. Court-set / conditional rows whose + // date isn't in the duration ladder sort LAST within their group. + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + resp := &UIResponse{ ProceedingType: pickedProceeding.Code, ProceedingName: pickedProceeding.Name, @@ -893,6 +904,138 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t return resp, nil } +// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of +// deadlines whose underlying rule shares the same trigger group +// (parent_id + trigger_event_id) and reorders each run in place by +// duration ascending. Different trigger groups keep their original +// proceeding-sequence position — the walk only ever permutes adjacent +// same-group rows. +// +// Sort key (within a run): +// 1. Conditional / court-set rows (no concrete date in the duration +// ladder) sort LAST, tiebroken by submission_code. +// 2. duration_unit weight ASC: days/working_days < weeks < months < years +// 3. duration_value ASC +// 4. submission_code ASC (deterministic tiebreak) +// +// Issue: m/paliad#128 — post-decision optional events (R.151/R.353 +// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog +// order instead of likely-sequence order. (t-paliad-296) +func sortDeadlinesByDurationWithinTriggerGroup( + deadlines []UIDeadline, + ruleByID map[uuid.UUID]models.DeadlineRule, +) { + if len(deadlines) < 2 { + return + } + n := len(deadlines) + i := 0 + for i < n { + gid := triggerGroupKey(deadlines[i], ruleByID) + j := i + 1 + for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid { + j++ + } + // Root rules (no parent and no trigger_event) get gid="root" + // and would otherwise collapse into one big run. Skip the sort + // for the "root" pseudo-group — each root rule represents its + // own anchor (SoC, oral hearing, decision …) and the + // proceeding-sequence order between them must be preserved. + if j-i > 1 && gid != "" { + chunk := deadlines[i:j] + sort.SliceStable(chunk, func(a, b int) bool { + return durationLessForSort(chunk[a], chunk[b], ruleByID) + }) + } + i = j + } +} + +// triggerGroupKey returns a string key identifying which trigger group +// a deadline belongs to. Same key = same group = candidates for sort. +// Empty string means "root" (no parent, no trigger_event) — used as a +// sentinel by the caller to skip sorting roots against each other. +func triggerGroupKey(d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule) string { + rid, err := uuid.Parse(d.RuleID) + if err != nil { + return "" + } + r, ok := ruleByID[rid] + if !ok { + return "" + } + if r.ParentID != nil { + return "p:" + r.ParentID.String() + } + if r.TriggerEventID != nil { + return fmt.Sprintf("t:%d", *r.TriggerEventID) + } + return "" +} + +// durationLessForSort compares two deadlines for the duration-ascending +// sort. Court-set / conditional rows (no concrete date) sort LAST +// regardless of duration — they don't fit the duration ladder. +func durationLessForSort( + a, b UIDeadline, + ruleByID map[uuid.UUID]models.DeadlineRule, +) bool { + aLast := a.IsCourtSet || a.IsConditional + bLast := b.IsCourtSet || b.IsConditional + if aLast != bLast { + return !aLast + } + if aLast && bLast { + return a.Code < b.Code + } + + ra := lookupRuleFromDeadline(a, ruleByID) + rb := lookupRuleFromDeadline(b, ruleByID) + + wa := durationUnitWeight(ra.DurationUnit) + wb := durationUnitWeight(rb.DurationUnit) + if wa != wb { + return wa < wb + } + if ra.DurationValue != rb.DurationValue { + return ra.DurationValue < rb.DurationValue + } + return a.Code < b.Code +} + +func lookupRuleFromDeadline( + d UIDeadline, + ruleByID map[uuid.UUID]models.DeadlineRule, +) models.DeadlineRule { + if d.RuleID == "" { + return models.DeadlineRule{} + } + rid, err := uuid.Parse(d.RuleID) + if err != nil { + return models.DeadlineRule{} + } + return ruleByID[rid] +} + +// durationUnitWeight maps a duration unit to its sort weight so the +// trigger-group sort can order shorter durations first. days and +// working_days share weight 0 (both are sub-week granularities); +// unknown units sort to the end so they're visible as a tail rather +// than silently winning. +func durationUnitWeight(unit string) int { + switch unit { + case "days", "working_days": + return 0 + case "weeks": + return 1 + case "months": + return 2 + case "years": + return 3 + } + return 4 +} + // ErrUnknownRule is returned when CalculateRule can't resolve the // (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule. var ErrUnknownRule = errors.New("unknown rule") diff --git a/internal/services/fristenrechner_sort_test.go b/internal/services/fristenrechner_sort_test.go new file mode 100644 index 0000000..76403e9 --- /dev/null +++ b/internal/services/fristenrechner_sort_test.go @@ -0,0 +1,221 @@ +package services + +// Pure-function tests for the trigger-group duration sort introduced +// by t-paliad-296 / m/paliad#128. No DB needed — feeds synthetic +// UIDeadlines and a ruleByID map directly into the helper. + +import ( + "testing" + + "github.com/google/uuid" + + "mgit.msbls.de/m/paliad/internal/models" +) + +// makeRule is a tiny constructor for a synthetic rule with just the +// fields the sort reads (parent_id, duration_value, duration_unit, +// submission_code, trigger_event_id). +func makeRule(t *testing.T, parent *uuid.UUID, code string, val int, unit string) (uuid.UUID, models.DeadlineRule) { + t.Helper() + id := uuid.New() + codeCopy := code + return id, models.DeadlineRule{ + ID: id, + ParentID: parent, + SubmissionCode: &codeCopy, + DurationValue: val, + DurationUnit: unit, + } +} + +func makeDeadline(id uuid.UUID, code string) UIDeadline { + return UIDeadline{ + RuleID: id.String(), + Code: code, + } +} + +// TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision is the +// canonical scenario from m's report — four post-decision optional +// events anchored on the same decision must render with 1-month rules +// before 2-month rules. +func TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision(t *testing.T) { + decisionID := uuid.New() + + // Catalog order matches mig 132 sequence_order: cons_orders(60), + // cost_app(70), rectification(70), appeal_spawn(80). + consOrdID, consOrdRule := makeRule(t, &decisionID, "upc.inf.cfi.cons_orders", 2, "months") + costAppID, costAppRule := makeRule(t, &decisionID, "upc.inf.cfi.cost_app", 1, "months") + rectID, rectRule := makeRule(t, &decisionID, "upc.inf.cfi.rectification", 1, "months") + appealID, appealRule := makeRule(t, &decisionID, "upc.inf.cfi.appeal_spawn", 2, "months") + + ruleByID := map[uuid.UUID]models.DeadlineRule{ + consOrdID: consOrdRule, + costAppID: costAppRule, + rectID: rectRule, + appealID: appealRule, + } + + deadlines := []UIDeadline{ + makeDeadline(consOrdID, "upc.inf.cfi.cons_orders"), + makeDeadline(costAppID, "upc.inf.cfi.cost_app"), + makeDeadline(rectID, "upc.inf.cfi.rectification"), + makeDeadline(appealID, "upc.inf.cfi.appeal_spawn"), + } + + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + // 1-month tier first (cost_app, rectification — alphabetical by + // submission_code), then 2-month tier (appeal_spawn, cons_orders + // — submission_code ASC tiebreak per spec). + want := []string{ + "upc.inf.cfi.cost_app", + "upc.inf.cfi.rectification", + "upc.inf.cfi.appeal_spawn", + "upc.inf.cfi.cons_orders", + } + for i, w := range want { + if deadlines[i].Code != w { + t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w) + } + } +} + +// TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight asserts the +// unit-weight ordering: days < weeks < months < years, with shorter +// durations of the same unit winning their tier. +func TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight(t *testing.T) { + parentID := uuid.New() + + d14ID, d14Rule := makeRule(t, &parentID, "x.14days", 14, "days") + d2wID, d2wRule := makeRule(t, &parentID, "x.2weeks", 2, "weeks") + d1mID, d1mRule := makeRule(t, &parentID, "x.1month", 1, "months") + d6mID, d6mRule := makeRule(t, &parentID, "x.6months", 6, "months") + d1yID, d1yRule := makeRule(t, &parentID, "x.1year", 1, "years") + + ruleByID := map[uuid.UUID]models.DeadlineRule{ + d14ID: d14Rule, d2wID: d2wRule, d1mID: d1mRule, d6mID: d6mRule, d1yID: d1yRule, + } + + deadlines := []UIDeadline{ + makeDeadline(d6mID, "x.6months"), + makeDeadline(d1yID, "x.1year"), + makeDeadline(d2wID, "x.2weeks"), + makeDeadline(d14ID, "x.14days"), + makeDeadline(d1mID, "x.1month"), + } + + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + want := []string{"x.14days", "x.2weeks", "x.1month", "x.6months", "x.1year"} + for i, w := range want { + if deadlines[i].Code != w { + t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w) + } + } +} + +// TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder +// guards the hard rule: rules with different parents must keep their +// relative position. Sorting only ever permutes adjacent same-parent +// rows. +func TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder(t *testing.T) { + parentAID := uuid.New() + parentBID := uuid.New() + + a3mID, a3mRule := makeRule(t, &parentAID, "ga.3months", 3, "months") + b1mID, b1mRule := makeRule(t, &parentBID, "gb.1month", 1, "months") + a14dID, a14dRule := makeRule(t, &parentAID, "ga.14days", 14, "days") + b2mID, b2mRule := makeRule(t, &parentBID, "gb.2months", 2, "months") + + ruleByID := map[uuid.UUID]models.DeadlineRule{ + a3mID: a3mRule, b1mID: b1mRule, a14dID: a14dRule, b2mID: b2mRule, + } + + // Interleaved groups: A, B, A, B. Each group has one rule between + // each other group's rules — the consecutive-run walk should treat + // each as its own one-element run and not reorder anything. + deadlines := []UIDeadline{ + makeDeadline(a3mID, "ga.3months"), + makeDeadline(b1mID, "gb.1month"), + makeDeadline(a14dID, "ga.14days"), + makeDeadline(b2mID, "gb.2months"), + } + + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + want := []string{"ga.3months", "gb.1month", "ga.14days", "gb.2months"} + for i, w := range want { + if deadlines[i].Code != w { + t.Errorf("deadlines[%d].Code = %q, want %q (interleaved groups must not reorder across)", i, deadlines[i].Code, w) + } + } +} + +// TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast asserts +// that court-set / conditional rows (no concrete date in the duration +// ladder) sort LAST within their group, regardless of their stated +// duration value. +func TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast(t *testing.T) { + parentID := uuid.New() + + dID, dRule := makeRule(t, &parentID, "x.duration", 2, "months") + cID, cRule := makeRule(t, &parentID, "x.conditional", 1, "months") + csID, csRule := makeRule(t, &parentID, "x.courtset", 1, "months") + d2ID, d2Rule := makeRule(t, &parentID, "x.short", 14, "days") + + ruleByID := map[uuid.UUID]models.DeadlineRule{ + dID: dRule, cID: cRule, csID: csRule, d2ID: d2Rule, + } + + deadlines := []UIDeadline{ + {RuleID: cID.String(), Code: "x.conditional", IsConditional: true}, + {RuleID: dID.String(), Code: "x.duration"}, + {RuleID: csID.String(), Code: "x.courtset", IsCourtSet: true}, + {RuleID: d2ID.String(), Code: "x.short"}, + } + + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + // Concrete rows first (sorted by duration): x.short (14d) then + // x.duration (2mo). Then the two no-date rows, tiebroken by code: + // x.conditional < x.courtset alphabetically. + want := []string{"x.short", "x.duration", "x.conditional", "x.courtset"} + for i, w := range want { + if deadlines[i].Code != w { + t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w) + } + } +} + +// TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged guards +// the root-rule exception: top-level rules (parent_id=nil, no +// trigger_event_id) must never be sorted against each other — they +// represent distinct anchor points (SoC vs oral hearing vs decision) +// whose proceeding-sequence order is non-negotiable. +func TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged(t *testing.T) { + rootSoCID, rootSoCRule := makeRule(t, nil, "x.soc", 0, "months") + rootOralID, rootOralRule := makeRule(t, nil, "x.oral", 0, "months") + rootDecID, rootDecRule := makeRule(t, nil, "x.decision", 0, "months") + + ruleByID := map[uuid.UUID]models.DeadlineRule{ + rootSoCID: rootSoCRule, rootOralID: rootOralRule, rootDecID: rootDecRule, + } + + deadlines := []UIDeadline{ + makeDeadline(rootSoCID, "x.soc"), + makeDeadline(rootOralID, "x.oral"), + makeDeadline(rootDecID, "x.decision"), + } + + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + // Roots must keep their input order — they're not in the same + // trigger group as each other. + want := []string{"x.soc", "x.oral", "x.decision"} + for i, w := range want { + if deadlines[i].Code != w { + t.Errorf("deadlines[%d].Code = %q, want %q (roots must not be sorted against each other)", i, deadlines[i].Code, w) + } + } +}