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.
1082 lines
36 KiB
Go
1082 lines
36 KiB
Go
package litigationplanner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
|
// Preserves the pre-Phase-C in-memory calculator's classification:
|
|
//
|
|
// - Rules with duration_value = 0 and no parent_id → IsRootEvent
|
|
// (due date = trigger date)
|
|
// - Rules with duration_value = 0 and a parent_id → IsCourtSet
|
|
// (due date empty, UI shows "court-set" placeholder)
|
|
// - All other rules → calculate from either the trigger date (no parent)
|
|
// or the previously-computed date for their parent rule.
|
|
//
|
|
// Audit-driven extensions:
|
|
//
|
|
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
|
|
// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr").
|
|
// - opts.PriorityDateStr overrides the anchor for rules with
|
|
// anchor_alt='priority_date' (e.g. epa.grant.exa publication date
|
|
// is 18mo from priority, not filing).
|
|
// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
|
|
// caller redirect a downstream rule's parent anchor to a user-set
|
|
// date.
|
|
func Calculate(
|
|
ctx context.Context,
|
|
proceedingCode string,
|
|
triggerDateStr string,
|
|
opts CalcOptions,
|
|
catalog Catalog,
|
|
holidays HolidayCalendar,
|
|
courts CourtRegistry,
|
|
) (*Timeline, error) {
|
|
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
|
|
// branch (Pipeline-C unified rules). proceedingCode is ignored on
|
|
// this path.
|
|
if opts.TriggerEventIDFilter != nil {
|
|
return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts)
|
|
}
|
|
|
|
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
|
}
|
|
|
|
var priorityDate *time.Time
|
|
if opts.PriorityDateStr != "" {
|
|
pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
|
|
}
|
|
priorityDate = &pd
|
|
}
|
|
flagSet := make(map[string]struct{}, len(opts.Flags))
|
|
for _, f := range opts.Flags {
|
|
flagSet[f] = struct{}{}
|
|
}
|
|
// v1 simplification (t-paliad-265): when any IncludeCCRFor entry
|
|
// exists, we treat with_ccr as set in the flag context.
|
|
if len(opts.IncludeCCRFor) > 0 {
|
|
flagSet["with_ccr"] = struct{}{}
|
|
}
|
|
|
|
// Parse anchor overrides up-front so a malformed date errors out
|
|
// before we start walking rules.
|
|
overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides))
|
|
for code, dateStr := range opts.AnchorOverrides {
|
|
od, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err)
|
|
}
|
|
overrideDates[code] = od
|
|
}
|
|
|
|
// Look up proceeding type metadata.
|
|
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sub-track routing (m/paliad#58). When the user picks a proceeding
|
|
// that has no native rules and is normally a sub-track of another
|
|
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
|
|
// rule lookup to the parent and merge the default flags into the
|
|
// user's flag set. The response identity stays on the user-picked
|
|
// proceeding so the page header still reads "Counterclaim for
|
|
// Revocation", but the timeline body is the parent's full flow with
|
|
// the sub-track flag enabled.
|
|
var subTrackNote SubTrackRouting
|
|
var hasSubTrackNote bool
|
|
pt := pickedProceeding
|
|
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
|
|
subTrackNote = route
|
|
hasSubTrackNote = true
|
|
parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err)
|
|
}
|
|
pt = parentPt
|
|
rules = parentRules
|
|
// Merge default flags into the user's flag set so the gated
|
|
// rules render. User-supplied flags win on conflict.
|
|
for _, f := range route.DefaultFlags {
|
|
if _, exists := flagSet[f]; !exists {
|
|
flagSet[f] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve (country, regime) for non-working-day adjustment. Court
|
|
// wins when supplied; otherwise default by proceeding regime.
|
|
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
|
country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
|
}
|
|
|
|
if len(opts.RuleOverrides) > 0 {
|
|
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
|
}
|
|
|
|
// AppealTarget filter (Slice B1, m/paliad#124 §18.1). When set,
|
|
// keep only rules whose AppliesToTarget contains the requested
|
|
// slug. Unknown slugs short-circuit to no-op (defensive: a stale
|
|
// frontend chip shouldn't break the render). Empty AppliesToTarget
|
|
// on a rule means "doesn't belong to an appeal target" — such a
|
|
// rule is suppressed under any non-empty AppealTarget filter.
|
|
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
|
filtered := make([]Rule, 0, len(rules))
|
|
for _, r := range rules {
|
|
for _, t := range r.AppliesToTarget {
|
|
if t == opts.AppealTarget {
|
|
filtered = append(filtered, r)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
rules = filtered
|
|
}
|
|
|
|
// ruleByID lets the conditional-rendering branches resolve a parent
|
|
// rule's display fields (submission_code, name, name_en) for the
|
|
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
|
|
// slice on every iteration. (t-paliad-289)
|
|
ruleByID := make(map[uuid.UUID]Rule, len(rules))
|
|
for _, r := range rules {
|
|
ruleByID[r.ID] = r
|
|
}
|
|
|
|
// triggerEventByID powers the trigger-event override on the
|
|
// conditional-label chip (m/paliad#126 / t-paliad-294). When a rule
|
|
// carries a real paliad.trigger_events row, that catalog event —
|
|
// not the rule's parent_id — is the rule's actual semantic anchor.
|
|
// The override fires below when stamping ParentRule* on the wire so
|
|
// the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit
|
|
// gegenüber der Öffentlichkeit" for R.262(2) — instead of the
|
|
// (misleading) parent_id-derived "abhängig von Klageerhebung".
|
|
//
|
|
// Bulk-loaded in one round-trip; trees in the live corpus carry at
|
|
// most a handful of trigger_event_id-bearing rules (2 today on
|
|
// upc.inf.cfi), so the IN(...) is small.
|
|
var triggerIDs []int64
|
|
seenTrigger := make(map[int64]struct{}, len(rules))
|
|
for _, r := range rules {
|
|
if r.TriggerEventID == nil {
|
|
continue
|
|
}
|
|
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
|
|
continue
|
|
}
|
|
seenTrigger[*r.TriggerEventID] = struct{}{}
|
|
triggerIDs = append(triggerIDs, *r.TriggerEventID)
|
|
}
|
|
triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
|
}
|
|
|
|
// Walk the rule list in TOPOLOGICAL order (parents before children),
|
|
// not the raw sequence_order order from the catalog. The catalog
|
|
// returns rules sorted by sequence_order, which is the chronological/
|
|
// display order. That order is parent-first for the common
|
|
// timing='after' case but parent-LAST for timing='before' children
|
|
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
|
|
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
|
|
// ordering the parent-state checks below (courtSet[parent] /
|
|
// computed[parent_code]) read stale empty maps when a child appears
|
|
// before its parent, and the engine falls back to the trigger date
|
|
// → fabricates dates before the SoC.
|
|
//
|
|
// Original sequence_order is restored at the end of the walk so the
|
|
// wire shape and the timeline view's render order stay identical to
|
|
// the legacy behaviour modulo the bug fix.
|
|
sequenceIndex := make(map[uuid.UUID]int, len(rules))
|
|
for i, r := range rules {
|
|
sequenceIndex[r.ID] = i
|
|
}
|
|
walkRules := topoSortByParentDepth(rules)
|
|
|
|
computed := make(map[string]time.Time, len(rules))
|
|
courtSet := make(map[uuid.UUID]bool, len(rules))
|
|
deadlines := make([]TimelineEntry, 0, len(rules))
|
|
|
|
skipRules := opts.SkipRules
|
|
perCardAppellant := opts.PerCardAppellant
|
|
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
|
hiddenCount := 0
|
|
appellantContext := make(map[uuid.UUID]string, len(rules))
|
|
|
|
for _, r := range walkRules {
|
|
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
|
// Suppression semantic preserved: when the gate fires false
|
|
// AND no alt_* values exist, the rule is dropped from the
|
|
// timeline entirely (purely conditional). When alt_* values
|
|
// exist, the gate-false branch still renders, just without
|
|
// the alt-swap.
|
|
gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
|
if !gateMet && r.AltDurationValue == nil {
|
|
continue
|
|
}
|
|
|
|
// SkipRules suppression (t-paliad-265).
|
|
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
|
// we re-surface the directly-skipped row (faded via IsHidden)
|
|
// instead of dropping it.
|
|
var isHidden bool
|
|
if r.SubmissionCode != nil {
|
|
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
|
hiddenCount++
|
|
if !opts.IncludeHidden {
|
|
skippedIDs[r.ID] = struct{}{}
|
|
continue
|
|
}
|
|
isHidden = true
|
|
}
|
|
}
|
|
if r.ParentID != nil {
|
|
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
|
|
skippedIDs[r.ID] = struct{}{}
|
|
continue
|
|
}
|
|
}
|
|
|
|
// AppellantContext propagation. A rule with its own
|
|
// PerCardAppellant pick stamps its UUID with that value.
|
|
// Otherwise inherit from parent if the parent had a context.
|
|
var ctxVal string
|
|
if r.SubmissionCode != nil {
|
|
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
|
|
ctxVal = v
|
|
}
|
|
}
|
|
if ctxVal == "" && r.ParentID != nil {
|
|
if v, ok := appellantContext[*r.ParentID]; ok {
|
|
ctxVal = v
|
|
}
|
|
}
|
|
if ctxVal != "" {
|
|
appellantContext[r.ID] = ctxVal
|
|
}
|
|
|
|
ruleTiming := ""
|
|
if r.Timing != nil {
|
|
ruleTiming = *r.Timing
|
|
}
|
|
d := TimelineEntry{
|
|
RuleID: r.ID.String(),
|
|
Name: r.Name,
|
|
NameEN: r.NameEN,
|
|
Priority: r.Priority,
|
|
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
|
AppellantContext: ctxVal,
|
|
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
|
IsHidden: isHidden,
|
|
DurationValue: r.DurationValue,
|
|
DurationUnit: r.DurationUnit,
|
|
Timing: ruleTiming,
|
|
}
|
|
if r.SubmissionCode != nil {
|
|
d.Code = *r.SubmissionCode
|
|
}
|
|
if r.PrimaryParty != nil {
|
|
d.Party = *r.PrimaryParty
|
|
}
|
|
if r.RuleCode != nil {
|
|
d.RuleRef = *r.RuleCode
|
|
}
|
|
if r.LegalSource != nil {
|
|
d.LegalSource = *r.LegalSource
|
|
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
|
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
|
}
|
|
if r.DeadlineNotes != nil {
|
|
d.Notes = *r.DeadlineNotes
|
|
}
|
|
if r.DeadlineNotesEn != nil {
|
|
d.NotesEN = *r.DeadlineNotesEn
|
|
}
|
|
|
|
// Resolve the parent rule once so every conditional-rendering
|
|
// branch (incl. the optional-not-recorded path below) can stamp
|
|
// ParentRule* on the wire without re-scanning. Populated even
|
|
// for non-conditional rows — the frontend dependency-footer
|
|
// ("Folgt aus …") already consumes this on regular projected
|
|
// rows. (t-paliad-289)
|
|
var parentRule *Rule
|
|
if r.ParentID != nil {
|
|
if pr, ok := ruleByID[*r.ParentID]; ok {
|
|
parentRule = &pr
|
|
if pr.SubmissionCode != nil {
|
|
d.ParentRuleCode = *pr.SubmissionCode
|
|
}
|
|
d.ParentRuleName = pr.Name
|
|
d.ParentRuleNameEN = pr.NameEN
|
|
}
|
|
}
|
|
|
|
// Trigger-event override on the user-facing dependency identity
|
|
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
|
// trigger_event_id, that catalog event is the actual semantic
|
|
// anchor — not the parent_id node, which is only the calc-time
|
|
// arithmetic anchor. Only the user-facing wire fields shift;
|
|
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
|
// and the calc-time arithmetic below) stays anchored on the
|
|
// rule tree.
|
|
if r.TriggerEventID != nil {
|
|
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
|
d.ParentRuleCode = te.Code
|
|
d.ParentRuleName = te.NameDE
|
|
d.ParentRuleNameEN = te.Name
|
|
}
|
|
}
|
|
|
|
// Propagate court-set status from a parent rule whose date the
|
|
// court determines: if the anchor itself has no real date,
|
|
// nothing downstream can be computed either — UNLESS the user
|
|
// has supplied an override date for the parent.
|
|
parentOverridden := false
|
|
if r.ParentID != nil && courtSet[*r.ParentID] {
|
|
for _, prev := range rules {
|
|
if prev.ID == *r.ParentID {
|
|
if prev.SubmissionCode != nil {
|
|
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
|
parentOverridden = true
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden
|
|
|
|
// Zero-duration rules fall into one of four buckets:
|
|
// 1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
|
|
// 2. parent=nil, court-determined → IsCourtSet
|
|
// 3. parent set, court-determined → IsCourtSet (waypoint)
|
|
// 4. parent set, NOT court-determined → "filed-with-parent"
|
|
//
|
|
// AnchorOverrides: when the user has set a date for any zero-
|
|
// duration rule, that override wins over both the court-set
|
|
// placeholder and the parent-inheritance.
|
|
if r.DurationValue == 0 {
|
|
if r.SubmissionCode != nil {
|
|
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
|
d.DueDate = ov.Format("2006-01-02")
|
|
d.OriginalDate = d.DueDate
|
|
d.IsOverridden = true
|
|
computed[*r.SubmissionCode] = ov
|
|
deadlines = append(deadlines, d)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if r.ParentID == nil && !r.IsCourtSet {
|
|
// Bucket 1: timeline anchor.
|
|
d.IsRootEvent = true
|
|
d.DueDate = triggerDateStr
|
|
d.OriginalDate = triggerDateStr
|
|
if r.SubmissionCode != nil {
|
|
computed[*r.SubmissionCode] = triggerDate
|
|
}
|
|
} else if r.ParentID != nil && !r.IsCourtSet {
|
|
// Bucket 4: filed-with-parent. Inherit parent's date.
|
|
if parentIsCourtSet {
|
|
// Indirect: rule isn't itself court-determined,
|
|
// it's blocked because its parent is.
|
|
d.IsCourtSet = true
|
|
d.IsCourtSetIndirect = true
|
|
d.IsConditional = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
courtSet[r.ID] = true
|
|
} else {
|
|
var parentDate time.Time
|
|
var haveParentDate bool
|
|
for _, prev := range rules {
|
|
if prev.ID == *r.ParentID {
|
|
if prev.SubmissionCode != nil {
|
|
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
|
parentDate = ov
|
|
haveParentDate = true
|
|
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
|
parentDate = ref
|
|
haveParentDate = true
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if haveParentDate {
|
|
d.DueDate = parentDate.Format("2006-01-02")
|
|
d.OriginalDate = d.DueDate
|
|
if r.SubmissionCode != nil {
|
|
computed[*r.SubmissionCode] = parentDate
|
|
}
|
|
} else {
|
|
// Parent not yet computed (defensive).
|
|
d.IsCourtSet = true
|
|
d.IsCourtSetIndirect = true
|
|
d.IsConditional = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
courtSet[r.ID] = true
|
|
}
|
|
}
|
|
} else {
|
|
// Buckets 2 + 3: court-determined directly.
|
|
d.IsCourtSet = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
courtSet[r.ID] = true
|
|
}
|
|
deadlines = append(deadlines, d)
|
|
continue
|
|
}
|
|
|
|
// If the parent is court-determined and not overridden we have
|
|
// no real anchor date; surface this rule as court-set too
|
|
// rather than fabricating one off the trigger date. IsConditional
|
|
// surfaces the "abhängig von <ParentRuleName>" UX (t-paliad-289).
|
|
if parentIsCourtSet {
|
|
d.IsCourtSet = true
|
|
d.IsCourtSetIndirect = true
|
|
d.IsConditional = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
courtSet[r.ID] = true
|
|
deadlines = append(deadlines, d)
|
|
continue
|
|
}
|
|
|
|
// Anchor: prefer alt-anchor (e.g. priority_date for
|
|
// epa.grant.exa publish) when supplied, then parent's computed
|
|
// date (or user override), then trigger date.
|
|
baseDate := triggerDate
|
|
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
|
baseDate = *priorityDate
|
|
} else if r.ParentID != nil {
|
|
for _, prev := range rules {
|
|
if prev.ID == *r.ParentID {
|
|
if prev.SubmissionCode != nil {
|
|
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
|
baseDate = ov
|
|
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
|
baseDate = ref
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
|
|
// gate fires AND alt_* values exist, swap the primary duration
|
|
// to the alt values. This is distinct from combine_op below —
|
|
// alt-swap is a one-or-the-other choice keyed on flags, whereas
|
|
// combine_op computes both legs and picks max/min.
|
|
durationValue := r.DurationValue
|
|
durationUnit := r.DurationUnit
|
|
timing := ""
|
|
if r.Timing != nil {
|
|
timing = *r.Timing
|
|
}
|
|
if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
|
durationValue = *r.AltDurationValue
|
|
if r.AltDurationUnit != nil {
|
|
durationUnit = *r.AltDurationUnit
|
|
}
|
|
if r.AltRuleCode != nil {
|
|
d.RuleRef = *r.AltRuleCode
|
|
}
|
|
}
|
|
|
|
// User override on this rule: replace the calculated date with
|
|
// the user's date. Skip holiday rollover — the user's date is
|
|
// authoritative.
|
|
if r.SubmissionCode != nil {
|
|
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
|
d.OriginalDate = ov.Format("2006-01-02")
|
|
d.DueDate = ov.Format("2006-01-02")
|
|
d.WasAdjusted = false
|
|
d.AdjustmentReason = nil
|
|
d.IsOverridden = true
|
|
computed[*r.SubmissionCode] = ov
|
|
deadlines = append(deadlines, d)
|
|
continue
|
|
}
|
|
}
|
|
|
|
origDate, adjusted, wasAdj, reason := ApplyDuration(
|
|
baseDate, durationValue, durationUnit, timing, country, regime, holidays,
|
|
)
|
|
|
|
// combine_op composite: compute the alt leg too, apply max/min.
|
|
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
|
altOrig, altAdj, altWasAdj, altReason := ApplyDuration(
|
|
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
|
)
|
|
switch *r.CombineOp {
|
|
case "max":
|
|
if altAdj.After(adjusted) {
|
|
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
|
}
|
|
case "min":
|
|
if altAdj.Before(adjusted) {
|
|
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
|
}
|
|
}
|
|
}
|
|
|
|
d.OriginalDate = origDate.Format("2006-01-02")
|
|
d.DueDate = adjusted.Format("2006-01-02")
|
|
d.WasAdjusted = wasAdj
|
|
d.AdjustmentReason = reason
|
|
|
|
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
|
|
// Rules with priority='optional' AND primary_party='both' whose
|
|
// data-model parent is the proceeding's trigger anchor (parent
|
|
// has parent_id=NULL and is not court-set, i.e. the SoC root
|
|
// rule) represent a rule whose REAL triggering event sits
|
|
// outside the rule data — e.g. R.262(2) Erwiderung auf
|
|
// Vertraulichkeitsantrag anchors on SoC in the data, but the
|
|
// real trigger is the opposing party's confidentiality motion
|
|
// which may never happen. Without an explicit anchor on the
|
|
// rule itself, the projection must NOT claim a concrete date.
|
|
if !d.IsOverridden && !d.IsConditional &&
|
|
r.Priority == "optional" &&
|
|
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
|
|
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
|
|
d.IsConditional = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
d.WasAdjusted = false
|
|
d.AdjustmentReason = nil
|
|
// Mark this rule's ID as having an uncertain anchor so
|
|
// rules chaining off it also surface conditional via the
|
|
// parentIsCourtSet path.
|
|
courtSet[r.ID] = true
|
|
}
|
|
|
|
if r.SubmissionCode != nil {
|
|
computed[*r.SubmissionCode] = adjusted
|
|
}
|
|
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
|
|
// view both rely on sequence_order being the surface render order.
|
|
// (m/paliad#135)
|
|
sort.SliceStable(deadlines, func(i, j int) bool {
|
|
a, errA := uuid.Parse(deadlines[i].RuleID)
|
|
b, errB := uuid.Parse(deadlines[j].RuleID)
|
|
if errA != nil || errB != nil {
|
|
return false
|
|
}
|
|
return sequenceIndex[a] < sequenceIndex[b]
|
|
})
|
|
|
|
// 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. Different trigger groups keep their
|
|
// proceeding-sequence position — the chunk walk only sorts adjacent
|
|
// 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,
|
|
ProceedingNameEN: pickedProceeding.NameEN,
|
|
TriggerDate: triggerDateStr,
|
|
Deadlines: deadlines,
|
|
HiddenCount: hiddenCount,
|
|
}
|
|
// Sub-track routing keeps the user-picked proceeding's identity,
|
|
// so the trigger-event label rides on `pickedProceeding`.
|
|
if pickedProceeding.TriggerEventLabelDE != nil {
|
|
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
|
|
}
|
|
if pickedProceeding.TriggerEventLabelEN != nil {
|
|
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
|
}
|
|
// t-paliad-301 / m/paliad#132 Bug B — appeal_target-driven trigger
|
|
// label. When the request narrows to a specific appeal target, the
|
|
// "Auslösendes Ereignis" label describes the underlying decision
|
|
// (Endentscheidung / Kostenentscheidung / Anordnung /
|
|
// Schadensbemessung / Bucheinsicht) rather than the appeal
|
|
// proceeding itself. Overrides the proceeding's own
|
|
// trigger_event_label set above.
|
|
if opts.AppealTarget != "" {
|
|
if de := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de"); de != "" {
|
|
resp.TriggerEventLabel = de
|
|
}
|
|
if en := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en"); en != "" {
|
|
resp.TriggerEventLabelEN = en
|
|
}
|
|
}
|
|
if hasSubTrackNote {
|
|
resp.ContextualNote = subTrackNote.NoteDE
|
|
resp.ContextualNoteEN = subTrackNote.NoteEN
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
|
|
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
|
|
// chains), have no flag gating, no priority_date alt-anchor, no party
|
|
// classification, and no IsRootEvent / IsCourtSet semantics. The math
|
|
// is just: base + (timing-signed) duration → optional alt-leg combine
|
|
// → optional weekend/holiday rollover for calendar units.
|
|
//
|
|
// Timeline.ProceedingType / ProceedingName stay empty —
|
|
// EventDeadlineService owns the trigger-event metadata.
|
|
func calculateByTriggerEvent(
|
|
ctx context.Context,
|
|
triggerEventID int64,
|
|
triggerDateStr string,
|
|
opts CalcOptions,
|
|
catalog Catalog,
|
|
holidays HolidayCalendar,
|
|
courts CourtRegistry,
|
|
) (*Timeline, error) {
|
|
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
|
}
|
|
|
|
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
|
|
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
|
|
country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
|
}
|
|
|
|
rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(opts.RuleOverrides) > 0 {
|
|
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
|
}
|
|
|
|
deadlines := make([]TimelineEntry, 0, len(rules))
|
|
for _, r := range rules {
|
|
timing := ""
|
|
if r.Timing != nil {
|
|
timing = *r.Timing
|
|
}
|
|
baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration(
|
|
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays,
|
|
)
|
|
picked := baseAdj
|
|
original := baseRaw
|
|
wasAdj := baseChanged
|
|
reason := baseReason
|
|
|
|
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
|
altRaw, altAdj, altChanged, altReason := ApplyDuration(
|
|
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
|
)
|
|
switch *r.CombineOp {
|
|
case "max":
|
|
if altAdj.After(baseAdj) {
|
|
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
|
}
|
|
case "min":
|
|
if altAdj.Before(baseAdj) {
|
|
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
|
}
|
|
}
|
|
}
|
|
|
|
d := TimelineEntry{
|
|
RuleID: r.ID.String(),
|
|
Name: r.Name,
|
|
NameEN: r.NameEN,
|
|
Priority: r.Priority,
|
|
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
|
DueDate: picked.Format("2006-01-02"),
|
|
OriginalDate: original.Format("2006-01-02"),
|
|
WasAdjusted: wasAdj,
|
|
AdjustmentReason: reason,
|
|
DurationValue: r.DurationValue,
|
|
DurationUnit: r.DurationUnit,
|
|
Timing: timing,
|
|
}
|
|
if r.SubmissionCode != nil {
|
|
d.Code = *r.SubmissionCode
|
|
}
|
|
if r.PrimaryParty != nil {
|
|
d.Party = *r.PrimaryParty
|
|
}
|
|
if r.RuleCode != nil {
|
|
d.RuleRef = *r.RuleCode
|
|
}
|
|
if r.LegalSource != nil {
|
|
d.LegalSource = *r.LegalSource
|
|
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
|
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
|
}
|
|
if r.DeadlineNotes != nil {
|
|
d.Notes = *r.DeadlineNotes
|
|
}
|
|
if r.DeadlineNotesEn != nil {
|
|
d.NotesEN = *r.DeadlineNotesEn
|
|
}
|
|
deadlines = append(deadlines, d)
|
|
}
|
|
|
|
return &Timeline{
|
|
// Trigger-event responses don't carry proceeding metadata —
|
|
// EventDeadlineService.Calculate fills the trigger fields in
|
|
// the legacy CalculateResponse shape. Leaving these empty is
|
|
// the stable contract.
|
|
ProceedingType: "",
|
|
ProceedingName: "",
|
|
TriggerDate: triggerDateStr,
|
|
Deadlines: deadlines,
|
|
}, nil
|
|
}
|
|
|
|
// CalculateRule computes a single deadline from a rule + trigger date.
|
|
// Used by the v4 result-card click flow. Distinct from Calculate: no
|
|
// parent-chain walk, no full-timeline rendering — just one date out.
|
|
//
|
|
// When the rule is court-determined, DueDate is empty and
|
|
// IsCourtSet=true; the caller should disable the "Add to project" CTA.
|
|
//
|
|
// When the rule has a condition_expr gate and the caller's Flags
|
|
// satisfy it AND alt_duration_value is non-NULL, the calc swaps to
|
|
// alt_*. When the gate is not satisfied, the calc still proceeds with
|
|
// the base duration_value and surfaces FlagsRequired.
|
|
func CalculateRule(
|
|
ctx context.Context,
|
|
params CalcRuleParams,
|
|
catalog Catalog,
|
|
holidays HolidayCalendar,
|
|
courts CourtRegistry,
|
|
) (*RuleCalculation, error) {
|
|
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
|
|
}
|
|
|
|
rule, pt, err := resolveRule(ctx, params, catalog)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mandWire, _ := wireFlagsFromPriority(rule.Priority)
|
|
out := &RuleCalculation{
|
|
Rule: RuleCalculationRule{
|
|
ID: rule.ID.String(),
|
|
NameDE: rule.Name,
|
|
NameEN: rule.NameEN,
|
|
DurationValue: rule.DurationValue,
|
|
DurationUnit: rule.DurationUnit,
|
|
IsMandatory: mandWire,
|
|
},
|
|
Proceeding: RuleCalculationProceeding{
|
|
Code: pt.Code,
|
|
NameDE: pt.Name,
|
|
NameEN: pt.NameEN,
|
|
},
|
|
TriggerDate: params.TriggerDate,
|
|
}
|
|
if rule.SubmissionCode != nil {
|
|
out.Rule.LocalCode = *rule.SubmissionCode
|
|
}
|
|
if rule.RuleCode != nil {
|
|
out.Rule.RuleRef = *rule.RuleCode
|
|
}
|
|
if rule.LegalSource != nil {
|
|
out.Rule.LegalSource = *rule.LegalSource
|
|
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
|
|
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
|
|
}
|
|
if rule.PrimaryParty != nil {
|
|
out.Rule.Party = *rule.PrimaryParty
|
|
}
|
|
if rule.DeadlineNotes != nil {
|
|
out.Rule.NotesDE = *rule.DeadlineNotes
|
|
}
|
|
if rule.DeadlineNotesEn != nil {
|
|
out.Rule.NotesEN = *rule.DeadlineNotesEn
|
|
}
|
|
// Slice 9 (t-paliad-195) replacement for the dropped condition_flag
|
|
// text[] enumeration: walk the jsonb gate to pull out flag-leaf
|
|
// names. Returns nil on an unconditional rule.
|
|
out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr)
|
|
|
|
// Court-determined: no calculable date.
|
|
if rule.IsCourtSet {
|
|
out.IsCourtSet = true
|
|
return out, nil
|
|
}
|
|
|
|
// Resolve flag-conditional duration via the unified condition_expr
|
|
// evaluator.
|
|
flagSet := make(map[string]struct{}, len(params.Flags))
|
|
for _, f := range params.Flags {
|
|
flagSet[f] = struct{}{}
|
|
}
|
|
durationValue := rule.DurationValue
|
|
durationUnit := rule.DurationUnit
|
|
gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet)
|
|
if gateMet && HasConditionExpr(rule.ConditionExpr) {
|
|
out.FlagsApplied = out.FlagsRequired
|
|
if rule.AltDurationValue != nil {
|
|
durationValue = *rule.AltDurationValue
|
|
}
|
|
if rule.AltDurationUnit != nil {
|
|
durationUnit = *rule.AltDurationUnit
|
|
}
|
|
if rule.AltRuleCode != nil {
|
|
out.Rule.RuleRef = *rule.AltRuleCode
|
|
}
|
|
}
|
|
|
|
// Zero-duration non-court-determined rules are "filed at the same
|
|
// time as parent" markers: effectively mean "due on the trigger
|
|
// date itself".
|
|
if durationValue == 0 {
|
|
out.OriginalDate = params.TriggerDate
|
|
out.DueDate = params.TriggerDate
|
|
return out, nil
|
|
}
|
|
|
|
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
|
country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
|
|
}
|
|
|
|
timing := ""
|
|
if rule.Timing != nil {
|
|
timing = *rule.Timing
|
|
}
|
|
endDate, adjusted, wasAdj, reason := ApplyDuration(
|
|
triggerDate, durationValue, durationUnit, timing, country, regime, holidays,
|
|
)
|
|
out.OriginalDate = endDate.Format("2006-01-02")
|
|
out.DueDate = adjusted.Format("2006-01-02")
|
|
out.WasAdjusted = wasAdj
|
|
out.AdjustmentReason = reason
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
|
|
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
|
|
// frontend uses the latter form (it has the pill context) and the
|
|
// programmatic / test caller can use the former.
|
|
func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) {
|
|
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
|
|
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
|
|
}
|
|
|
|
if params.RuleID != "" {
|
|
rule, err := catalog.LoadRuleByID(ctx, params.RuleID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if rule.ProceedingTypeID == nil {
|
|
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
|
|
}
|
|
pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
|
|
}
|
|
return rule, pt, nil
|
|
}
|
|
|
|
rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return rule, pt, nil
|
|
}
|
|
|
|
// ApplyRuleOverrides replaces rules whose ID appears in `overrides`
|
|
// with the override row, and appends any override whose ID isn't in
|
|
// the source list (net-new drafts the rule editor wants to preview).
|
|
//
|
|
// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor
|
|
// passes the draft as an override so Calculate runs against the
|
|
// proposed shape without writing to the DB. Empty overrides slice =
|
|
// pass-through.
|
|
func ApplyRuleOverrides(src, overrides []Rule) []Rule {
|
|
if len(overrides) == 0 {
|
|
return src
|
|
}
|
|
byID := make(map[uuid.UUID]Rule, len(overrides))
|
|
for _, o := range overrides {
|
|
byID[o.ID] = o
|
|
}
|
|
out := make([]Rule, 0, len(src)+len(overrides))
|
|
seen := make(map[uuid.UUID]bool, len(overrides))
|
|
for _, r := range src {
|
|
if ov, ok := byID[r.ID]; ok {
|
|
out = append(out, ov)
|
|
seen[ov.ID] = true
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
for _, o := range overrides {
|
|
if seen[o.ID] {
|
|
continue
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
|
|
// pair from the unified priority enum so the wire shape stays
|
|
// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3):
|
|
//
|
|
// 'mandatory' → (true, false)
|
|
// 'optional' → (true, true)
|
|
// 'recommended' → (false, false)
|
|
// 'informational' → (false, false)
|
|
// (unknown) → (true, false)
|
|
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
|
switch priority {
|
|
case "mandatory":
|
|
return true, false
|
|
case "optional":
|
|
return true, true
|
|
case "recommended":
|
|
return false, false
|
|
case "informational":
|
|
return false, false
|
|
default:
|
|
return true, false
|
|
}
|
|
}
|
|
|
|
// AllFlagsSet is retained as a tiny utility for callers that have a
|
|
// flat list of flag strings + a flag-set lookup. The new condition_expr
|
|
// gate is the canonical evaluator; this helper exists for forward-
|
|
// compat with any future caller that wants the legacy AND-over-list
|
|
// semantic without rebuilding the jsonb.
|
|
func AllFlagsSet(required []string, set map[string]struct{}) bool {
|
|
return allFlagsSet(required, set)
|
|
}
|
|
|
|
// WireFlagsFromPriority is the public form of wireFlagsFromPriority so
|
|
// the paliad-side test suite (which historically asserted the mapping
|
|
// directly) can still test the contract.
|
|
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
|
return wireFlagsFromPriority(priority)
|
|
}
|
|
|
|
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
|
|
// appears after its parent_id ancestor. Ties (rules at the same depth)
|
|
// preserve their input order — which the catalog returns in
|
|
// sequence_order. Used by Calculate to ensure the parent-state checks
|
|
// (courtSet[parent], computed[parent_code]) see populated entries even
|
|
// when sequence_order lists a "before"-timed child BEFORE its parent
|
|
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
|
|
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
|
|
//
|
|
// Rules whose parent_id is missing from the rule slice (cross-tree
|
|
// references that the per-proceeding filter dropped) are treated as
|
|
// depth 0 — they walk in their original sequence position.
|
|
//
|
|
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
|
|
// rule chain that revisits a node is broken at depth 0; production
|
|
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
|
|
// the calculator.
|
|
func topoSortByParentDepth(rules []Rule) []Rule {
|
|
byID := make(map[uuid.UUID]Rule, len(rules))
|
|
inSlice := make(map[uuid.UUID]bool, len(rules))
|
|
for _, r := range rules {
|
|
byID[r.ID] = r
|
|
inSlice[r.ID] = true
|
|
}
|
|
|
|
depth := make(map[uuid.UUID]int, len(rules))
|
|
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
|
|
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
|
|
if d, ok := depth[id]; ok {
|
|
return d
|
|
}
|
|
if seen[id] {
|
|
depth[id] = 0
|
|
return 0
|
|
}
|
|
seen[id] = true
|
|
r, ok := byID[id]
|
|
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
|
|
depth[id] = 0
|
|
return 0
|
|
}
|
|
d := resolve(*r.ParentID, seen) + 1
|
|
depth[id] = d
|
|
return d
|
|
}
|
|
for _, r := range rules {
|
|
resolve(r.ID, map[uuid.UUID]bool{})
|
|
}
|
|
|
|
out := make([]Rule, len(rules))
|
|
copy(out, rules)
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
return depth[out[i].ID] < depth[out[j].ID]
|
|
})
|
|
return out
|
|
}
|