Files
paliad/internal/services/fristenrechner.go
mAi 990cc2b797 refactor(t-paliad-185): unified calculator (Slice 4 Step D)
Phase 3 Slice 4 Step D (design §3.D, the last foundation slice).
Pure Go — no migrations. Collapses the proceeding-tree + Pipeline-C
calculators onto a single set of unified helpers + reads, all
without changing wire output.

Helpers (package-level in services/fristenrechner.go):

  applyDuration(base, value, unit, timing, country, regime, holidays)
      → (raw, adjusted, didAdjust, reason)
    Single source-of-truth for date arithmetic. Replaces:
      - addDuration (proceeding-tree, no timing / working_days)
      - applyDurationOnCalendar (Slice 3 Pipeline-C-only)
      - EventDeadlineService.applyDuration / addWorkingDays methods
    Handles: timing=before/after, units days/weeks/months/working_days,
    weekend + holiday rollover for calendar units. working_days lands
    on a working day by construction (no post-rollover).

  evalConditionExpr(expr jsonb, conditionFlag []string, flags) bool
    Long-form jsonb gate evaluator (design §2.4). Grammar:
      leaf:  {"flag":"X"}
      AND:   {"op":"and","args":[<n>...]}
      OR:    {"op":"or","args":[<n>...]}
      NOT:   {"op":"not","args":[<one>]}
    NULL / empty / "null" → unconditional. Defensive fall-through
    on malformed JSON / unknown ops (rule still renders — never
    silently drop a deadline). Fallback to condition_flag
    AND-semantics when expr is NULL but the legacy column is set
    (defensive cover for any row Slice 2 missed).

  wireFlagsFromPriority(priority) → (isMandatory, isOptional)
    Derives the legacy wire pair from the unified priority enum:
      mandatory     → (T, F)     — statutory must
      optional      → (T, T)     — RoP.151 (opt-in, ☐ pre-unchecked)
      recommended   → (F, F)     — situational filing
      informational → (F, F)     — never saves today
      unknown       → (T, F)     — safe default
    Slice 8 will swap the wire to emit priority directly.

Calculate (proceeding-tree) refactor:

  - r.IsCourtSet column read direct, isCourtDeterminedRule() heuristic
    function deleted. Slice 2 backfill (mig 082) wrote the column
    using the exact heuristic predicate; column-read saves the
    per-rule branch test at runtime.
  - r.Priority drives the wire IsMandatory / IsOptional pair via
    wireFlagsFromPriority. Read of r.IsMandatory / r.IsOptional
    columns retained (compat-mode) but never decision-shaping.
  - r.ConditionExpr drives the gate; condition_flag is the fallback.
  - Added combine_op composite (max/min) branch for proceeding-tree
    rules. No live Pipeline-A rules carry combine_op today (it's a
    future-friendly column the rule editor will surface); the
    branch is reachable but produces zero diffs on the current
    corpus.
  - timing=before + working_days now usable on proceeding-tree rules
    via the unified applyDuration. No live Pipeline-A rules use them.

CalculateRule (single-rule card-click) refactor: same column reads
(IsCourtSet, ConditionExpr, Priority), unified applyDuration.

calculateByTriggerEvent (Pipeline C) refactor: switched to the
unified applyDuration; loses the redundant post-pick reason
recompute (applyDuration now returns reason directly).

EventDeadlineService.Calculate composite-note recompute now calls
the package-level applyDuration instead of the deleted method.

Frontend wire shape stays pixel-identical pre/post-Slice-4. The 17
condition_flag rules in the live corpus continue to gate via the
same (a) leaf or (b) AND-of-args evaluator branches mig 084
produced; jsonb path is exercised first, the array fallback
remains as defensive cover.
2026-05-15 00:52:49 +02:00

