m's 2026-05-20 14:08 reports on /tools/verfahrensablauf:
1. "There seems to be a lacking english term here" — picking
UPC CCR shows "Trigger event: Widerklage auf Nichtigkeit" on EN.
2. "Nothing shows in the roadmap" — the timeline is empty because
upc.ccr.cfi has no native rules (it's an illustrative peer that
normally runs as a sub-track of upc.inf.cfi with with_ccr).
Root cause for (1): UIResponse.proceedingName was DE-only. When a
proceeding had no root rule the frontend fell back to that field, so
EN users saw the DE label. The DB already has bilingual names; this
was pure plumbing.
Root cause for (2): the upc.ccr.cfi proceeding-type row exists for
the picker (mig 096) but ResolveCounterclaimRouting — the helper
that maps it to upc.inf.cfi with the with_ccr flag — was defined
but never called. Calculate queried rules directly off upc.ccr.cfi
and got an empty list.
Fix:
* Add ProceedingNameEN, ContextualNote, ContextualNoteEN to
UIResponse. Frontend triggerEventLabelFor now consults the EN
name on EN, falling back to DE only if the EN field is empty.
* New SubTrackRouting registry in proceeding_mapping.go and a
LookupSubTrackRouting lookup — single source of truth for the
"this proceeding has no native rules, route to a parent with
flags + show a contextual note" pattern. Today's only entry is
upc.ccr.cfi → upc.inf.cfi + with_ccr; the pattern generalises
to other sub-tracks via data-only additions.
* Calculate consults the registry at the top: when a hit, the
proceeding type is re-resolved to the parent for rule lookup, the
default flags are merged into the user's flag set (user flags win
on conflict), and the response identity (Code/Name/NameEN) stays
on the user-picked proceeding so the page header still reads
"Counterclaim for Revocation". The bilingual note surfaces in
ContextualNote{,EN}.
* Frontend renderResults paints a lime-accent banner above the
timeline body when the response carries a note
(.timeline-context-note). escHtml already exported from
views/verfahrensablauf-core — imported here for the banner.
No DB migration: SELECTs against paliad.proceeding_types,
paliad.deadline_rules, and paliad.trigger_events confirm every
active row already has a non-empty name_en / name. The bug was
the API + frontend never reading the EN columns through the
proceedingName fallback path.
Tests: TestSubTrackRoutings pins the registry shape (every entry
has matching key/value, non-empty parent+flags, bilingual notes;
CCR's exact shape is asserted; non-sub-tracks miss). The existing
TestResolveCounterclaimRouting continues to pass because the
helper now consults the registry but the CCR semantics are
unchanged.
1338 lines
51 KiB
Go
1338 lines
51 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).
|
|
//
|
|
// 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"`
|
|
IsOverridden bool `json:"isOverridden,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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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{}{}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
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))
|
|
|
|
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
|
|
}
|
|
|
|
d := UIDeadline{
|
|
RuleID: r.ID.String(),
|
|
Name: r.Name,
|
|
NameEN: r.NameEN,
|
|
Priority: r.Priority,
|
|
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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.SubmissionCode != nil {
|
|
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
|
parentOverridden = true
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden
|
|
|
|
// Zero-duration rules fall into one of four buckets:
|
|
// 1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
|
|
// 2. parent=nil, court-determined → IsCourtSet (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.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.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 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
|
|
if r.SubmissionCode != nil {
|
|
computed[*r.SubmissionCode] = adjusted
|
|
}
|
|
deadlines = append(deadlines, d)
|
|
}
|
|
|
|
resp := &UIResponse{
|
|
ProceedingType: pickedProceeding.Code,
|
|
ProceedingName: pickedProceeding.Name,
|
|
ProceedingNameEN: pickedProceeding.NameEN,
|
|
TriggerDate: triggerDateStr,
|
|
Deadlines: deadlines,
|
|
}
|
|
if hasSubTrackNote {
|
|
resp.ContextualNote = subTrackNote.NoteDE
|
|
resp.ContextualNoteEN = subTrackNote.NoteEN
|
|
}
|
|
return resp, 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"`
|
|
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, ""
|
|
}
|
|
}
|