Files
paliad/pkg/litigationplanner/sort.go
mAi 5f0a85fa83
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124)
Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.

Package contents (~1850 LoC):
- doc.go              package docstring + reuse manifesto
- types.go            Rule, ProceedingType, NullableJSON, AdjustmentReason,
                      HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
                      TimelineEntry, RuleCalculation*, FristenrechnerType,
                      ProjectHint, sentinel errors
- catalog.go          Catalog interface (proceeding + rule lookups)
- holidays.go         HolidayCalendar interface
- courts.go           CourtRegistry interface + DefaultsForJurisdiction +
                      country/regime constants
- expr.go             EvalConditionExpr + HasConditionExpr +
                      ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go        ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go         SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go     FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go  MapLitigationToFristenrechner + code constants
                      (CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go           Calculate + CalculateRule + the trigger-event
                      branch + applyRuleOverrides (the big move)

paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
  (thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
  become type aliases to litigationplanner.* — every sqlx scan and
  every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
  aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
  of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
  + BuildLegalSourceURL replaced with delegating wrappers to lp.

Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
  service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
  time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.

Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.

Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.

Refs: docs/design-litigation-planner-2026-05-26.md
2026-05-26 13:01:07 +02:00

152 lines
4.2 KiB
Go

package litigationplanner
import (
"fmt"
"sort"
"github.com/google/uuid"
)
// SortDeadlinesByDurationWithinTriggerGroup is the public form of
// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's
// test suite (which historically reached the helper directly) can
// keep invoking it via a tiny wrapper.
func SortDeadlinesByDurationWithinTriggerGroup(
deadlines []TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) {
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
}
// 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 []TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) {
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="" 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 TimelineEntry, ruleByID map[uuid.UUID]Rule) 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 TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) 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 TimelineEntry,
ruleByID map[uuid.UUID]Rule,
) Rule {
if d.RuleID == "" {
return Rule{}
}
rid, err := uuid.Parse(d.RuleID)
if err != nil {
return Rule{}
}
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
}