1148 lines
43 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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).
type UIDeadline struct {
RuleID string `json:"ruleId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Party string `json:"party"`
IsMandatory bool `json:"isMandatory"`
RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,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"`
// IsOptional mirrors paliad.deadline_rules.is_optional. The save-
// modal pre-unchecks these rows; the timeline still renders them
// so the user sees what could apply.
IsOptional bool `json:"isOptional,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"`
IsOverridden bool `json:"isOverridden,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
type UIResponse struct {
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
}
// 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. EP_GRANT.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
}
// 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 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. EP_GRANT 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{}{}
}
// 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"`
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
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)
}
// 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
}
// 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))
for _, r := range rules {
// Phase-3 unified gate: evaluate condition_expr (jsonb) with
// fallback to condition_flag (legacy text[]) AND-semantics.
// 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), []string(r.ConditionFlag), flagSet)
if !gateMet && r.AltDurationValue == nil {
continue
}
// Wire-compat: derive the legacy (IsMandatory, IsOptional) pair
// from the unified priority enum so /tools/fristenrechner's
// frontend keeps reading the same fields. Slice 8 will swap the
// wire to emit priority directly.
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
IsMandatory: wireMand,
IsOptional: wireOpt,
}
if r.Code != nil {
d.Code = *r.Code
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
}
if r.RuleCode != nil {
d.RuleRef = *r.RuleCode
}
if r.LegalSource != nil {
d.LegalSource = *r.LegalSource
}
if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes
}
if r.DeadlineNotesEn != nil {
d.NotesEN = *r.DeadlineNotesEn
}
// 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] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if _, ok := overrideDates[*prev.Code]; 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 (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.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.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
d.DueDate = ov.Format("2006-01-02")
d.OriginalDate = d.DueDate
d.IsOverridden = true
computed[*r.Code] = 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.Code != nil {
computed[*r.Code] = 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.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.Code != nil {
if ov, ok := overrideDates[*prev.Code]; ok {
parentDate = ov
haveParentDate = true
} else if ref, ok := computed[*prev.Code]; ok {
parentDate = ref
haveParentDate = true
}
}
break
}
}
if haveParentDate {
d.DueDate = parentDate.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.Code != nil {
computed[*r.Code] = 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.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.
if parentIsCourtSet {
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
deadlines = append(deadlines, d)
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for EP_GRANT 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.Code != 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.Code]; ok {
baseDate = ov
} else if ref, ok := computed[*prev.Code]; 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 && len(r.ConditionFlag) > 0 && 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.Code != nil {
if ov, ok := overrideDates[*r.Code]; 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.Code] = 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
if r.Code != nil {
computed[*r.Code] = adjusted
}
deadlines = append(deadlines, d)
}
return &UIResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
}
// 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"`
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
}
out := &RuleCalculation{
Rule: RuleCalculationRule{
ID: rule.ID.String(),
NameDE: rule.Name,
NameEN: rule.NameEN,
DurationValue: rule.DurationValue,
DurationUnit: rule.DurationUnit,
IsMandatory: rule.IsMandatory,
},
Proceeding: RuleCalculationProceeding{
Code: pt.Code,
NameDE: pt.Name,
NameEN: pt.NameEN,
},
TriggerDate: params.TriggerDate,
}
if rule.Code != nil {
out.Rule.LocalCode = *rule.Code
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
}
if rule.LegalSource != nil {
out.Rule.LegalSource = *rule.LegalSource
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*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
}
if len(rule.ConditionFlag) > 0 {
out.FlagsRequired = []string(rule.ConditionFlag)
}
// 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), []string(rule.ConditionFlag), flagSet)
if gateMet && len(rule.ConditionFlag) > 0 {
out.FlagsApplied = []string(rule.ConditionFlag)
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.app_to_amend, UPC_REV.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 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).
//
// Fallback: when expr is NULL but the legacy condition_flag text[] is
// set, evaluate AND-semantics over condition_flag — preserves
// pre-Slice-2 behaviour for the (defensive, shouldn't-happen) case
// where mig 084 missed a row.
func evalConditionExpr(expr []byte, conditionFlag []string, flags map[string]struct{}) bool {
if len(expr) == 0 || string(expr) == "null" {
if len(conditionFlag) == 0 {
return true
}
return allFlagsSet(conditionFlag, flags)
}
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
}
// 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
}
}
// 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
}
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
}
}
}
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
IsMandatory: r.IsMandatory,
IsOptional: r.IsOptional,
DueDate: picked.Format("2006-01-02"),
OriginalDate: original.Format("2006-01-02"),
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
}
if r.RuleCode != nil {
d.RuleRef = *r.RuleCode
}
if r.LegalSource != nil {
d.LegalSource = *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, ""
}
}