feat(fristenrechner): sort post-trigger events by duration ASC within parent group (t-paliad-296)
Optional events anchored on the same trigger (e.g. the four post-Entscheidung rules in upc.inf.cfi) used to render in catalog sequence_order, so a 2-month rule (R.118.4 Folgeentscheidungen) would precede a 1-month rule (R.151 Kostenentscheidung) chained off the same decision. Now the calculator does a post-evaluation permutation pass that sorts consecutive same-parent rows by duration ascending — days < weeks < months < years, ties broken by duration_value then submission_code. Different trigger groups keep their proceeding-sequence position — the walk only ever permutes rows that already share a parent. Root rules (no parent) are never sorted against each other. Court-set / conditional rows whose date isn't in the duration ladder sort LAST within their group. Verified order against m's report: R.151 cost_app + R.353 rectification (1-month tier) now render before R.220.1 appeal_spawn + R.118.4 cons_orders (2-month tier). Issue: m/paliad#128
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -867,6 +868,16 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
deadlines = append(deadlines, d)
|
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{
|
resp := &UIResponse{
|
||||||
ProceedingType: pickedProceeding.Code,
|
ProceedingType: pickedProceeding.Code,
|
||||||
ProceedingName: pickedProceeding.Name,
|
ProceedingName: pickedProceeding.Name,
|
||||||
@@ -893,6 +904,138 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
return resp, nil
|
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
|
// ErrUnknownRule is returned when CalculateRule can't resolve the
|
||||||
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
|
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
|
||||||
var ErrUnknownRule = errors.New("unknown rule")
|
var ErrUnknownRule = errors.New("unknown rule")
|
||||||
|
|||||||
221
internal/services/fristenrechner_sort_test.go
Normal file
221
internal/services/fristenrechner_sort_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user