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
1763 lines
69 KiB
Go
1763 lines
69 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// FristenrechnerService renders the Paliad public Fristenrechner's response
|
|
// shape from DB-stored rules. It sits on top of DeadlineRuleService and
|
|
// HolidayService and produces the bilingual, rule-code + notes-rich payload
|
|
// that /tools/fristenrechner's client expects.
|
|
//
|
|
// The UI-facing response is distinct from the plain calculator in
|
|
// DeadlineCalculator: it adds IsRootEvent, IsCourtSet, RuleRef, Notes,
|
|
// party color classes, and keeps the result ordered by sequence_order
|
|
// within each proceeding type.
|
|
type FristenrechnerService struct {
|
|
rules *DeadlineRuleService
|
|
holidays *HolidayService
|
|
courts *CourtService
|
|
}
|
|
|
|
// NewFristenrechnerService wires the service to its dependencies.
|
|
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
|
|
return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
|
|
}
|
|
|
|
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
|
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
|
//
|
|
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy IsMandatory +
|
|
// IsOptional fields — Priority is the canonical wire signal. The
|
|
// frontend reads priorityRendering(d) which since Slice 8 has
|
|
// priority as the primary input; Slice 9 removes the legacy fallback
|
|
// branch from the frontend too.
|
|
type UIDeadline struct {
|
|
RuleID string `json:"ruleId,omitempty"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
NameEN string `json:"nameEN"`
|
|
Party string `json:"party"`
|
|
// Priority is the 4-way enum the rule-editor + save-modal logic
|
|
// reads: 'mandatory' | 'recommended' | 'optional' | 'informational'.
|
|
// Informational rules render as notice cards (no save button, no
|
|
// checkbox) — the visible UX win of Phase 3 on today's F/F rules.
|
|
Priority string `json:"priority"`
|
|
RuleRef string `json:"ruleRef"`
|
|
LegalSource string `json:"legalSource,omitempty"`
|
|
// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
|
|
// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
|
|
// renders this in the deadline card meta line; falls back to
|
|
// RuleRef when LegalSource is empty.
|
|
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
|
// LegalSourceURL is the youpc.org/laws permalink when the cited
|
|
// body is hosted there (UPCRoP / UPCA / UPCS today). Empty for
|
|
// DE/EPA/EU bodies — the renderer shows display text without a link.
|
|
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
NotesEN string `json:"notesEN,omitempty"`
|
|
DueDate string `json:"dueDate"`
|
|
OriginalDate string `json:"originalDate"`
|
|
WasAdjusted bool `json:"wasAdjusted"`
|
|
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
|
IsRootEvent bool `json:"isRootEvent"`
|
|
IsCourtSet bool `json:"isCourtSet"`
|
|
// ConditionExpr is the jsonb gate predicate (design §2.4 long
|
|
// form) emitted verbatim so the rule editor (Slice 11) + admin
|
|
// surfaces can show the rule's gating shape. NULL / empty when
|
|
// the rule is unconditional. Frontend reads this to render the
|
|
// "Mit Nichtigkeitswiderklage" hint chips.
|
|
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
|
// IsCourtSetIndirect is true when IsCourtSet is true because the
|
|
// rule chains off a court-determined parent (e.g. RoP.151
|
|
// Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the
|
|
// Hauptentscheidung itself is the court-set anchor). Direct
|
|
// court-determined rules (Urteil / Beschluss / Anordnung
|
|
// themselves) keep IsCourtSet=true with IsCourtSetIndirect=false.
|
|
// The frontend uses this to render "unbestimmt" for indirect
|
|
// cases instead of "wird vom Gericht bestimmt", which is only
|
|
// strictly correct for the direct ones — the indirect deadline
|
|
// is computed off a parent date that the COURT sets, not by the
|
|
// court itself.
|
|
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
|
// IsConditional signals the rule's anchor is uncertain — no
|
|
// concrete date can be projected. Set when the rule depends on:
|
|
// - a court-set ancestor whose date isn't anchored
|
|
// (overlaps with IsCourtSetIndirect; the two are kept
|
|
// distinct because IsCourtSet wraps a specific UX message
|
|
// "wird vom Gericht bestimmt", whereas IsConditional is
|
|
// the broader "render as 'abhängig von <parent>'" signal)
|
|
// - timing='before' rules whose forward anchor isn't set
|
|
// (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month
|
|
// before the oral hearing — without the hearing date, the
|
|
// backward arithmetic against the trigger date is meaningless)
|
|
// - optional opposing-side rules whose true triggering event
|
|
// hasn't been recorded for this project (e.g. R.262(2)
|
|
// Erwiderung auf Vertraulichkeitsantrag — the data-model
|
|
// parent is the SoC, but the real trigger is the opposing
|
|
// party's confidentiality motion which may never happen)
|
|
// When true, DueDate and OriginalDate are empty and the frontend
|
|
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
|
// date. Suppressed by an explicit user anchor (IsOverridden wins).
|
|
// (t-paliad-289)
|
|
IsConditional bool `json:"isConditional,omitempty"`
|
|
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
|
// parent's identity so the frontend can render
|
|
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
|
// Populated whenever the rule has a parent_id, not only when
|
|
// conditional — keeps the wire shape stable. Empty for root rules.
|
|
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
|
ParentRuleName string `json:"parentRuleName,omitempty"`
|
|
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
|
IsOverridden bool `json:"isOverridden,omitempty"`
|
|
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
|
|
// the rule so the frontend knows whether to render the per-event-card
|
|
// caret affordance, and which choice-kinds to populate the popover
|
|
// with. NULL / empty for rules with no choices. (t-paliad-265)
|
|
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
|
// AppellantContext is the per-decision appellant pick that applies
|
|
// to descendants of the closest ancestor decision card with a
|
|
// PerCardAppellant set. Empty when no per-card override is in
|
|
// effect (page-level ?appellant= still applies in that case).
|
|
// Frontend bucketer prefers this over the page-level appellant when
|
|
// non-empty. (t-paliad-265)
|
|
AppellantContext string `json:"appellantContext,omitempty"`
|
|
// IsHidden marks a card the user has previously hidden via a
|
|
// skip choice. Only ever true when CalcOptions.IncludeHidden is
|
|
// set — the toggle re-surfaces these rows so the user can either
|
|
// keep them faded for context or un-hide them via the inline
|
|
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
|
|
IsHidden bool `json:"isHidden,omitempty"`
|
|
}
|
|
|
|
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
|
type UIResponse struct {
|
|
ProceedingType string `json:"proceedingType"`
|
|
ProceedingName string `json:"proceedingName"`
|
|
// ProceedingNameEN carries the English label of the proceeding so
|
|
// the frontend can switch on lang. Empty when the proceeding has no
|
|
// English label populated; the frontend falls back to ProceedingName.
|
|
// Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf
|
|
// "Trigger event" label fell back to the DE proceedingName whenever
|
|
// the timeline had no root rule (e.g. for sub-track proceedings like
|
|
// upc.ccr.cfi that have no native rules).
|
|
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
Deadlines []UIDeadline `json:"deadlines"`
|
|
// ContextualNote / ContextualNoteEN surface a banner above the
|
|
// timeline. Populated by sub-track routing (m/paliad#58): when the
|
|
// user picks a proceeding that is normally a sub-track of another
|
|
// proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
|
|
// with_ccr), the renderer routes to the parent's rules but keeps
|
|
// the user-picked code/name as the response identity and surfaces a
|
|
// note explaining the framing.
|
|
ContextualNote string `json:"contextualNote,omitempty"`
|
|
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
|
// TriggerEventLabel / TriggerEventLabelEN: optional caption for the
|
|
// /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated
|
|
// from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121).
|
|
// The frontend prefers this over the proceedingName fallback that
|
|
// fires when no rule has IsRootEvent=true — UPC Appeal needed it
|
|
// because all its rules carry a non-zero duration off the trigger
|
|
// date so no rule is the "anchor". The trigger event for UPC Appeal
|
|
// is the appealable first-instance decision (m/paliad#81).
|
|
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
|
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
|
// HiddenCount is the number of rules whose submission_code is in
|
|
// CalcOptions.SkipRules AND whose condition_expr gate passes —
|
|
// i.e. how many rows the user has hidden in this projection
|
|
// regardless of the IncludeHidden toggle state. The frontend uses
|
|
// this to render the "Ausgeblendete (N)" badge on the toggle even
|
|
// when the toggle is OFF (so users know there's something to
|
|
// re-surface). (t-paliad-290 / m/paliad#122)
|
|
HiddenCount int `json:"hiddenCount"`
|
|
}
|
|
|
|
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
|
var ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
|
|
|
// CalcOptions carries optional inputs for Calculate. Callers can leave fields
|
|
// empty/nil for the legacy behaviour.
|
|
//
|
|
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt =
|
|
// 'priority_date' (e.g. epa.grant.exa.ep_grant.publish per Art. 93 EPÜ) use
|
|
// this date as their base instead of the parent's adjusted date / the
|
|
// trigger date.
|
|
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
|
|
// "with_amend", "with_cci"). A rule with a non-empty condition_flag
|
|
// array renders iff EVERY element of that array is in Flags. When all
|
|
// are present AND alt_duration_value is non-NULL, the calculator
|
|
// swaps to alt_*; when set + flags not satisfied, the rule is
|
|
// suppressed entirely (skipped from the result list).
|
|
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
|
|
// of the computed deadline date. When a child rule chains off a
|
|
// parent whose code is in AnchorOverrides, the override date is
|
|
// used as the anchor instead of the parent's calculated date. Lets
|
|
// the user set a real court-extended deadline, or a court-set
|
|
// decision date once known, and have downstream rules re-flow.
|
|
type CalcOptions struct {
|
|
PriorityDateStr string
|
|
Flags []string
|
|
AnchorOverrides map[string]string
|
|
// CourtID picks the forum the proceeding is filed in (e.g. "upc-ld-paris",
|
|
// "de-bgh"). The calculator resolves it to (country, regime) for non-
|
|
// working-day computation. Empty falls back to UPC München (DE/UPC) for
|
|
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
|
// behaviour for callers that don't yet send a court.
|
|
CourtID string
|
|
// TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
|
// rules: when non-nil, the proceedingCode argument is ignored and the
|
|
// service selects rules WHERE trigger_event_id = *TriggerEventIDFilter
|
|
// instead of WHERE proceeding_type_id = .... Set by
|
|
// EventDeadlineService.Calculate so the unified backend can serve the
|
|
// "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width
|
|
// matches paliad.trigger_events.id (bigint, mig 028). See design
|
|
// §3.D (calculator unification).
|
|
TriggerEventIDFilter *int64
|
|
// RuleOverrides substitutes specific rules in the calculator's
|
|
// rule list with caller-supplied in-memory rows. Used by the
|
|
// rule-editor preview (Slice 11a, t-paliad-191): the admin's
|
|
// draft replaces its published peer (matched by rule.ID) so the
|
|
// editor sees "what would this rule do?" without writing to the
|
|
// DB. Net-new drafts (no draft_of peer) get appended to the rule
|
|
// list so their effect lights up on a fresh evaluation.
|
|
//
|
|
// Empty / nil = no override (default). Overrides apply equally to
|
|
// the proceeding-tree and trigger-event branches.
|
|
RuleOverrides []models.DeadlineRule
|
|
|
|
// Per-event-card choice overlays (t-paliad-265 / m/paliad#96).
|
|
// Keyed by paliad.deadline_rules.submission_code — same key
|
|
// AnchorOverrides uses.
|
|
//
|
|
// - PerCardAppellant: maps a decision-card's submission_code to the
|
|
// user-picked appellant ("claimant"|"defendant"|"both"|"none").
|
|
// The engine walks the parent chain of each rule and stamps the
|
|
// resulting UIDeadline.AppellantContext from the closest ancestor
|
|
// decision with a pick. The frontend bucketer then prefers the
|
|
// per-rule context over the page-level appellant.
|
|
// - SkipRules: set of submission_code values whose rules (and any
|
|
// descendants) the user has opted out of for this projection.
|
|
// Same suppression path as a failed condition_expr gate.
|
|
// - IncludeCCRFor: set of submission_code values for rules where
|
|
// the user opted in to the include-CCR choice (Klageerwiderung
|
|
// cards). v1 simplification (design §4.2 #2): if non-empty,
|
|
// "with_ccr" is appended to the flag set before gate
|
|
// evaluation. Correct for single-CCR-entry-point proceedings
|
|
// (UPC INF + DE LG today). Multi-CCR scope is a future expansion.
|
|
PerCardAppellant map[string]string
|
|
SkipRules map[string]struct{}
|
|
IncludeCCRFor map[string]struct{}
|
|
|
|
// IncludeHidden re-surfaces rules whose submission_code is in
|
|
// SkipRules (t-paliad-290 / m/paliad#122). When true:
|
|
// - Skipped rules are NOT dropped from the result; they render
|
|
// with UIDeadline.IsHidden=true so the frontend can fade them.
|
|
// - Descendant suppression is bypassed (the skipped parent is
|
|
// present in the result, so children compute their dates off
|
|
// it as if the user had never hidden it).
|
|
// Default false preserves the original skip semantic (drop rule +
|
|
// suppress descendants). HiddenCount on UIResponse is independent
|
|
// of this flag — it always reflects the number of hide-eligible
|
|
// rows so the toggle's count badge stays accurate.
|
|
IncludeHidden bool
|
|
}
|
|
|
|
// 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"). When a
|
|
// rule's condition_flag array is non-empty, the rule renders iff
|
|
// EVERY element is in opts.Flags; rules that fail this gate are
|
|
// suppressed entirely (used by Phase B1 cross-flow rules that should
|
|
// only appear with their flag).
|
|
// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
|
|
// set (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. Used for court-extended deadlines and for entering
|
|
// court-set decision dates post-hoc.
|
|
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
|
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
|
|
// branch (Pipeline-C unified rules; mig 085 moved 77 rows out of
|
|
// paliad.event_deadlines into paliad.deadline_rules carrying a
|
|
// non-NULL trigger_event_id). proceedingCode is ignored on this
|
|
// path. EventDeadlineService.Calculate is the sole caller today;
|
|
// future "event-trigger" surfaces (design §5) plug in here too.
|
|
if opts.TriggerEventIDFilter != nil {
|
|
return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts)
|
|
}
|
|
|
|
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 (design §4.2 #2, t-paliad-265): when any
|
|
// IncludeCCRFor entry exists, we treat with_ccr as set in the flag
|
|
// context. Correct for single-CCR-entry-point proceedings (UPC INF +
|
|
// DE LG today). Multi-CCR scope is a future expansion that would
|
|
// thread the include set through the gate evaluator per-rule.
|
|
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.
|
|
var pt struct {
|
|
ID int `db:"id"`
|
|
Code string `db:"code"`
|
|
Name string `db:"name"`
|
|
NameEN string `db:"name_en"`
|
|
Jurisdiction *string `db:"jurisdiction"`
|
|
TriggerEventLabelDE *string `db:"trigger_event_label_de"`
|
|
TriggerEventLabelEN *string `db:"trigger_event_label_en"`
|
|
}
|
|
err = s.rules.db.GetContext(ctx, &pt,
|
|
`SELECT id, code, name, name_en, jurisdiction,
|
|
trigger_event_label_de, trigger_event_label_en
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, proceedingCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, 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 (Code/Name/NameEN) 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. A note
|
|
// surfaces the framing.
|
|
var pickedProceeding = pt
|
|
var subTrackNote SubTrackRouting
|
|
var hasSubTrackNote bool
|
|
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
|
|
subTrackNote = route
|
|
hasSubTrackNote = true
|
|
// Re-resolve to the parent proceeding for rule lookup.
|
|
err = s.rules.db.GetContext(ctx, &pt,
|
|
`SELECT id, code, name, name_en, jurisdiction,
|
|
trigger_event_label_de, trigger_event_label_en
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, route.ParentCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err)
|
|
}
|
|
// Merge default flags into the user's flag set so the gated
|
|
// rules render. User-supplied flags win on conflict (they're
|
|
// already in flagSet); default flags only add what's missing.
|
|
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. UPC proceedings
|
|
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
|
|
// DE proceedings default to DE (no supranational regime).
|
|
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
|
country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
|
}
|
|
|
|
rules, err := s.rules.List(ctx, &pt.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(opts.RuleOverrides) > 0 {
|
|
rules = applyRuleOverrides(rules, opts.RuleOverrides)
|
|
}
|
|
|
|
// Walk the rule list in sequence_order (already sorted by the query) and
|
|
// compute each entry, keeping a code→date map so RelativeTo / parent_id
|
|
// references resolve to the adjusted predecessor date.
|
|
computed := make(map[string]time.Time, len(rules))
|
|
courtSet := make(map[uuid.UUID]bool, len(rules))
|
|
deadlines := make([]UIDeadline, 0, len(rules))
|
|
|
|
// Pre-pass: identify rules flagged is_court_set=true in the data so
|
|
// order-of-evaluation in sequence_order doesn't matter for the
|
|
// parent-court-set check below. Without this, a rule processed
|
|
// earlier than its court-set parent (e.g. R.109(1) Antrag auf
|
|
// Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung
|
|
// sequence_order=50 in upc.inf.cfi) misses the court-set propagation
|
|
// and computes a meaningless date — for timing='before' rules, that
|
|
// produces a backward offset from the trigger date, which has no
|
|
// semantic relationship to the rule. (t-paliad-289)
|
|
for _, r := range rules {
|
|
if r.IsCourtSet {
|
|
courtSet[r.ID] = true
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
|
|
for _, r := range rules {
|
|
ruleByID[r.ID] = r
|
|
}
|
|
|
|
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
|
|
// for membership tests; the engine reads them but doesn't mutate.
|
|
skipRules := opts.SkipRules
|
|
perCardAppellant := opts.PerCardAppellant
|
|
// skippedIDs accumulates the set of rule UUIDs whose timeline entry
|
|
// the user has opted out of. Walking in sequence_order means a
|
|
// child rule's parent has already been classified — so descendant
|
|
// suppression is a one-pass parent_id lookup.
|
|
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
|
// hiddenCount counts rows whose submission_code is in skipRules
|
|
// AND that pass the condition_expr gate — i.e. rows the user has
|
|
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
|
|
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
|
|
// IncludeHidden is off and the rows aren't in the result list.
|
|
// (t-paliad-290 / m/paliad#122)
|
|
hiddenCount := 0
|
|
// appellantContext maps a rule UUID to the appellant value that
|
|
// applies to its descendants. A rule that has its own PerCardAppellant
|
|
// pick stamps itself with that value; a rule whose parent has a
|
|
// context inherits it.
|
|
appellantContext := make(map[uuid.UUID]string, len(rules))
|
|
|
|
for _, r := range rules {
|
|
// 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
|
|
// (legacy "swap-on-flag" pattern, e.g. with_ccr).
|
|
gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
|
if !gateMet && r.AltDurationValue == nil {
|
|
continue
|
|
}
|
|
|
|
// SkipRules suppression (t-paliad-265): the user has marked
|
|
// this rule (or one of its ancestors) as "don't consider for
|
|
// this case". Drop the row entirely AND record the rule ID so
|
|
// descendants suppress too.
|
|
//
|
|
// 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. Descendants are NOT cascade-suppressed
|
|
// in that mode either — the un-suppressed parent computes its
|
|
// date normally, so children compute off it as usual. Either
|
|
// way we count the hide for the toggle's badge.
|
|
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
|
|
}
|
|
|
|
d := UIDeadline{
|
|
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,
|
|
}
|
|
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 *models.DeadlineRule
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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 (which they can
|
|
// once they know the real decision date).
|
|
parentOverridden := false
|
|
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
|
|
if parentRule.SubmissionCode != nil {
|
|
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
|
|
parentOverridden = true
|
|
}
|
|
}
|
|
}
|
|
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 (Zwischenverfahren /
|
|
// Mündliche Verhandlung / Entscheidung etc.)
|
|
// 3. parent set, court-determined → IsCourtSet (waypoint)
|
|
// 4. parent set, NOT court-determined → "filed-with-parent"
|
|
// semantic: rule is filed AT THE SAME TIME as its parent
|
|
// (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says
|
|
// Application to amend / Counterclaim for infringement are
|
|
// INCLUDED in the Defence to revocation). Use the parent's
|
|
// computed date.
|
|
//
|
|
// 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 {
|
|
// User override always wins.
|
|
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 parent is court-set, we have nothing to inherit —
|
|
// fall through to court-set marking.
|
|
if parentIsCourtSet {
|
|
// Indirect: this rule isn't itself court-determined,
|
|
// it's blocked because its parent is. UI should say
|
|
// "unbestimmt", not "wird vom Gericht bestimmt".
|
|
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 — shouldn't
|
|
// happen given sequence_order). Treat as indirect
|
|
// court-set: the date is unknown but the rule
|
|
// itself isn't a court action.
|
|
d.IsCourtSet = true
|
|
d.IsCourtSetIndirect = true
|
|
d.IsConditional = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
courtSet[r.ID] = true
|
|
}
|
|
}
|
|
} else {
|
|
// Buckets 2 + 3: court-determined directly (the rule
|
|
// itself is a hearing / decision / order or has
|
|
// primary_party='court'). The label "wird vom Gericht
|
|
// bestimmt" is strictly correct here — keep
|
|
// IsCourtSetIndirect=false.
|
|
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. The user can re-run
|
|
// with the actual decision date once the court issues it (or
|
|
// supplied via AnchorOverrides).
|
|
//
|
|
// This is the RoP.151 case (Antrag auf Kostenentscheidung is
|
|
// "1 Monat ab Hauptentscheidung") — the rule has a real
|
|
// duration but its anchor is the court-set parent. The UI
|
|
// should say "unbestimmt", not "wird vom Gericht bestimmt":
|
|
// the date isn't directly determined by the court, it's
|
|
// derived from a date the court sets.
|
|
//
|
|
// timing='before' rules end up here too — a rule with
|
|
// "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the
|
|
// oral hearing as its parent; if the hearing date isn't set,
|
|
// the backward arithmetic against the trigger date is
|
|
// meaningless. The pre-pass above ensures courtSet[oral.ID]
|
|
// is true even when the oral hearing rule is processed later
|
|
// in sequence_order. 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 {
|
|
// Linear scan is fine — rule trees are < 20 entries.
|
|
for _, prev := range rules {
|
|
if prev.ID == *r.ParentID {
|
|
if prev.SubmissionCode != nil {
|
|
// User override on the parent rule wins over
|
|
// the calculated date — lets the user redirect
|
|
// downstream from a real (court-extended,
|
|
// court-set) date.
|
|
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. Mutually
|
|
// exclusive in the live corpus today (no rule sets both).
|
|
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. Downstream rules that chain off this rule will
|
|
// see the override via the parent-anchor lookup above.
|
|
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, s.holidays,
|
|
)
|
|
|
|
// combine_op composite: compute the alt leg too, apply max/min.
|
|
// No proceeding-tree rules carry combine_op today (it's a
|
|
// future-friendly column the rule editor will surface). When
|
|
// present, the gate-met / alt-swap branch above has been
|
|
// skipped, so the comparison is between the unmodified base
|
|
// (durationValue/Unit) and the alt (AltDurationValue/Unit).
|
|
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
|
altOrig, altAdj, altWasAdj, altReason := applyDuration(
|
|
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.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 (user clicks "Datum setzen" after the motion
|
|
// arrives), the projection must NOT claim a concrete date.
|
|
//
|
|
// In the live corpus this catches confidentiality_response;
|
|
// every other optional+both rule has a court-set ancestor and
|
|
// is already caught by the parentIsCourtSet branches above.
|
|
// Suppressed when IsOverridden (the user has anchored the rule
|
|
// — the date is real) or when the rule has already been marked
|
|
// IsConditional by an earlier branch.
|
|
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 (no rule currently chains off
|
|
// confidentiality_response in the live corpus, but the
|
|
// extension keeps the propagation semantics consistent).
|
|
courtSet[r.ID] = true
|
|
}
|
|
|
|
if r.SubmissionCode != nil {
|
|
computed[*r.SubmissionCode] = adjusted
|
|
}
|
|
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,
|
|
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` (e.g.
|
|
// upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not
|
|
// upc.ccr.cfi's own — which is fine: the sub-track note already
|
|
// explains the framing).
|
|
if pickedProceeding.TriggerEventLabelDE != nil {
|
|
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
|
|
}
|
|
if pickedProceeding.TriggerEventLabelEN != nil {
|
|
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
|
}
|
|
if hasSubTrackNote {
|
|
resp.ContextualNote = subTrackNote.NoteDE
|
|
resp.ContextualNoteEN = subTrackNote.NoteEN
|
|
}
|
|
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")
|
|
|
|
// CalcRuleParams identifies a single rule and the inputs needed to
|
|
// compute one deadline from it. Caller supplies either RuleID OR the
|
|
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
|
|
// hand from the concept-card pill it just received a click on.
|
|
type CalcRuleParams struct {
|
|
RuleID string // optional — UUID
|
|
ProceedingCode string // optional — used with RuleLocalCode
|
|
RuleLocalCode string // optional — paliad.deadline_rules.code
|
|
TriggerDate string // required — YYYY-MM-DD
|
|
Flags []string // optional — condition_flag inputs
|
|
CourtID string // optional — selects holiday calendar; defaults via proceeding's jurisdiction
|
|
}
|
|
|
|
// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc
|
|
// response that backs the result-card click → calc-panel flow. Distinct
|
|
// from UIDeadline (which represents one rendered timeline row inside a
|
|
// full-proceeding response): RuleCalculation is self-contained — caller
|
|
// gets the rule metadata + the computed date in one payload, no separate
|
|
// proceeding-types lookup needed.
|
|
//
|
|
// Trigger semantics: TriggerDate is the immediate parent event's
|
|
// effective date — i.e. when the user clicks "Duplik" in the card and
|
|
// types "2026-05-05", they mean "I received the Replik on 2026-05-05".
|
|
// We do NOT walk the parent chain; callers wanting the full timeline
|
|
// for a proceeding still go through Calculate.
|
|
type RuleCalculation struct {
|
|
Rule RuleCalculationRule `json:"rule"`
|
|
Proceeding RuleCalculationProceeding `json:"proceeding"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
OriginalDate string `json:"originalDate"`
|
|
DueDate string `json:"dueDate"`
|
|
WasAdjusted bool `json:"wasAdjusted"`
|
|
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
|
IsCourtSet bool `json:"isCourtSet"`
|
|
// FlagsApplied lists the condition_flag values from the rule that
|
|
// the caller's Flags satisfied. Empty when the rule has no
|
|
// condition_flag, OR when the caller didn't satisfy the gate. Lets
|
|
// the frontend show "Mit Nichtigkeitswiderklage angewandt" hints.
|
|
FlagsApplied []string `json:"flagsApplied,omitempty"`
|
|
// FlagsRequired is the rule's condition_flag in canonical order so
|
|
// the frontend can render checkboxes for each flag the rule gates on.
|
|
FlagsRequired []string `json:"flagsRequired,omitempty"`
|
|
}
|
|
|
|
// RuleCalculationRule mirrors the small subset of DeadlineRule the
|
|
// frontend needs to render the calc panel.
|
|
type RuleCalculationRule struct {
|
|
ID string `json:"id"`
|
|
LocalCode string `json:"localCode,omitempty"`
|
|
NameDE string `json:"nameDE"`
|
|
NameEN string `json:"nameEN"`
|
|
RuleRef string `json:"ruleRef,omitempty"`
|
|
LegalSource string `json:"legalSource,omitempty"`
|
|
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
|
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
|
DurationValue int `json:"durationValue"`
|
|
DurationUnit string `json:"durationUnit"`
|
|
Party string `json:"party,omitempty"`
|
|
IsMandatory bool `json:"isMandatory"`
|
|
NotesDE string `json:"notesDE,omitempty"`
|
|
NotesEN string `json:"notesEN,omitempty"`
|
|
}
|
|
|
|
// RuleCalculationProceeding identifies the proceeding context for the
|
|
// rule. Used by the frontend for display + by the add-to-project flow.
|
|
type RuleCalculationProceeding struct {
|
|
Code string `json:"code"`
|
|
NameDE string `json:"nameDE"`
|
|
NameEN string `json:"nameEN"`
|
|
}
|
|
|
|
// 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 (primary_party='court' or event_type
|
|
// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true;
|
|
// the caller should disable the "Add to project" CTA in that case.
|
|
//
|
|
// When the rule has condition_flag and the caller's Flags satisfy every
|
|
// element AND alt_duration_value is non-NULL, the calc swaps to alt_*
|
|
// (matches the existing flag-conditional semantics in Calculate).
|
|
//
|
|
// When the rule has condition_flag and the caller's Flags do NOT satisfy
|
|
// every element, the calc still proceeds with the base duration_value
|
|
// and surfaces FlagsRequired so the frontend can render the gating
|
|
// checkboxes. The result IS the date the rule would be due if the user
|
|
// confirmed the flag — letting the user toggle the checkbox and see the
|
|
// duration change live.
|
|
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*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 := s.resolveRule(ctx, params)
|
|
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 (Slice 4). Same semantics as Calculate: gate met + alt
|
|
// values present → swap to alt; otherwise use base values.
|
|
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 (upc.rev.cfi.app_to_amend, upc.rev.cfi.cc_inf):
|
|
// effectively mean "due on the trigger date itself". The card-click
|
|
// flow doesn't need to surface those as a calc panel — but if it
|
|
// does, returning the trigger date is the right answer.
|
|
if durationValue == 0 {
|
|
out.OriginalDate = params.TriggerDate
|
|
out.DueDate = params.TriggerDate
|
|
return out, nil
|
|
}
|
|
|
|
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
|
country, regime, err := s.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, s.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 (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) {
|
|
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
|
|
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
|
|
}
|
|
|
|
const ptCols = `id, code, name, name_en, description, jurisdiction,
|
|
category, default_color, sort_order, is_active`
|
|
|
|
var rule models.DeadlineRule
|
|
var pt models.ProceedingType
|
|
if params.RuleID != "" {
|
|
err := s.rules.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules
|
|
WHERE id = $1 AND is_active = true`, params.RuleID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, ErrUnknownRule
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err)
|
|
}
|
|
if rule.ProceedingTypeID == nil {
|
|
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
|
|
}
|
|
err = s.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+ptCols+`
|
|
FROM paliad.proceeding_types
|
|
WHERE id = $1`, *rule.ProceedingTypeID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
|
|
}
|
|
return &rule, &pt, nil
|
|
}
|
|
|
|
err := s.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+ptCols+`
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, params.ProceedingCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err)
|
|
}
|
|
err = s.rules.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules
|
|
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
|
pt.ID, params.RuleLocalCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, ErrUnknownRule
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err)
|
|
}
|
|
return &rule, &pt, nil
|
|
}
|
|
|
|
// ListFristenrechnerTypes returns the proceeding types that populate the
|
|
// Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order.
|
|
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) {
|
|
rows, err := s.rules.db.QueryxContext(ctx, `
|
|
SELECT code, name, name_en, jurisdiction
|
|
FROM paliad.proceeding_types
|
|
WHERE category = 'fristenrechner' AND is_active = true
|
|
ORDER BY sort_order`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list fristenrechner types: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []FristenrechnerType
|
|
for rows.Next() {
|
|
var t FristenrechnerType
|
|
var juris sql.NullString
|
|
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
|
return nil, err
|
|
}
|
|
if juris.Valid {
|
|
t.Group = juris.String
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// FristenrechnerType mirrors the /api/tools/proceeding-types response metadata.
|
|
type FristenrechnerType struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
NameEN string `json:"nameEN"`
|
|
Group string `json:"group"`
|
|
}
|
|
|
|
// allFlagsSet returns true when every element of `required` is present in
|
|
// `set`. Empty `required` returns true (no condition). Retained as the
|
|
// fallback predicate used by evalConditionExpr when condition_expr is
|
|
// NULL but the legacy condition_flag text[] is set — preserves
|
|
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
|
|
// but defensive).
|
|
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
|
for _, f := range required {
|
|
if _, ok := set[f]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// evalConditionExpr returns true iff the rule's gate predicate is
|
|
// satisfied for the caller's flag set. Drives flag-conditional rendering
|
|
// + flag-conditional alt-swap throughout the calculator.
|
|
//
|
|
// Grammar (design §2.4 long form, mig 084 backfill):
|
|
//
|
|
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
|
|
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
|
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
|
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
|
//
|
|
// NULL / empty / "null" expression → true (unconditional). Malformed
|
|
// JSON → true (defensive: the rule still renders, the lawyer sees
|
|
// it even if the gate is broken).
|
|
//
|
|
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
|
// text[] column; the fallback that AND'd over it is gone. Any future
|
|
// row needing array-of-flags semantics writes the equivalent
|
|
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
|
func evalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
|
if len(expr) == 0 || string(expr) == "null" {
|
|
return true
|
|
}
|
|
return evalConditionExprNode(expr, flags)
|
|
}
|
|
|
|
// evalConditionExprNode walks one node of the condition_expr jsonb
|
|
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
|
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
|
// 2-arg AND (mig 084).
|
|
func evalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
|
var node struct {
|
|
Flag string `json:"flag"`
|
|
Op string `json:"op"`
|
|
Args []json.RawMessage `json:"args"`
|
|
}
|
|
if err := json.Unmarshal(raw, &node); err != nil {
|
|
// Malformed → unconditional. The Slice 11 editor's validation
|
|
// will block such writes; in the live corpus today mig 084's
|
|
// jsonb_build_object output is well-formed by construction.
|
|
return true
|
|
}
|
|
if node.Flag != "" {
|
|
_, ok := flags[node.Flag]
|
|
return ok
|
|
}
|
|
switch node.Op {
|
|
case "and":
|
|
for _, a := range node.Args {
|
|
if !evalConditionExprNode(a, flags) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case "or":
|
|
for _, a := range node.Args {
|
|
if evalConditionExprNode(a, flags) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "not":
|
|
if len(node.Args) != 1 {
|
|
// Malformed NOT — fall through to unconditional rather
|
|
// than risk suppressing a rule the lawyer expects to see.
|
|
return true
|
|
}
|
|
return !evalConditionExprNode(node.Args[0], flags)
|
|
}
|
|
// Unknown op (forward-compat with editor extensions): treat as
|
|
// unconditional so the rule still renders.
|
|
return true
|
|
}
|
|
|
|
// hasConditionExpr returns true when the rule carries a non-empty,
|
|
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
|
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
|
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
|
// when the gate flips to met, swap to alt".
|
|
func hasConditionExpr(expr models.NullableJSON) bool {
|
|
if len(expr) == 0 {
|
|
return false
|
|
}
|
|
s := string(expr)
|
|
return s != "null" && s != "{}"
|
|
}
|
|
|
|
// extractFlagsFromExpr walks the jsonb gate and returns the unique
|
|
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
|
// CalculateRule's response (FlagsRequired) so the result-card calc
|
|
// panel can render flag checkboxes for each gate input. Replaces the
|
|
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
|
// expression or one that contains no flag leaves.
|
|
func extractFlagsFromExpr(expr models.NullableJSON) []string {
|
|
if !hasConditionExpr(expr) {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{})
|
|
walkFlagLeaves([]byte(expr), seen)
|
|
if len(seen) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for f := range seen {
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
|
|
var node struct {
|
|
Flag string `json:"flag"`
|
|
Op string `json:"op"`
|
|
Args []json.RawMessage `json:"args"`
|
|
}
|
|
if err := json.Unmarshal(raw, &node); err != nil {
|
|
return
|
|
}
|
|
if node.Flag != "" {
|
|
into[node.Flag] = struct{}{}
|
|
return
|
|
}
|
|
for _, a := range node.Args {
|
|
walkFlagLeaves(a, into)
|
|
}
|
|
}
|
|
|
|
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
|
|
// pair from the unified priority enum so the wire shape stays
|
|
// pixel-identical through Slice 4. Slice 8 will swap the wire to
|
|
// emit priority directly. Mapping is the exact reverse of mig 083's
|
|
// backfill (per design §2.3):
|
|
//
|
|
// 'mandatory' → (true, false) — statutory must, ☑ pre-checked
|
|
// 'optional' → (true, true) — RoP.151 case: strict but opt-in,
|
|
// ☐ pre-unchecked save modal
|
|
// 'recommended' → (false, false) — situational filing, save by default
|
|
// with override (legacy F/F semantic)
|
|
// 'informational' → (false, false) — never saves; today no live rows
|
|
// carry it. Future: surfaces as a
|
|
// notice card in the timeline.
|
|
// (unknown) → (true, false) — safe default; treat as mandatory
|
|
// so we never silently drop a rule.
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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 (Calculate's existing behaviour for non-preview
|
|
// callers). The override slice is small (1 row in practice — the
|
|
// draft being previewed) so the linear scan is fine.
|
|
func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule {
|
|
if len(overrides) == 0 {
|
|
return src
|
|
}
|
|
byID := make(map[uuid.UUID]models.DeadlineRule, len(overrides))
|
|
for _, o := range overrides {
|
|
byID[o.ID] = o
|
|
}
|
|
out := make([]models.DeadlineRule, 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
|
|
}
|
|
|
|
// applyDuration is the unified date-arithmetic helper used by every
|
|
// calculator path (Pipeline-A proceeding-tree, Pipeline-C trigger-event,
|
|
// CalculateRule single-rule). Phase 3 Slice 4 (t-paliad-185) replaces
|
|
// the prior split between addDuration (proceeding-tree, no timing /
|
|
// working_days) and applyDurationOnCalendar (Pipeline-C, full support).
|
|
//
|
|
// Returns (raw, adjusted, didAdjust, reason):
|
|
//
|
|
// - raw: the date strictly implied by the rule before rollover.
|
|
// - adjusted: post-rollover for calendar units. 'working_days' lands
|
|
// on a working day by construction so raw == adjusted there.
|
|
// - didAdjust: true iff rollover moved the date.
|
|
// - reason: populated when didAdjust is true; nil otherwise.
|
|
//
|
|
// timing='before' negates the sign. timing='after' (or any other value
|
|
// including the empty string) keeps it positive — preserves the
|
|
// pre-Slice-4 behaviour for proceeding-tree rules whose Timing field
|
|
// is sometimes NULL (mig 003 defaults to 'after' but legacy callers
|
|
// pass r.Timing dereferenced).
|
|
func applyDuration(
|
|
base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService,
|
|
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
|
sign := 1
|
|
if timing == "before" {
|
|
sign = -1
|
|
}
|
|
switch unit {
|
|
case "days":
|
|
raw = base.AddDate(0, 0, sign*value)
|
|
case "weeks":
|
|
raw = base.AddDate(0, 0, sign*value*7)
|
|
case "months":
|
|
raw = base.AddDate(0, sign*value, 0)
|
|
case "working_days":
|
|
raw = addWorkingDays(base, sign*value, country, regime, holidays)
|
|
// Working-day arithmetic lands on a working day by construction
|
|
// — the per-step skip loop in addWorkingDays already passes over
|
|
// weekends and holidays. No post-rollover required.
|
|
return raw, raw, false, nil
|
|
default:
|
|
raw = base
|
|
}
|
|
adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
|
|
return raw, adjusted, didAdjust, reason
|
|
}
|
|
|
|
// addWorkingDays advances from `from` by `n` working days, skipping
|
|
// weekends and holidays applicable to the given country/regime. Negative
|
|
// n walks backward. n=0 keeps the input date as-is (caller decides
|
|
// whether to roll forward via AdjustForNonWorkingDays).
|
|
//
|
|
// Bounded by an inner 30-step skip per advance — vacation runs in our
|
|
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
|
|
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
|
|
if n == 0 {
|
|
return from
|
|
}
|
|
step := 1
|
|
if n < 0 {
|
|
step = -1
|
|
n = -n
|
|
}
|
|
cur := from
|
|
for i := 0; i < n; i++ {
|
|
cur = cur.AddDate(0, 0, step)
|
|
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
|
cur = cur.AddDate(0, 0, step)
|
|
}
|
|
}
|
|
return cur
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService
|
|
// owns the trigger-event metadata (it's the caller that needed it
|
|
// pre-Slice-3 and continues to load it for the legacy CalculateResponse
|
|
// shape). Callers that don't need those fields can ignore them.
|
|
func (s *FristenrechnerService) calculateByTriggerEvent(
|
|
ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions,
|
|
) (*UIResponse, 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 := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
|
}
|
|
|
|
rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(opts.RuleOverrides) > 0 {
|
|
rules = applyRuleOverrides(rules, opts.RuleOverrides)
|
|
}
|
|
|
|
deadlines := make([]UIDeadline, 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, s.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, s.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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slice 9 (t-paliad-195) wire-shape cleanup: trigger-event
|
|
// path emits Priority + ConditionExpr directly. The legacy
|
|
// IsMandatory/IsOptional pair was retired with the column
|
|
// drop; frontend reads priorityRendering(d) which now branches
|
|
// on priority alone.
|
|
d := UIDeadline{
|
|
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,
|
|
}
|
|
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 &UIResponse{
|
|
// 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
|
|
}
|
|
|
|
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
|
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
|
|
// holiday lookup should default to when the caller didn't pass an explicit
|
|
// CourtID. UPC proceedings get DE+UPC (München LD is HLC's most common
|
|
// venue, German federal holidays plus UPC vacations apply); DE / DPMA / EPA
|
|
// get DE-only (German federal). Future EPA-specific closures will require
|
|
// callers to pick an EPA court explicitly so the EPO regime kicks in.
|
|
//
|
|
// Helper kept tiny and stateless — when a caller passes a real CourtID,
|
|
// these defaults are bypassed entirely and the court's actual country +
|
|
// regime are used.
|
|
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
|
if jurisdiction == nil {
|
|
return CountryDE, ""
|
|
}
|
|
switch *jurisdiction {
|
|
case "UPC":
|
|
return CountryDE, RegimeUPC
|
|
default:
|
|
return CountryDE, ""
|
|
}
|
|
}
|