From 5f0a85fa835fcc091e3e8b9bf46db29581633bad Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 12:52:59 +0200 Subject: [PATCH] refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic extraction of the deadline-rule compute engine + types from internal/services into a new pkg/litigationplanner package that paliad + youpc.org can both import. No behaviour change — every existing test passes against the post-move shape. Package contents (~1850 LoC): - doc.go package docstring + reuse manifesto - types.go Rule, ProceedingType, NullableJSON, AdjustmentReason, HolidayDTO, CalcOptions, CalcRuleParams, Timeline, TimelineEntry, RuleCalculation*, FristenrechnerType, ProjectHint, sentinel errors - catalog.go Catalog interface (proceeding + rule lookups) - holidays.go HolidayCalendar interface - courts.go CourtRegistry interface + DefaultsForJurisdiction + country/regime constants - expr.go EvalConditionExpr + HasConditionExpr + ExtractFlagsFromExpr (jsonb gate evaluator) - durations.go ApplyDuration + AddWorkingDays (pure compute) - subtrack.go SubTrackRouting + LookupSubTrackRouting registry - legal_source.go FormatLegalSourceDisplay + BuildLegalSourceURL - proceeding_mapping.go MapLitigationToFristenrechner + code constants (CodeUPCInfringement, CodeDEInfringementLG, ...) - engine.go Calculate + CalculateRule + the trigger-event branch + applyRuleOverrides (the big move) paliad side (~1900 LoC net deletion): - internal/services/fristenrechner.go shrinks from 1505 → ~290 lines (thin paliad Catalog adapter + type aliases for back-compat). - internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON become type aliases to litigationplanner.* — every sqlx scan and every projection_service caller compiles unchanged. - internal/services/holidays.go: AdjustmentReason + HolidayDTO become aliases to lp.* (canonical definitions now in the package). - internal/services/proceeding_mapping.go: rewritten as thin re-exports of lp constants + helpers. - internal/services/deadline_search_service.go: FormatLegalSourceDisplay + BuildLegalSourceURL replaced with delegating wrappers to lp. Catalog interface satisfaction: - DeadlineRuleService → paliadCatalog adapter (wraps the existing service, replicates the original SELECT shapes). - HolidayService → satisfies lp.HolidayCalendar directly (compile- time assertion at end of fristenrechner.go). - CourtService → satisfies lp.CourtRegistry directly. Wire shape is byte-identical. JSON tags on Rule / ProceedingType / Timeline / TimelineEntry / RuleCalculation match the historical UIResponse / UIDeadline shape; the frontend reads the same bytes. Slice B (Catalog interface + paliad loader cleanup) is folded into this commit since Slice A already needs the interfaces to call Calculate across the boundary. Slice C (embedded UPC snapshot + generator) is the next coder shift; the Berufung unification m called out lands in Slice B/C per head's brief. Refs: docs/design-litigation-planner-2026-05-26.md --- internal/models/models.go | 215 +- internal/services/deadline_rule_service.go | 3 +- internal/services/deadline_search_service.go | 127 +- internal/services/fristenrechner.go | 1985 +++--------------- internal/services/holidays.go | 42 +- internal/services/proceeding_mapping.go | 218 +- pkg/litigationplanner/catalog.go | 49 + pkg/litigationplanner/courts.go | 49 + pkg/litigationplanner/doc.go | 17 + pkg/litigationplanner/durations.go | 76 + pkg/litigationplanner/engine.go | 908 ++++++++ pkg/litigationplanner/expr.go | 145 ++ pkg/litigationplanner/holidays.go | 25 + pkg/litigationplanner/legal_source.go | 123 ++ pkg/litigationplanner/proceeding_mapping.go | 139 ++ pkg/litigationplanner/sort.go | 151 ++ pkg/litigationplanner/subtrack.go | 53 + pkg/litigationplanner/types.go | 428 ++++ 18 files changed, 2491 insertions(+), 2262 deletions(-) create mode 100644 pkg/litigationplanner/catalog.go create mode 100644 pkg/litigationplanner/courts.go create mode 100644 pkg/litigationplanner/doc.go create mode 100644 pkg/litigationplanner/durations.go create mode 100644 pkg/litigationplanner/engine.go create mode 100644 pkg/litigationplanner/expr.go create mode 100644 pkg/litigationplanner/holidays.go create mode 100644 pkg/litigationplanner/legal_source.go create mode 100644 pkg/litigationplanner/proceeding_mapping.go create mode 100644 pkg/litigationplanner/sort.go create mode 100644 pkg/litigationplanner/subtrack.go create mode 100644 pkg/litigationplanner/types.go diff --git a/internal/models/models.go b/internal/models/models.go index 87933d4..80786bf 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -4,63 +4,20 @@ package models import ( - "database/sql/driver" "encoding/json" - "fmt" "time" "github.com/google/uuid" "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) -// NullableJSON is a jsonb column that may be NULL. json.RawMessage -// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value -// from Postgres breaks the row scan with "unsupported Scan, storing -// driver.Value type into type *json.RawMessage" — exactly the -// error that hid every approval_request from the inbox when m's first -// "create" lifecycle row arrived with NULL pre_image (m's dogfood -// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column -// fixes the scan and preserves inline JSON output (no base64 cast). -type NullableJSON []byte - -func (n *NullableJSON) Scan(value any) error { - if value == nil { - *n = nil - return nil - } - switch v := value.(type) { - case []byte: - *n = append((*n)[:0], v...) - return nil - case string: - *n = []byte(v) - return nil - } - return fmt.Errorf("NullableJSON: unsupported scan type %T", value) -} - -func (n NullableJSON) Value() (driver.Value, error) { - if len(n) == 0 { - return nil, nil - } - return []byte(n), nil -} - -func (n NullableJSON) MarshalJSON() ([]byte, error) { - if len(n) == 0 { - return []byte("null"), nil - } - return []byte(n), nil -} - -func (n *NullableJSON) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - *n = nil - return nil - } - *n = append((*n)[:0], data...) - return nil -} +// NullableJSON is a jsonb column that may be NULL. Canonical definition +// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler) +// lives in pkg/litigationplanner — kept here as a type alias so every +// existing models.NullableJSON reference continues to compile. +type NullableJSON = litigationplanner.NullableJSON // User extends auth.users with firm-specific profile fields. Created by the // Phase D onboarding flow; without a row here, the user can't see any Projects. @@ -584,112 +541,10 @@ type Party struct { } // DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.). -type DeadlineRule struct { - ID uuid.UUID `db:"id" json:"id"` - ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"` - ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` - SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"` - Name string `db:"name" json:"name"` - NameEN string `db:"name_en" json:"name_en"` - Description *string `db:"description" json:"description,omitempty"` - PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` - EventType *string `db:"event_type" json:"event_type,omitempty"` - DurationValue int `db:"duration_value" json:"duration_value"` - DurationUnit string `db:"duration_unit" json:"duration_unit"` - Timing *string `db:"timing" json:"timing,omitempty"` - RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` - DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"` - DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"` - SequenceOrder int `db:"sequence_order" json:"sequence_order"` - AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` - AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` - AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` - AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"` - ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"` - // ConceptDefaultEventTypeID is the canonical paliad.event_types row for - // this rule's concept (joined via paliad.deadline_concept_event_types - // where is_default = true). Lets the deadline create form auto-populate - // the Typ chip when the user picks this rule. Hydrated by the service - // layer; not a column. NULL when the concept has no mapped event_type. - ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"` - LegalSource *string `db:"legal_source" json:"legal_source,omitempty"` - IsSpawn bool `db:"is_spawn" json:"is_spawn"` - SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` - IsActive bool `db:"is_active" json:"is_active"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - - // --------------------------------------------------------------- - // Phase 3 unified-rule columns (mig 078, t-paliad-182). - // Slice 9 (t-paliad-195) dropped the legacy IsMandatory / - // IsOptional / ConditionFlag / ConditionRuleID fields — they - // were superseded by Priority / ConditionExpr / IsCourtSet and - // the unified calculator no longer reads them. - // --------------------------------------------------------------- - - // TriggerEventID points at paliad.trigger_events when this rule is - // event-rooted (Pipeline C unification, design §2.5). NULL on - // proceeding-rooted rules. Exactly one of (proceeding_type_id, - // trigger_event_id) is set after Slice 3. - TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"` - - // SpawnProceedingTypeID is the cross-proceeding spawn target — - // when is_spawn=true and this is non-NULL, the calculator follows - // the FK and emits the target proceeding's root rule chain. Slice - // 7 backfills the 8 live is_spawn=true rows. - SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"` - - // CombineOp is 'max' or 'min' for composite-rule arithmetic - // (R.198 / R.213: "31d OR 20 working_days, whichever is longer"). - // NULL = single-anchor arithmetic. - CombineOp *string `db:"combine_op" json:"combine_op,omitempty"` - - // ConditionExpr is the jsonb gating expression replacing - // ConditionFlag (design §2.4). Grammar: - // {"flag": ""} - // {"op":"and"|"or", "args":[, ...]} - // {"op":"not", "args":[]} - // NULL or {} = unconditional. NullableJSON so a NULL column scans - // cleanly (the row mishap that hid approval rows from the inbox - // must not recur on rule rows). - ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"` - - // Priority is the 4-way unified enum replacing - // (IsMandatory, IsOptional). Values: 'mandatory' (default), - // 'recommended', 'optional', 'informational'. Backfilled in - // Slice 2; legacy callers read IsMandatory + IsOptional until - // Slice 4 cuts them over. - Priority string `db:"priority" json:"priority"` - - // IsCourtSet replaces the runtime heuristic - // (primary_party='court' OR event_type IN ('hearing','decision', - // 'order')). Backfilled in Slice 2; legacy callers read the - // heuristic until Slice 4. - IsCourtSet bool `db:"is_court_set" json:"is_court_set"` - - // LifecycleState drives the rule-editor flow (design §4.2): - // 'draft' (admin work-in-progress) | 'published' (live, calculator- - // visible) | 'archived' (historical, retained for audit). Every - // pre-Slice-1 row defaults to 'published' via the migration. - LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"` - - // DraftOf points at the published rule this draft will replace on - // publish. NULL on published / archived rows. NULL also on net- - // new drafts that have no prior published peer. - DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"` - - // PublishedAt records when the row entered LifecycleState='published'. - // NULL while draft, set on publish, retained through archive. - // Distinct from UpdatedAt (moves on every edit). - PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"` - - // ChoicesOffered declares which per-event-card choice-kinds this - // rule offers on the Verfahrensablauf timeline (mig 129, - // t-paliad-265). NULL = no caret affordance (default). See the - // COMMENT on paliad.deadline_rules.choices_offered for the value - // shape. The engine and the frontend both read this column. - ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"` -} +// Canonical definition lives in pkg/litigationplanner.Rule — kept here +// as a type alias so every existing models.DeadlineRule reference (sqlx +// scans, hydration, projection service) continues to compile. +type DeadlineRule = litigationplanner.Rule // DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the // append-only audit log for every change to paliad.deadline_rules. @@ -721,43 +576,19 @@ type DeadlineRuleAudit struct { MigrationExported bool `db:"migration_exported" json:"migration_exported"` } -// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter -// management) or the lowercase dot-separated fristenrechner codes -// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see -// docs/design-proceeding-code-taxonomy-2026-05-18.md. -type ProceedingType struct { - ID int `db:"id" json:"id"` - Code string `db:"code" json:"code"` - Name string `db:"name" json:"name"` - NameEN string `db:"name_en" json:"name_en"` - Description *string `db:"description" json:"description,omitempty"` - Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"` - Category *string `db:"category" json:"category,omitempty"` - DefaultColor string `db:"default_color" json:"default_color"` - SortOrder int `db:"sort_order" json:"sort_order"` - IsActive bool `db:"is_active" json:"is_active"` - // TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf - // "Auslösendes Ereignis". When set, overrides the proceedingName fallback - // that fires when no rule has IsRootEvent=true. Populated for UPC Appeal - // (mig 121) so the caption reads "Anfechtbare Entscheidung" / - // "Appealable Decision" instead of "Berufungsverfahren" / "Appeal". - // NULL on most proceedings — they already carry a root rule. - TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"` - TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"` -} +// ProceedingType is one of the litigation conceptual codes (INF / REV / +// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated +// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see +// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical +// definition lives in pkg/litigationplanner.ProceedingType — kept here +// as a type alias so every existing models.ProceedingType reference +// continues to compile. +type ProceedingType = litigationplanner.ProceedingType -// TriggerEvent is a UPC procedural event that can start one or more deadlines -// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven -// lookup, mirrored from youpc data.events). -type TriggerEvent struct { - ID int64 `db:"id" json:"id"` - Code string `db:"code" json:"code"` - Name string `db:"name" json:"name"` - NameDE string `db:"name_de" json:"name_de"` - Description string `db:"description" json:"description"` - IsActive bool `db:"is_active" json:"is_active"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} +// TriggerEvent is a UPC procedural event referenced by deadline rules +// whose semantic anchor is an event rather than a parent rule. +// Canonical definition lives in pkg/litigationplanner.TriggerEvent. +type TriggerEvent = litigationplanner.TriggerEvent // EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors // youpc data.deadlines + the trigger half of data.deadline_events. diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 381da34..6ff3a40 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -38,7 +38,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n choices_offered` const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction, - category, default_color, sort_order, is_active` + category, default_color, sort_order, is_active, + trigger_event_label_de, trigger_event_label_en` // List returns active rules, optionally filtered by proceeding type. // Each row has ConceptDefaultEventTypeID hydrated from diff --git a/internal/services/deadline_search_service.go b/internal/services/deadline_search_service.go index 6a0f749..92acb85 100644 --- a/internal/services/deadline_search_service.go +++ b/internal/services/deadline_search_service.go @@ -9,6 +9,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" + + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // DeadlineSearchService backs the unified Fristenrechner search bar @@ -921,130 +923,15 @@ func roundScore(v float64) float64 { return float64(int(v*10000+0.5)) / 10000 } -// FormatLegalSourceDisplay renders a structured legal_source code into -// the form HLC users read in pleadings: -// -// UPC.RoP.23.1 → "UPC RoP R.23(1)" -// UPC.RoP.139 → "UPC RoP R.139" -// DE.PatG.82.1 → "PatG §82(1)" -// DE.ZPO.276.1 → "ZPO §276(1)" -// EU.EPÜ.108 → "EPÜ Art.108" -// EU.EPC-R.79.1 → "EPC R.79(1)" -// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)" -// -// Returns the empty string for an empty input. Unknown jurisdictions -// fall through with the structured form preserved (caller decides -// whether to display). +// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically +// defined in pkg/litigationplanner — kept here as thin re-exports so +// the existing in-package + handler call-sites compile unchanged. func FormatLegalSourceDisplay(src string) string { - src = strings.TrimSpace(src) - if src == "" { - return "" - } - parts := strings.Split(src, ".") - if len(parts) < 3 { - // Malformed — return as-is so the caller still has something. - return src - } - code := parts[1] - rest := parts[2:] - var prefix string - switch code { - case "RoP": - prefix = "UPC RoP R." - case "PatG": - prefix = "PatG §" - case "ZPO": - prefix = "ZPO §" - case "EPÜ": - prefix = "EPÜ Art." - case "EPC-R": - prefix = "EPC R." - case "RPBA": - prefix = "RPBA Art." - default: - prefix = code + " " - } - var b strings.Builder - b.Grow(len(prefix) + len(src)) - b.WriteString(prefix) - b.WriteString(rest[0]) - for _, p := range rest[1:] { - b.WriteByte('(') - b.WriteString(p) - b.WriteByte(')') - } - return b.String() + return lp.FormatLegalSourceDisplay(src) } -// BuildLegalSourceURL maps a structured legal_source code to a -// youpc.org/laws permalink when the cited body is hosted there. Today -// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national -// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc -// home yet, so the helper returns the empty string for those and the -// caller renders the display string as plain text. -// -// Inputs mirror FormatLegalSourceDisplay — structured dot-separated -// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond -// the law-number position are dropped; youpc resolves the page at -// . granularity. The law-number is zero-padded to 3 -// digits to match how youpc stores law_number (laws-data.json carries -// "001" / "023" / "220" forms). -// -// URL shape uses the hash-fragment form that youpc itself emits from -// its laws-page redirect (handlers/laws.go:215+229) — the canonical -// in-app deep link target. The `/laws/:type/:number` pretty route also -// resolves the same page but redirects to the hash form anyway. -// -// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023 -// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139 -// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220 -// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029 -// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083 -// DE.ZPO.276.1 → "" (no youpc home — render display text plain) func BuildLegalSourceURL(src string) string { - src = strings.TrimSpace(src) - if src == "" { - return "" - } - parts := strings.Split(src, ".") - if len(parts) < 3 { - return "" - } - var lawType string - switch parts[0] + "." + parts[1] { - case "UPC.RoP": - lawType = "UPCRoP" - case "UPC.UPCA": - lawType = "UPCA" - case "UPC.UPCS": - lawType = "UPCS" - default: - return "" - } - number := padLawNumber(parts[2]) - if number == "" { - return "" - } - return "https://youpc.org/laws#" + lawType + "." + number -} - -// padLawNumber zero-pads a pure-digit law-number segment to 3 digits. -// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art. -// 112a) pass through unchanged so the URL still resolves. Empty input -// returns the empty string. -func padLawNumber(s string) string { - if s == "" { - return "" - } - for _, c := range s { - if c < '0' || c > '9' { - return s - } - } - if len(s) >= 3 { - return s - } - return strings.Repeat("0", 3-len(s)) + s + return lp.BuildLegalSourceURL(src) } // RefreshSearchView re-populates the materialised view. Safe to call on diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 1288b14..8fea562 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -3,1365 +3,82 @@ package services import ( "context" "database/sql" - "encoding/json" "errors" "fmt" - "sort" "time" "github.com/google/uuid" "mgit.msbls.de/m/paliad/internal/models" + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) -// 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. +// FristenrechnerService renders the Paliad public Fristenrechner's +// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it +// is a thin adapter: the compute engine + types live in +// pkg/litigationplanner, and FristenrechnerService just wires the +// Postgres-backed Catalog + HolidayCalendar + CourtRegistry +// implementations and delegates Calculate / CalculateRule across the +// boundary. // -// 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. +// The package owns the wire shape (Timeline / TimelineEntry); paliad's +// historical aliases (UIResponse / UIDeadline) keep call-sites +// unchanged. type FristenrechnerService struct { rules *DeadlineRuleService holidays *HolidayService courts *CourtService + + catalog lp.Catalog } // NewFristenrechnerService wires the service to its dependencies. func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService { - return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts} + s := &FristenrechnerService{rules: rules, holidays: holidays, courts: courts} + s.catalog = &paliadCatalog{rules: rules} + return s } -// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface -// (camelCase JSON to keep /tools/fristenrechner byte-identical). -// -// Phase 3 Slice 9 (t-paliad-195) dropped the legacy IsMandatory + -// IsOptional fields — Priority is the canonical wire signal. The -// frontend reads priorityRendering(d) which since Slice 8 has -// priority as the primary input; Slice 9 removes the legacy fallback -// branch from the frontend too. -type UIDeadline struct { - RuleID string `json:"ruleId,omitempty"` - Code string `json:"code"` - Name string `json:"name"` - NameEN string `json:"nameEN"` - Party string `json:"party"` - // Priority is the 4-way enum the rule-editor + save-modal logic - // reads: 'mandatory' | 'recommended' | 'optional' | 'informational'. - // Informational rules render as notice cards (no save button, no - // checkbox) — the visible UX win of Phase 3 on today's F/F rules. - Priority string `json:"priority"` - RuleRef string `json:"ruleRef"` - LegalSource string `json:"legalSource,omitempty"` - // LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)") - // of LegalSource, produced by FormatLegalSourceDisplay. Frontend - // renders this in the deadline card meta line; falls back to - // RuleRef when LegalSource is empty. - LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` - // LegalSourceURL is the youpc.org/laws permalink when the cited - // body is hosted there (UPCRoP / UPCA / UPCS today). Empty for - // DE/EPA/EU bodies — the renderer shows display text without a link. - LegalSourceURL string `json:"legalSourceURL,omitempty"` - Notes string `json:"notes,omitempty"` - NotesEN string `json:"notesEN,omitempty"` - DueDate string `json:"dueDate"` - OriginalDate string `json:"originalDate"` - WasAdjusted bool `json:"wasAdjusted"` - AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"` - IsRootEvent bool `json:"isRootEvent"` - IsCourtSet bool `json:"isCourtSet"` - // ConditionExpr is the jsonb gate predicate (design §2.4 long - // form) emitted verbatim so the rule editor (Slice 11) + admin - // surfaces can show the rule's gating shape. NULL / empty when - // the rule is unconditional. Frontend reads this to render the - // "Mit Nichtigkeitswiderklage" hint chips. - ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"` - // IsCourtSetIndirect is true when IsCourtSet is true because the - // rule chains off a court-determined parent (e.g. RoP.151 - // Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the - // Hauptentscheidung itself is the court-set anchor). Direct - // court-determined rules (Urteil / Beschluss / Anordnung - // themselves) keep IsCourtSet=true with IsCourtSetIndirect=false. - // The frontend uses this to render "unbestimmt" for indirect - // cases instead of "wird vom Gericht bestimmt", which is only - // strictly correct for the direct ones — the indirect deadline - // is computed off a parent date that the COURT sets, not by the - // court itself. - IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"` - // IsConditional signals the rule's anchor is uncertain — no - // concrete date can be projected. Set when the rule depends on: - // - a court-set ancestor whose date isn't anchored - // (overlaps with IsCourtSetIndirect; the two are kept - // distinct because IsCourtSet wraps a specific UX message - // "wird vom Gericht bestimmt", whereas IsConditional is - // the broader "render as 'abhängig von '" signal) - // - timing='before' rules whose forward anchor isn't set - // (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month - // before the oral hearing — without the hearing date, the - // backward arithmetic against the trigger date is meaningless) - // - optional opposing-side rules whose true triggering event - // hasn't been recorded for this project (e.g. R.262(2) - // Erwiderung auf Vertraulichkeitsantrag — the data-model - // parent is the SoC, but the real trigger is the opposing - // party's confidentiality motion which may never happen) - // When true, DueDate and OriginalDate are empty and the frontend - // renders an "abhängig von " chip in place of a - // date. Suppressed by an explicit user anchor (IsOverridden wins). - // (t-paliad-289) - IsConditional bool `json:"isConditional,omitempty"` - // ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the - // parent's identity so the frontend can render - // "abhängig von " when IsConditional=true. - // Populated whenever the rule has a parent_id, not only when - // conditional — keeps the wire shape stable. Empty for root rules. - ParentRuleCode string `json:"parentRuleCode,omitempty"` - ParentRuleName string `json:"parentRuleName,omitempty"` - ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"` - IsOverridden bool `json:"isOverridden,omitempty"` - // ChoicesOffered surfaces paliad.deadline_rules.choices_offered for - // the rule so the frontend knows whether to render the per-event-card - // caret affordance, and which choice-kinds to populate the popover - // with. NULL / empty for rules with no choices. (t-paliad-265) - ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"` - // AppellantContext is the per-decision appellant pick that applies - // to descendants of the closest ancestor decision card with a - // PerCardAppellant set. Empty when no per-card override is in - // effect (page-level ?appellant= still applies in that case). - // Frontend bucketer prefers this over the page-level appellant when - // non-empty. (t-paliad-265) - AppellantContext string `json:"appellantContext,omitempty"` - // IsHidden marks a card the user has previously hidden via a - // skip choice. Only ever true when CalcOptions.IncludeHidden is - // set — the toggle re-surfaces these rows so the user can either - // keep them faded for context or un-hide them via the inline - // "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122) - IsHidden bool `json:"isHidden,omitempty"` -} +// Type aliases keep call-sites byte-identical with the pre-Slice-A +// shape. The wire JSON tags are owned by the package. +// (AdjustmentReason + HolidayDTO are aliased in holidays.go.) +type ( + UIResponse = lp.Timeline + UIDeadline = lp.TimelineEntry + CalcOptions = lp.CalcOptions + CalcRuleParams = lp.CalcRuleParams + RuleCalculation = lp.RuleCalculation + RuleCalculationRule = lp.RuleCalculationRule + RuleCalculationProceeding = lp.RuleCalculationProceeding + SubTrackRouting = lp.SubTrackRouting +) -// UIResponse matches the frontend's DeadlineResponse TypeScript interface. -type UIResponse struct { - ProceedingType string `json:"proceedingType"` - ProceedingName string `json:"proceedingName"` - // ProceedingNameEN carries the English label of the proceeding so - // the frontend can switch on lang. Empty when the proceeding has no - // English label populated; the frontend falls back to ProceedingName. - // Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf - // "Trigger event" label fell back to the DE proceedingName whenever - // the timeline had no root rule (e.g. for sub-track proceedings like - // upc.ccr.cfi that have no native rules). - ProceedingNameEN string `json:"proceedingNameEN,omitempty"` - TriggerDate string `json:"triggerDate"` - Deadlines []UIDeadline `json:"deadlines"` - // ContextualNote / ContextualNoteEN surface a banner above the - // timeline. Populated by sub-track routing (m/paliad#58): when the - // user picks a proceeding that is normally a sub-track of another - // proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with - // with_ccr), the renderer routes to the parent's rules but keeps - // the user-picked code/name as the response identity and surfaces a - // note explaining the framing. - ContextualNote string `json:"contextualNote,omitempty"` - ContextualNoteEN string `json:"contextualNoteEN,omitempty"` - // TriggerEventLabel / TriggerEventLabelEN: optional caption for the - // /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated - // from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121). - // The frontend prefers this over the proceedingName fallback that - // fires when no rule has IsRootEvent=true — UPC Appeal needed it - // because all its rules carry a non-zero duration off the trigger - // date so no rule is the "anchor". The trigger event for UPC Appeal - // is the appealable first-instance decision (m/paliad#81). - TriggerEventLabel string `json:"triggerEventLabel,omitempty"` - TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` - // HiddenCount is the number of rules whose submission_code is in - // CalcOptions.SkipRules AND whose condition_expr gate passes — - // i.e. how many rows the user has hidden in this projection - // regardless of the IncludeHidden toggle state. The frontend uses - // this to render the "Ausgeblendete (N)" badge on the toggle even - // when the toggle is OFF (so users know there's something to - // re-surface). (t-paliad-290 / m/paliad#122) - HiddenCount int `json:"hiddenCount"` -} +// Sentinel errors. Re-exported as package-level vars so handlers that +// errors.Is(..., services.ErrUnknownProceedingType) continue to work. +var ( + ErrUnknownProceedingType = lp.ErrUnknownProceedingType + ErrUnknownRule = lp.ErrUnknownRule +) -// ErrUnknownProceedingType is returned when the UI sends an unrecognised code. -var ErrUnknownProceedingType = errors.New("unknown proceeding type") - -// CalcOptions carries optional inputs for Calculate. Callers can leave fields -// empty/nil for the legacy behaviour. -// -// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt = -// 'priority_date' (e.g. epa.grant.exa.ep_grant.publish per Art. 93 EPÜ) use -// this date as their base instead of the parent's adjusted date / the -// trigger date. -// - Flags: lowercase string flags from the UI (e.g. "with_ccr", -// "with_amend", "with_cci"). A rule with a non-empty condition_flag -// array renders iff EVERY element of that array is in Flags. When all -// are present AND alt_duration_value is non-NULL, the calculator -// swaps to alt_*; when set + flags not satisfied, the rule is -// suppressed entirely (skipped from the result list). -// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides -// of the computed deadline date. When a child rule chains off a -// parent whose code is in AnchorOverrides, the override date is -// used as the anchor instead of the parent's calculated date. Lets -// the user set a real court-extended deadline, or a court-set -// decision date once known, and have downstream rules re-flow. -type CalcOptions struct { - PriorityDateStr string - Flags []string - AnchorOverrides map[string]string - // CourtID picks the forum the proceeding is filed in (e.g. "upc-ld-paris", - // "de-bgh"). The calculator resolves it to (country, regime) for non- - // working-day computation. Empty falls back to UPC München (DE/UPC) for - // UPC-flavoured proceedings, DE for everything else — preserves legacy - // behaviour for callers that don't yet send a court. - CourtID string - // TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C - // rules: when non-nil, the proceedingCode argument is ignored and the - // service selects rules WHERE trigger_event_id = *TriggerEventIDFilter - // instead of WHERE proceeding_type_id = .... Set by - // EventDeadlineService.Calculate so the unified backend can serve the - // "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width - // matches paliad.trigger_events.id (bigint, mig 028). See design - // §3.D (calculator unification). - TriggerEventIDFilter *int64 - // RuleOverrides substitutes specific rules in the calculator's - // rule list with caller-supplied in-memory rows. Used by the - // rule-editor preview (Slice 11a, t-paliad-191): the admin's - // draft replaces its published peer (matched by rule.ID) so the - // editor sees "what would this rule do?" without writing to the - // DB. Net-new drafts (no draft_of peer) get appended to the rule - // list so their effect lights up on a fresh evaluation. - // - // Empty / nil = no override (default). Overrides apply equally to - // the proceeding-tree and trigger-event branches. - RuleOverrides []models.DeadlineRule - - // Per-event-card choice overlays (t-paliad-265 / m/paliad#96). - // Keyed by paliad.deadline_rules.submission_code — same key - // AnchorOverrides uses. - // - // - PerCardAppellant: maps a decision-card's submission_code to the - // user-picked appellant ("claimant"|"defendant"|"both"|"none"). - // The engine walks the parent chain of each rule and stamps the - // resulting UIDeadline.AppellantContext from the closest ancestor - // decision with a pick. The frontend bucketer then prefers the - // per-rule context over the page-level appellant. - // - SkipRules: set of submission_code values whose rules (and any - // descendants) the user has opted out of for this projection. - // Same suppression path as a failed condition_expr gate. - // - IncludeCCRFor: set of submission_code values for rules where - // the user opted in to the include-CCR choice (Klageerwiderung - // cards). v1 simplification (design §4.2 #2): if non-empty, - // "with_ccr" is appended to the flag set before gate - // evaluation. Correct for single-CCR-entry-point proceedings - // (UPC INF + DE LG today). Multi-CCR scope is a future expansion. - PerCardAppellant map[string]string - SkipRules map[string]struct{} - IncludeCCRFor map[string]struct{} - - // IncludeHidden re-surfaces rules whose submission_code is in - // SkipRules (t-paliad-290 / m/paliad#122). When true: - // - Skipped rules are NOT dropped from the result; they render - // with UIDeadline.IsHidden=true so the frontend can fade them. - // - Descendant suppression is bypassed (the skipped parent is - // present in the result, so children compute their dates off - // it as if the user had never hidden it). - // Default false preserves the original skip semantic (drop rule + - // suppress descendants). HiddenCount on UIResponse is independent - // of this flag — it always reflects the number of hide-eligible - // rows so the toggle's count badge stays accurate. - IncludeHidden bool -} - -// Calculate renders the full UI timeline for a proceeding type + trigger date. -// Preserves the pre-Phase-C in-memory calculator's classification: -// -// - Rules with duration_value = 0 and no parent_id → IsRootEvent -// (due date = trigger date) -// - Rules with duration_value = 0 and a parent_id → IsCourtSet -// (due date empty, UI shows "court-set" placeholder) -// - All other rules → calculate from either the trigger date (no parent) -// or the previously-computed date for their parent rule. -// -// Audit-driven extensions: -// -// - opts.Flags can flip flag-conditioned rules onto their alt_* values -// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). When a -// rule's condition_flag array is non-empty, the rule renders iff -// EVERY element is in opts.Flags; rules that fail this gate are -// suppressed entirely (used by Phase B1 cross-flow rules that should -// only appear with their flag). -// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt -// set (e.g. epa.grant.exa publication date is 18mo from priority, not filing). -// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the -// caller redirect a downstream rule's parent anchor to a user-set -// date. Used for court-extended deadlines and for entering -// court-set decision dates post-hoc. +// Calculate delegates to litigationplanner.Calculate with paliad's +// Postgres-backed Catalog / HolidayCalendar / CourtRegistry implementations. func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) { - // Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven - // branch (Pipeline-C unified rules; mig 085 moved 77 rows out of - // paliad.event_deadlines into paliad.deadline_rules carrying a - // non-NULL trigger_event_id). proceedingCode is ignored on this - // path. EventDeadlineService.Calculate is the sole caller today; - // future "event-trigger" surfaces (design §5) plug in here too. - if opts.TriggerEventIDFilter != nil { - return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts) - } - - triggerDate, err := time.Parse("2006-01-02", triggerDateStr) - if err != nil { - return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) - } - - var priorityDate *time.Time - if opts.PriorityDateStr != "" { - pd, err := time.Parse("2006-01-02", opts.PriorityDateStr) - if err != nil { - return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err) - } - priorityDate = &pd - } - flagSet := make(map[string]struct{}, len(opts.Flags)) - for _, f := range opts.Flags { - flagSet[f] = struct{}{} - } - // v1 simplification (design §4.2 #2, t-paliad-265): when any - // IncludeCCRFor entry exists, we treat with_ccr as set in the flag - // context. Correct for single-CCR-entry-point proceedings (UPC INF + - // DE LG today). Multi-CCR scope is a future expansion that would - // thread the include set through the gate evaluator per-rule. - if len(opts.IncludeCCRFor) > 0 { - flagSet["with_ccr"] = struct{}{} - } - - // Parse anchor overrides up-front so a malformed date errors out - // before we start walking rules. - overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides)) - for code, dateStr := range opts.AnchorOverrides { - od, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err) - } - overrideDates[code] = od - } - - // Look up proceeding type metadata. - var pt struct { - ID int `db:"id"` - Code string `db:"code"` - Name string `db:"name"` - NameEN string `db:"name_en"` - Jurisdiction *string `db:"jurisdiction"` - TriggerEventLabelDE *string `db:"trigger_event_label_de"` - TriggerEventLabelEN *string `db:"trigger_event_label_en"` - } - err = s.rules.db.GetContext(ctx, &pt, - `SELECT id, code, name, name_en, jurisdiction, - trigger_event_label_de, trigger_event_label_en - FROM paliad.proceeding_types - WHERE code = $1 AND is_active = true`, proceedingCode) - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrUnknownProceedingType - } - if err != nil { - return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) - } - - // Sub-track routing (m/paliad#58). When the user picks a proceeding - // that has no native rules and is normally a sub-track of another - // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route - // rule lookup to the parent and merge the default flags into the - // user's flag set. The response identity (Code/Name/NameEN) stays - // on the user-picked proceeding so the page header still reads - // "Counterclaim for Revocation", but the timeline body is the - // parent's full flow with the sub-track flag enabled. A note - // surfaces the framing. - var pickedProceeding = pt - var subTrackNote SubTrackRouting - var hasSubTrackNote bool - if route, ok := LookupSubTrackRouting(proceedingCode); ok { - subTrackNote = route - hasSubTrackNote = true - // Re-resolve to the parent proceeding for rule lookup. - err = s.rules.db.GetContext(ctx, &pt, - `SELECT id, code, name, name_en, jurisdiction, - trigger_event_label_de, trigger_event_label_en - FROM paliad.proceeding_types - WHERE code = $1 AND is_active = true`, route.ParentCode) - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType) - } - if err != nil { - return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err) - } - // Merge default flags into the user's flag set so the gated - // rules render. User-supplied flags win on conflict (they're - // already in flagSet); default flags only add what's missing. - for _, f := range route.DefaultFlags { - if _, exists := flagSet[f]; !exists { - flagSet[f] = struct{}{} - } - } - } - - // Resolve (country, regime) for non-working-day adjustment. Court wins - // when supplied; otherwise default by proceeding regime. UPC proceedings - // default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA / - // DE proceedings default to DE (no supranational regime). - defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) - country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime) - if err != nil { - return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) - } - - rules, err := s.rules.List(ctx, &pt.ID) - if err != nil { - return nil, err - } - if len(opts.RuleOverrides) > 0 { - rules = applyRuleOverrides(rules, opts.RuleOverrides) - } - - // Walk the rule list in sequence_order (already sorted by the query) and - // compute each entry, keeping a code→date map so RelativeTo / parent_id - // references resolve to the adjusted predecessor date. - computed := make(map[string]time.Time, len(rules)) - courtSet := make(map[uuid.UUID]bool, len(rules)) - deadlines := make([]UIDeadline, 0, len(rules)) - - // Pre-pass: identify rules flagged is_court_set=true in the data so - // order-of-evaluation in sequence_order doesn't matter for the - // parent-court-set check below. Without this, a rule processed - // earlier than its court-set parent (e.g. R.109(1) Antrag auf - // Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung - // sequence_order=50 in upc.inf.cfi) misses the court-set propagation - // and computes a meaningless date — for timing='before' rules, that - // produces a backward offset from the trigger date, which has no - // semantic relationship to the rule. (t-paliad-289) - for _, r := range rules { - if r.IsCourtSet { - courtSet[r.ID] = true - } - } - - // ruleByID lets the conditional-rendering branches resolve a parent - // rule's display fields (submission_code, name, name_en) for the - // "abhängig von " chip without re-scanning the - // rules slice on every iteration. - ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules)) - for _, r := range rules { - ruleByID[r.ID] = r - } - - // triggerEventByID powers the trigger-event override on the - // conditional-label chip (m/paliad#126 / t-paliad-294). When a - // rule carries a real paliad.trigger_events row, that catalog - // event — not the rule's parent_id — is the rule's actual - // semantic anchor. The override fires below when stamping - // ParentRule* on the wire so the chip reads e.g. - // abhängig von Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit - // for R.262(2) Erwiderung auf Vertraulichkeitsantrag — instead of - // the (misleading) parent_id-derived "abhängig von Klageerhebung". - // - // Bulk-loaded in one round-trip; trees in the live corpus carry at - // most a handful of trigger_event_id-bearing rules (2 today on - // upc.inf.cfi), so the IN(...) is small. - var triggerIDs []int64 - seenTrigger := make(map[int64]struct{}, len(rules)) - for _, r := range rules { - if r.TriggerEventID == nil { - continue - } - if _, ok := seenTrigger[*r.TriggerEventID]; ok { - continue - } - seenTrigger[*r.TriggerEventID] = struct{}{} - triggerIDs = append(triggerIDs, *r.TriggerEventID) - } - triggerEventByID, err := s.rules.LoadTriggerEventsByIDs(ctx, triggerIDs) - if err != nil { - return nil, fmt.Errorf("load trigger events for conditional labels: %w", err) - } - - // Per-event-card overlays (t-paliad-265). Empty/nil maps are safe - // for membership tests; the engine reads them but doesn't mutate. - skipRules := opts.SkipRules - perCardAppellant := opts.PerCardAppellant - // skippedIDs accumulates the set of rule UUIDs whose timeline entry - // the user has opted out of. Walking in sequence_order means a - // child rule's parent has already been classified — so descendant - // suppression is a one-pass parent_id lookup. - skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) - // hiddenCount counts rows whose submission_code is in skipRules - // AND that pass the condition_expr gate — i.e. rows the user has - // hidden in this projection. Surfaced on UIResponse.HiddenCount so - // the frontend's "Ausgeblendete (N)" badge stays accurate even when - // IncludeHidden is off and the rows aren't in the result list. - // (t-paliad-290 / m/paliad#122) - hiddenCount := 0 - // appellantContext maps a rule UUID to the appellant value that - // applies to its descendants. A rule that has its own PerCardAppellant - // pick stamps itself with that value; a rule whose parent has a - // context inherits it. - appellantContext := make(map[uuid.UUID]string, len(rules)) - - for _, r := range rules { - // Phase-3 unified gate: evaluate condition_expr (jsonb). - // Suppression semantic preserved: when the gate fires false AND - // no alt_* values exist, the rule is dropped from the timeline - // entirely (purely conditional). When alt_* values exist, the - // gate-false branch still renders, just without the alt-swap - // (legacy "swap-on-flag" pattern, e.g. with_ccr). - gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet) - if !gateMet && r.AltDurationValue == nil { - continue - } - - // SkipRules suppression (t-paliad-265): the user has marked - // this rule (or one of its ancestors) as "don't consider for - // this case". Drop the row entirely AND record the rule ID so - // descendants suppress too. - // - // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, - // we re-surface the directly-skipped row (faded via IsHidden) - // instead of dropping it. Descendants are NOT cascade-suppressed - // in that mode either — the un-suppressed parent computes its - // date normally, so children compute off it as usual. Either - // way we count the hide for the toggle's badge. - var isHidden bool - if r.SubmissionCode != nil { - if _, skipped := skipRules[*r.SubmissionCode]; skipped { - hiddenCount++ - if !opts.IncludeHidden { - skippedIDs[r.ID] = struct{}{} - continue - } - isHidden = true - } - } - if r.ParentID != nil { - if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped { - skippedIDs[r.ID] = struct{}{} - continue - } - } - - // AppellantContext propagation. A rule with its own PerCardAppellant - // pick stamps its UUID with that value. Otherwise inherit from - // parent if the parent had a context. - var ctxVal string - if r.SubmissionCode != nil { - if v, ok := perCardAppellant[*r.SubmissionCode]; ok { - ctxVal = v - } - } - if ctxVal == "" && r.ParentID != nil { - if v, ok := appellantContext[*r.ParentID]; ok { - ctxVal = v - } - } - if ctxVal != "" { - appellantContext[r.ID] = ctxVal - } - - d := UIDeadline{ - RuleID: r.ID.String(), - Name: r.Name, - NameEN: r.NameEN, - Priority: r.Priority, - ConditionExpr: json.RawMessage(r.ConditionExpr), - AppellantContext: ctxVal, - ChoicesOffered: json.RawMessage(r.ChoicesOffered), - IsHidden: isHidden, - } - if r.SubmissionCode != nil { - d.Code = *r.SubmissionCode - } - if r.PrimaryParty != nil { - d.Party = *r.PrimaryParty - } - if r.RuleCode != nil { - d.RuleRef = *r.RuleCode - } - if r.LegalSource != nil { - d.LegalSource = *r.LegalSource - d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) - d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) - } - if r.DeadlineNotes != nil { - d.Notes = *r.DeadlineNotes - } - if r.DeadlineNotesEn != nil { - d.NotesEN = *r.DeadlineNotesEn - } - - // Resolve the parent rule once so every conditional-rendering - // branch (incl. the optional-not-recorded path below) can stamp - // ParentRule* on the wire without re-scanning. Populated even - // for non-conditional rows — the frontend dependency-footer - // ("Folgt aus …") already consumes this on regular projected - // rows. (t-paliad-289) - var parentRule *models.DeadlineRule - if r.ParentID != nil { - if pr, ok := ruleByID[*r.ParentID]; ok { - parentRule = &pr - if pr.SubmissionCode != nil { - d.ParentRuleCode = *pr.SubmissionCode - } - d.ParentRuleName = pr.Name - d.ParentRuleNameEN = pr.NameEN - } - } - - // Trigger-event override on the user-facing dependency identity - // (m/paliad#126 / t-paliad-294). When a rule has a real - // trigger_event_id, that catalog event is the actual semantic - // anchor — not the parent_id node, which is only the calc-time - // arithmetic anchor. R.262(2) Erwiderung auf Vertraulichkeits- - // antrag is the canonical case: parent_id resolves to the SoC - // ("Klageerhebung"), but the real triggering event is the - // opposing party's confidentiality application. Generalises to - // any rule whose trigger_event_id is set (e.g. R.6(2) - // translations_lodge → judge-rapporteur's order). - // - // Only the user-facing wire fields shift; parentRule (and the - // parent_id chain that feeds parentIsCourtSet / the calc-time - // date arithmetic below) stays anchored on the rule tree — - // that's still the right calc semantic. parentRule is NOT - // reassigned here. - if r.TriggerEventID != nil { - if te, ok := triggerEventByID[*r.TriggerEventID]; ok { - d.ParentRuleCode = te.Code - d.ParentRuleName = te.NameDE - d.ParentRuleNameEN = te.Name - } - } - - // Propagate court-set status from a parent rule whose date the - // court determines: if the anchor itself has no real date, - // nothing downstream can be computed either — UNLESS the user - // has supplied an override date for the parent (which they can - // once they know the real decision date). - parentOverridden := false - if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil { - if parentRule.SubmissionCode != nil { - if _, ok := overrideDates[*parentRule.SubmissionCode]; ok { - parentOverridden = true - } - } - } - parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden - - // Zero-duration rules fall into one of four buckets: - // 1. parent=nil, not court-determined → IsRootEvent (trigger anchor) - // 2. parent=nil, court-determined → IsCourtSet (Zwischenverfahren / - // Mündliche Verhandlung / Entscheidung etc.) - // 3. parent set, court-determined → IsCourtSet (waypoint) - // 4. parent set, NOT court-determined → "filed-with-parent" - // semantic: rule is filed AT THE SAME TIME as its parent - // (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says - // Application to amend / Counterclaim for infringement are - // INCLUDED in the Defence to revocation). Use the parent's - // computed date. - // - // AnchorOverrides: when the user has set a date for any - // zero-duration rule, that override wins over both the - // court-set placeholder and the parent-inheritance. - if r.DurationValue == 0 { - // User override always wins. - if r.SubmissionCode != nil { - if ov, ok := overrideDates[*r.SubmissionCode]; ok { - d.DueDate = ov.Format("2006-01-02") - d.OriginalDate = d.DueDate - d.IsOverridden = true - computed[*r.SubmissionCode] = ov - deadlines = append(deadlines, d) - continue - } - } - - if r.ParentID == nil && !r.IsCourtSet { - // Bucket 1: timeline anchor. - d.IsRootEvent = true - d.DueDate = triggerDateStr - d.OriginalDate = triggerDateStr - if r.SubmissionCode != nil { - computed[*r.SubmissionCode] = triggerDate - } - } else if r.ParentID != nil && !r.IsCourtSet { - // Bucket 4: filed-with-parent. Inherit parent's date. - // If parent is court-set, we have nothing to inherit — - // fall through to court-set marking. - if parentIsCourtSet { - // Indirect: this rule isn't itself court-determined, - // it's blocked because its parent is. UI should say - // "unbestimmt", not "wird vom Gericht bestimmt". - d.IsCourtSet = true - d.IsCourtSetIndirect = true - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - } else { - var parentDate time.Time - var haveParentDate bool - for _, prev := range rules { - if prev.ID == *r.ParentID { - if prev.SubmissionCode != nil { - if ov, ok := overrideDates[*prev.SubmissionCode]; ok { - parentDate = ov - haveParentDate = true - } else if ref, ok := computed[*prev.SubmissionCode]; ok { - parentDate = ref - haveParentDate = true - } - } - break - } - } - if haveParentDate { - d.DueDate = parentDate.Format("2006-01-02") - d.OriginalDate = d.DueDate - if r.SubmissionCode != nil { - computed[*r.SubmissionCode] = parentDate - } - } else { - // Parent not yet computed (defensive — shouldn't - // happen given sequence_order). Treat as indirect - // court-set: the date is unknown but the rule - // itself isn't a court action. - d.IsCourtSet = true - d.IsCourtSetIndirect = true - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - } - } - } else { - // Buckets 2 + 3: court-determined directly (the rule - // itself is a hearing / decision / order or has - // primary_party='court'). The label "wird vom Gericht - // bestimmt" is strictly correct here — keep - // IsCourtSetIndirect=false. - d.IsCourtSet = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - } - deadlines = append(deadlines, d) - continue - } - - // If the parent is court-determined and not overridden we have no - // real anchor date; surface this rule as court-set too rather - // than fabricating one off the trigger date. The user can re-run - // with the actual decision date once the court issues it (or - // supplied via AnchorOverrides). - // - // This is the RoP.151 case (Antrag auf Kostenentscheidung is - // "1 Monat ab Hauptentscheidung") — the rule has a real - // duration but its anchor is the court-set parent. The UI - // should say "unbestimmt", not "wird vom Gericht bestimmt": - // the date isn't directly determined by the court, it's - // derived from a date the court sets. - // - // timing='before' rules end up here too — a rule with - // "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the - // oral hearing as its parent; if the hearing date isn't set, - // the backward arithmetic against the trigger date is - // meaningless. The pre-pass above ensures courtSet[oral.ID] - // is true even when the oral hearing rule is processed later - // in sequence_order. IsConditional surfaces the "abhängig - // von " UX. (t-paliad-289) - if parentIsCourtSet { - d.IsCourtSet = true - d.IsCourtSetIndirect = true - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - deadlines = append(deadlines, d) - continue - } - - // Anchor: prefer alt-anchor (e.g. priority_date for epa.grant.exa publish) - // when supplied, then parent's computed date (or user override), - // then trigger date. - baseDate := triggerDate - if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { - baseDate = *priorityDate - } else if r.ParentID != nil { - // Linear scan is fine — rule trees are < 20 entries. - for _, prev := range rules { - if prev.ID == *r.ParentID { - if prev.SubmissionCode != nil { - // User override on the parent rule wins over - // the calculated date — lets the user redirect - // downstream from a real (court-extended, - // court-set) date. - if ov, ok := overrideDates[*prev.SubmissionCode]; ok { - baseDate = ov - } else if ref, ok := computed[*prev.SubmissionCode]; ok { - baseDate = ref - } - } - break - } - } - } - - // Flag-conditioned alt-swap (legacy with_ccr pattern): when the - // gate fires AND alt_* values exist, swap the primary duration - // to the alt values. This is distinct from combine_op below — - // alt-swap is a one-or-the-other choice keyed on flags, whereas - // combine_op computes both legs and picks max/min. Mutually - // exclusive in the live corpus today (no rule sets both). - durationValue := r.DurationValue - durationUnit := r.DurationUnit - timing := "" - if r.Timing != nil { - timing = *r.Timing - } - if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil { - durationValue = *r.AltDurationValue - if r.AltDurationUnit != nil { - durationUnit = *r.AltDurationUnit - } - if r.AltRuleCode != nil { - d.RuleRef = *r.AltRuleCode - } - } - - // User override on this rule: replace the calculated date with - // the user's date. Skip holiday rollover — the user's date is - // authoritative. Downstream rules that chain off this rule will - // see the override via the parent-anchor lookup above. - if r.SubmissionCode != nil { - if ov, ok := overrideDates[*r.SubmissionCode]; ok { - d.OriginalDate = ov.Format("2006-01-02") - d.DueDate = ov.Format("2006-01-02") - d.WasAdjusted = false - d.AdjustmentReason = nil - d.IsOverridden = true - computed[*r.SubmissionCode] = ov - deadlines = append(deadlines, d) - continue - } - } - - origDate, adjusted, wasAdj, reason := applyDuration( - baseDate, durationValue, durationUnit, timing, country, regime, s.holidays, - ) - - // combine_op composite: compute the alt leg too, apply max/min. - // No proceeding-tree rules carry combine_op today (it's a - // future-friendly column the rule editor will surface). When - // present, the gate-met / alt-swap branch above has been - // skipped, so the comparison is between the unmodified base - // (durationValue/Unit) and the alt (AltDurationValue/Unit). - if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { - altOrig, altAdj, altWasAdj, altReason := applyDuration( - baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays, - ) - switch *r.CombineOp { - case "max": - if altAdj.After(adjusted) { - origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason - } - case "min": - if altAdj.Before(adjusted) { - origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason - } - } - } - - d.OriginalDate = origDate.Format("2006-01-02") - d.DueDate = adjusted.Format("2006-01-02") - d.WasAdjusted = wasAdj - d.AdjustmentReason = reason - - // Optional-on-the-other-side detection (t-paliad-289 Symptom B). - // Rules with priority='optional' AND primary_party='both' whose - // data-model parent is the proceeding's trigger anchor (parent - // has parent_id=NULL and is not court-set, i.e. the SoC root - // rule) represent a rule whose REAL triggering event sits - // outside the rule data — e.g. R.262(2) Erwiderung auf - // Vertraulichkeitsantrag anchors on SoC in the data, but the - // real trigger is the opposing party's confidentiality motion - // which may never happen. Without an explicit anchor on the - // rule itself (user clicks "Datum setzen" after the motion - // arrives), the projection must NOT claim a concrete date. - // - // In the live corpus this catches confidentiality_response; - // every other optional+both rule has a court-set ancestor and - // is already caught by the parentIsCourtSet branches above. - // Suppressed when IsOverridden (the user has anchored the rule - // — the date is real) or when the rule has already been marked - // IsConditional by an earlier branch. - if !d.IsOverridden && !d.IsConditional && - r.Priority == "optional" && - r.PrimaryParty != nil && *r.PrimaryParty == "both" && - parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet { - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - d.WasAdjusted = false - d.AdjustmentReason = nil - // Mark this rule's ID as having an uncertain anchor so - // rules chaining off it also surface conditional via the - // parentIsCourtSet path (no rule currently chains off - // confidentiality_response in the live corpus, but the - // extension keeps the propagation semantics consistent). - courtSet[r.ID] = true - } - - if r.SubmissionCode != nil { - computed[*r.SubmissionCode] = adjusted - } - deadlines = append(deadlines, d) - } - - // t-paliad-296: within consecutive runs of rules sharing the same - // trigger group (parent_id + trigger_event_id), reorder by duration - // ascending so optional events following the same anchor render in - // their likely-sequence order (a 1-month rule before a 2-month rule - // chained off the same decision). Different trigger groups keep - // their proceeding-sequence position — the chunk walk only sorts - // adjacent same-group rows. Court-set / conditional rows whose - // date isn't in the duration ladder sort LAST within their group. - sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) - - resp := &UIResponse{ - ProceedingType: pickedProceeding.Code, - ProceedingName: pickedProceeding.Name, - ProceedingNameEN: pickedProceeding.NameEN, - TriggerDate: triggerDateStr, - Deadlines: deadlines, - HiddenCount: hiddenCount, - } - // Sub-track routing keeps the user-picked proceeding's identity, - // so the trigger-event label rides on `pickedProceeding` (e.g. - // upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not - // upc.ccr.cfi's own — which is fine: the sub-track note already - // explains the framing). - if pickedProceeding.TriggerEventLabelDE != nil { - resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE - } - if pickedProceeding.TriggerEventLabelEN != nil { - resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN - } - if hasSubTrackNote { - resp.ContextualNote = subTrackNote.NoteDE - resp.ContextualNoteEN = subTrackNote.NoteEN - } - return resp, nil + return lp.Calculate(ctx, proceedingCode, triggerDateStr, opts, s.catalog, s.holidays, s.courts) } -// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of -// deadlines whose underlying rule shares the same trigger group -// (parent_id + trigger_event_id) and reorders each run in place by -// duration ascending. Different trigger groups keep their original -// proceeding-sequence position — the walk only ever permutes adjacent -// same-group rows. -// -// Sort key (within a run): -// 1. Conditional / court-set rows (no concrete date in the duration -// ladder) sort LAST, tiebroken by submission_code. -// 2. duration_unit weight ASC: days/working_days < weeks < months < years -// 3. duration_value ASC -// 4. submission_code ASC (deterministic tiebreak) -// -// Issue: m/paliad#128 — post-decision optional events (R.151/R.353 -// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog -// order instead of likely-sequence order. (t-paliad-296) -func sortDeadlinesByDurationWithinTriggerGroup( - deadlines []UIDeadline, - ruleByID map[uuid.UUID]models.DeadlineRule, -) { - if len(deadlines) < 2 { - return - } - n := len(deadlines) - i := 0 - for i < n { - gid := triggerGroupKey(deadlines[i], ruleByID) - j := i + 1 - for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid { - j++ - } - // Root rules (no parent and no trigger_event) get gid="root" - // and would otherwise collapse into one big run. Skip the sort - // for the "root" pseudo-group — each root rule represents its - // own anchor (SoC, oral hearing, decision …) and the - // proceeding-sequence order between them must be preserved. - if j-i > 1 && gid != "" { - chunk := deadlines[i:j] - sort.SliceStable(chunk, func(a, b int) bool { - return durationLessForSort(chunk[a], chunk[b], ruleByID) - }) - } - i = j - } -} - -// triggerGroupKey returns a string key identifying which trigger group -// a deadline belongs to. Same key = same group = candidates for sort. -// Empty string means "root" (no parent, no trigger_event) — used as a -// sentinel by the caller to skip sorting roots against each other. -func triggerGroupKey(d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule) string { - rid, err := uuid.Parse(d.RuleID) - if err != nil { - return "" - } - r, ok := ruleByID[rid] - if !ok { - return "" - } - if r.ParentID != nil { - return "p:" + r.ParentID.String() - } - if r.TriggerEventID != nil { - return fmt.Sprintf("t:%d", *r.TriggerEventID) - } - return "" -} - -// durationLessForSort compares two deadlines for the duration-ascending -// sort. Court-set / conditional rows (no concrete date) sort LAST -// regardless of duration — they don't fit the duration ladder. -func durationLessForSort( - a, b UIDeadline, - ruleByID map[uuid.UUID]models.DeadlineRule, -) bool { - aLast := a.IsCourtSet || a.IsConditional - bLast := b.IsCourtSet || b.IsConditional - if aLast != bLast { - return !aLast - } - if aLast && bLast { - return a.Code < b.Code - } - - ra := lookupRuleFromDeadline(a, ruleByID) - rb := lookupRuleFromDeadline(b, ruleByID) - - wa := durationUnitWeight(ra.DurationUnit) - wb := durationUnitWeight(rb.DurationUnit) - if wa != wb { - return wa < wb - } - if ra.DurationValue != rb.DurationValue { - return ra.DurationValue < rb.DurationValue - } - return a.Code < b.Code -} - -func lookupRuleFromDeadline( - d UIDeadline, - ruleByID map[uuid.UUID]models.DeadlineRule, -) models.DeadlineRule { - if d.RuleID == "" { - return models.DeadlineRule{} - } - rid, err := uuid.Parse(d.RuleID) - if err != nil { - return models.DeadlineRule{} - } - return ruleByID[rid] -} - -// durationUnitWeight maps a duration unit to its sort weight so the -// trigger-group sort can order shorter durations first. days and -// working_days share weight 0 (both are sub-week granularities); -// unknown units sort to the end so they're visible as a tail rather -// than silently winning. -func durationUnitWeight(unit string) int { - switch unit { - case "days", "working_days": - return 0 - case "weeks": - return 1 - case "months": - return 2 - case "years": - return 3 - } - return 4 -} - -// ErrUnknownRule is returned when CalculateRule can't resolve the -// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule. -var ErrUnknownRule = errors.New("unknown rule") - -// CalcRuleParams identifies a single rule and the inputs needed to -// compute one deadline from it. Caller supplies either RuleID OR the -// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on -// hand from the concept-card pill it just received a click on. -type CalcRuleParams struct { - RuleID string // optional — UUID - ProceedingCode string // optional — used with RuleLocalCode - RuleLocalCode string // optional — paliad.deadline_rules.code - TriggerDate string // required — YYYY-MM-DD - Flags []string // optional — condition_flag inputs - CourtID string // optional — selects holiday calendar; defaults via proceeding's jurisdiction -} - -// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc -// response that backs the result-card click → calc-panel flow. Distinct -// from UIDeadline (which represents one rendered timeline row inside a -// full-proceeding response): RuleCalculation is self-contained — caller -// gets the rule metadata + the computed date in one payload, no separate -// proceeding-types lookup needed. -// -// Trigger semantics: TriggerDate is the immediate parent event's -// effective date — i.e. when the user clicks "Duplik" in the card and -// types "2026-05-05", they mean "I received the Replik on 2026-05-05". -// We do NOT walk the parent chain; callers wanting the full timeline -// for a proceeding still go through Calculate. -type RuleCalculation struct { - Rule RuleCalculationRule `json:"rule"` - Proceeding RuleCalculationProceeding `json:"proceeding"` - TriggerDate string `json:"triggerDate"` - OriginalDate string `json:"originalDate"` - DueDate string `json:"dueDate"` - WasAdjusted bool `json:"wasAdjusted"` - AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"` - IsCourtSet bool `json:"isCourtSet"` - // FlagsApplied lists the condition_flag values from the rule that - // the caller's Flags satisfied. Empty when the rule has no - // condition_flag, OR when the caller didn't satisfy the gate. Lets - // the frontend show "Mit Nichtigkeitswiderklage angewandt" hints. - FlagsApplied []string `json:"flagsApplied,omitempty"` - // FlagsRequired is the rule's condition_flag in canonical order so - // the frontend can render checkboxes for each flag the rule gates on. - FlagsRequired []string `json:"flagsRequired,omitempty"` -} - -// RuleCalculationRule mirrors the small subset of DeadlineRule the -// frontend needs to render the calc panel. -type RuleCalculationRule struct { - ID string `json:"id"` - LocalCode string `json:"localCode,omitempty"` - NameDE string `json:"nameDE"` - NameEN string `json:"nameEN"` - RuleRef string `json:"ruleRef,omitempty"` - LegalSource string `json:"legalSource,omitempty"` - LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` - LegalSourceURL string `json:"legalSourceURL,omitempty"` - DurationValue int `json:"durationValue"` - DurationUnit string `json:"durationUnit"` - Party string `json:"party,omitempty"` - IsMandatory bool `json:"isMandatory"` - NotesDE string `json:"notesDE,omitempty"` - NotesEN string `json:"notesEN,omitempty"` -} - -// RuleCalculationProceeding identifies the proceeding context for the -// rule. Used by the frontend for display + by the add-to-project flow. -type RuleCalculationProceeding struct { - Code string `json:"code"` - NameDE string `json:"nameDE"` - NameEN string `json:"nameEN"` -} - -// CalculateRule computes a single deadline from a rule + trigger date. -// Used by the v4 result-card click flow. Distinct from Calculate: no -// parent-chain walk, no full-timeline rendering — just one date out. -// -// When the rule is court-determined (primary_party='court' or event_type -// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true; -// the caller should disable the "Add to project" CTA in that case. -// -// When the rule has condition_flag and the caller's Flags satisfy every -// element AND alt_duration_value is non-NULL, the calc swaps to alt_* -// (matches the existing flag-conditional semantics in Calculate). -// -// When the rule has condition_flag and the caller's Flags do NOT satisfy -// every element, the calc still proceeds with the base duration_value -// and surfaces FlagsRequired so the frontend can render the gating -// checkboxes. The result IS the date the rule would be due if the user -// confirmed the flag — letting the user toggle the checkbox and see the -// duration change live. +// CalculateRule delegates to litigationplanner.CalculateRule. Distinct +// from Calculate: no parent-chain walk, no full-timeline rendering — +// just one date out. 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 + return lp.CalculateRule(ctx, params, s.catalog, s.holidays, s.courts) } -// 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) { +// ListFristenrechnerTypes returns the proceeding types that populate +// the Fristenrechner UI (category='fristenrechner'), ordered by +// sort_order. Stays on the service because the response is a paliad- +// specific surface (the wire shape FristenrechnerType is owned by the +// package but the SQL filter is paliad-side). +func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) { rows, err := s.rules.db.QueryxContext(ctx, ` SELECT code, name, name_en, jurisdiction FROM paliad.proceeding_types @@ -1372,9 +89,9 @@ func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([] } defer rows.Close() - var out []FristenrechnerType + var out []lp.FristenrechnerType for rows.Next() { - var t FristenrechnerType + var t lp.FristenrechnerType var juris sql.NullString if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil { return nil, err @@ -1387,430 +104,210 @@ func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([] 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"` +// FristenrechnerType is paliad's local alias for lp.FristenrechnerType +// so historical call-sites (services.FristenrechnerType) keep working. +type FristenrechnerType = lp.FristenrechnerType + +// --------------------------------------------------------------------- +// paliadCatalog is the paliad-side litigationplanner.Catalog adapter. +// Wraps DeadlineRuleService to expose proceeding + rule lookups against +// paliad.proceeding_types + paliad.deadline_rules. +// --------------------------------------------------------------------- + +type paliadCatalog struct { + rules *DeadlineRuleService } -// 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 -} +// proceedingTypeColumns is canonically defined in +// deadline_rule_service.go; the catalog adapter reuses it via the +// shared package-level const. -// 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": ""} — leaf: true iff ∈ flags -// {"op": "and", "args": [...]} — true iff every arg evaluates true -// {"op": "or", "args": [...]} — true iff any arg evaluates true -// {"op": "not", "args": []} — 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":""},...]} jsonb directly. -func evalConditionExpr(expr []byte, flags map[string]struct{}) bool { - if len(expr) == 0 || string(expr) == "null" { - return true +// LoadProceeding returns the proceeding-type metadata + rules. The +// ProjectHint is currently ignored on paliad's side (per m's 2026-05-26 +// decision dropping the Slice E user-authored rules); kept on the +// interface for forward-compat. +func (c *paliadCatalog) LoadProceeding(ctx context.Context, code string, _ lp.ProjectHint) (*models.ProceedingType, []models.DeadlineRule, error) { + var pt models.ProceedingType + err := c.rules.db.GetContext(ctx, &pt, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE code = $1 AND is_active = true`, code) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, lp.ErrUnknownProceedingType } - 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":""} 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) + return nil, nil, fmt.Errorf("resolve proceeding %q: %w", code, 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) + rules, err := c.rules.List(ctx, &pt.ID) if err != nil { - return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) + return nil, nil, err } + return &pt, rules, nil +} - rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID) +// LoadProceedingByID is the resolver for a rule's parent proceeding. +func (c *paliadCatalog) LoadProceedingByID(ctx context.Context, id int) (*models.ProceedingType, error) { + var pt models.ProceedingType + err := c.rules.db.GetContext(ctx, &pt, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE id = $1`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, lp.ErrUnknownProceedingType + } if err != nil { + return nil, fmt.Errorf("resolve proceeding by id %d: %w", id, err) + } + return &pt, nil +} + +// LoadRuleByID resolves a rule UUID to the rule row. +func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*models.DeadlineRule, error) { + var rule models.DeadlineRule + err := c.rules.db.GetContext(ctx, &rule, + `SELECT `+ruleColumns+` + FROM paliad.deadline_rules + WHERE id = $1 AND is_active = true`, ruleID) + if errors.Is(err, sql.ErrNoRows) { + return nil, lp.ErrUnknownRule + } + if err != nil { + return nil, fmt.Errorf("resolve rule by id %q: %w", ruleID, err) + } + if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); 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 + return &rule, 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. +// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode) +// + returns the parent proceeding for use in the response identity. +func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*models.DeadlineRule, *models.ProceedingType, error) { + var pt models.ProceedingType + err := c.rules.db.GetContext(ctx, &pt, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE code = $1 AND is_active = true`, proceedingCode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, lp.ErrUnknownProceedingType + } + if err != nil { + return nil, nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) + } + var rule models.DeadlineRule + err = c.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, submissionCode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, lp.ErrUnknownRule + } + if err != nil { + return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", submissionCode, proceedingCode, err) + } + if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil { + return nil, nil, err + } + return &rule, &pt, nil +} + +// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules. +func (c *paliadCatalog) LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) { + return c.rules.ListByTriggerEvent(ctx, triggerEventID) +} + +// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for +// the conditional-label override (t-paliad-294 / m/paliad#126). +func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) { + return c.rules.LoadTriggerEventsByIDs(ctx, ids) +} + +// _ proves paliadCatalog satisfies lp.Catalog at compile time. +var _ lp.Catalog = (*paliadCatalog)(nil) + +// Ensure HolidayService satisfies lp.HolidayCalendar at compile time. +// HolidayService.AdjustForNonWorkingDaysWithReason returns the +// AdjustmentReason via paliad's internal type — since lp.AdjustmentReason +// is now the canonical definition and AdjustmentReason inside services +// is aliased to it, the signatures align verbatim. +var _ lp.HolidayCalendar = (*HolidayService)(nil) + +// Ensure CourtService satisfies lp.CourtRegistry at compile time. +var _ lp.CourtRegistry = (*CourtService)(nil) + +// --------------------------------------------------------------------- +// Helpers used by sibling services (event_trigger_service, +// event_deadline_service). Re-exported as thin wrappers so the existing +// call-sites in those services continue to compile without an import +// rewrite. A future slice can collapse them onto direct lp.* imports. +// --------------------------------------------------------------------- + +// applyDuration delegates to litigationplanner.ApplyDuration. +func applyDuration(base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) { + return lp.ApplyDuration(base, value, unit, timing, country, regime, holidays) +} + +// addWorkingDays delegates to litigationplanner.AddWorkingDays. // -// 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, "" - } +//nolint:unused // referenced for forward-compat with sibling services +func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time { + return lp.AddWorkingDays(from, n, country, regime, holidays) +} + +// evalConditionExpr delegates to litigationplanner.EvalConditionExpr. +func evalConditionExpr(expr []byte, flags map[string]struct{}) bool { + return lp.EvalConditionExpr(expr, flags) +} + +// hasConditionExpr delegates to litigationplanner.HasConditionExpr. +func hasConditionExpr(expr models.NullableJSON) bool { + return lp.HasConditionExpr(expr) +} + +// extractFlagsFromExpr delegates to litigationplanner.ExtractFlagsFromExpr. +// +//nolint:unused // retained for sibling services that may want it +func extractFlagsFromExpr(expr models.NullableJSON) []string { + return lp.ExtractFlagsFromExpr(expr) +} + +// allFlagsSet delegates to litigationplanner.AllFlagsSet. Retained for +// the paliad-side test suite that asserts the helper's contract. +func allFlagsSet(required []string, set map[string]struct{}) bool { + return lp.AllFlagsSet(required, set) +} + +// wireFlagsFromPriority delegates to +// litigationplanner.WireFlagsFromPriority. Retained for the paliad-side +// test suite that asserts the priority → (isMandatory, isOptional) +// mapping. +func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { + return lp.WireFlagsFromPriority(priority) +} + +// sortDeadlinesByDurationWithinTriggerGroup is the paliad-side wrapper +// retained for the t-paliad-296 sort tests. Delegates to the +// package-internal sort over the lp.TimelineEntry shape — which is +// just an alias for UIDeadline, so callers pass []UIDeadline directly. +func sortDeadlinesByDurationWithinTriggerGroup( + deadlines []UIDeadline, + ruleByID map[uuid.UUID]models.DeadlineRule, +) { + lp.SortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) +} + +// DefaultsForJurisdiction delegates to +// litigationplanner.DefaultsForJurisdiction. Public re-export so +// handlers (deadline_rules_db.go) can keep using +// services.DefaultsForJurisdiction without an import-rewrite. +func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) { + return lp.DefaultsForJurisdiction(jurisdiction) +} + +// applyRuleOverrides delegates to litigationplanner.ApplyRuleOverrides. +// +//nolint:unused // retained for sibling services that may want it +func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule { + return lp.ApplyRuleOverrides(src, overrides) } diff --git a/internal/services/holidays.go b/internal/services/holidays.go index c74895d..b346ff7 100644 --- a/internal/services/holidays.go +++ b/internal/services/holidays.go @@ -8,6 +8,8 @@ import ( "time" "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // Country and regime constants — keep in sync with the paliad.countries @@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime // Feiertag" — so a 27-day shift across UPC vacation no longer looks like a // math bug. See t-paliad-119. // -// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention -// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a -// separate RFC3339 parser. Holidays carries the same string-date shape. -type AdjustmentReason struct { - // Kind is the dominant cause; longest cause wins when several apply - // (vacation > public_holiday > weekend). - Kind string `json:"kind"` - // Holidays collects every named holiday encountered while walking past - // the non-working run, deduped by (date, name). May be empty when the - // only cause is a weekend. - Holidays []HolidayDTO `json:"holidays,omitempty"` - // VacationName, VacationStart and VacationEnd describe the contiguous - // vacation block the original date sits in. Populated only when Kind - // == "vacation". Span boundaries are the first/last vacation day in - // the block (excludes the weekends that pad it). - VacationName string `json:"vacationName,omitempty"` - VacationStart string `json:"vacationStart,omitempty"` - VacationEnd string `json:"vacationEnd,omitempty"` - // OriginalWeekday is the English weekday name of the original date — - // "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI - // can localise it. - OriginalWeekday string `json:"originalWeekday,omitempty"` -} - -// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason — -// distinct from Holiday so dates serialise as YYYY-MM-DD strings. -type HolidayDTO struct { - Date string `json:"date"` - Name string `json:"name"` - IsVacation bool `json:"isVacation,omitempty"` - IsClosure bool `json:"isClosure,omitempty"` -} +// Canonical AdjustmentReason + HolidayDTO definitions live in +// pkg/litigationplanner — kept here as type aliases so every existing +// reference (HolidayService methods, JSON serialisation, projection +// service) continues to compile. +type ( + AdjustmentReason = litigationplanner.AdjustmentReason + HolidayDTO = litigationplanner.HolidayDTO +) // AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an // explanation. Reason is nil when wasAdjusted is false. diff --git a/internal/services/proceeding_mapping.go b/internal/services/proceeding_mapping.go index 1174d5c..7938804 100644 --- a/internal/services/proceeding_mapping.go +++ b/internal/services/proceeding_mapping.go @@ -1,191 +1,63 @@ package services -// proceeding_mapping bridges the two proceeding-type vocabularies in the -// codebase: the **litigation** conceptual category (INF / REV / APP / -// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding -// + Pipeline-A rules, and the **fristenrechner** code category -// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator -// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects -// bind to fristenrechner codes directly, but the litigation→fristenrechner -// mapping is still needed for the ~40 Pipeline-A rules that remain on -// litigation proceedings and for any other surface that thinks in -// litigation terms. -// -// The mapping table here is the single source of truth — see -// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the -// design rationale + ambiguity notes, and -// docs/design-proceeding-code-taxonomy-2026-05-18.md for the -// lowercase dot-separated naming convention applied by mig 096 -// (t-paliad-206). **Never silent FK promotion**: every ambiguous case -// returns ok=false so callers can degrade gracefully ("no narrowing") -// instead of guessing. +import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" -// Stable code constants — the strings landed by mig 096. Use these -// throughout the codebase so a future rename only needs to touch this -// file. The id-anchored FKs (deadline_rules.proceeding_type_id, -// projects.proceeding_type_id) are unaffected by the rename. +// proceeding_mapping bridges the two proceeding-type vocabularies in +// the codebase. The canonical implementations now live in +// pkg/litigationplanner — this file keeps the existing service-level +// names alive as re-exports so the rest of internal/services + tests +// compile without an import-rewrite. +// +// See pkg/litigationplanner/proceeding_mapping.go for the logic + +// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the +// design rationale. + +// Stable code constants — re-exported from the package so existing +// services / handlers can keep using the bare names. const ( - CodeUPCInfringement = "upc.inf.cfi" - CodeUPCRevocation = "upc.rev.cfi" - CodeUPCCounterclaim = "upc.ccr.cfi" - CodeUPCPreliminary = "upc.pi.cfi" - CodeUPCDamages = "upc.dmgs.cfi" - CodeUPCDiscovery = "upc.disc.cfi" - CodeUPCAppealMerits = "upc.apl.merits" - CodeUPCAppealOrder = "upc.apl.order" - CodeUPCAppealCost = "upc.apl.cost" - CodeDEInfringementLG = "de.inf.lg" - CodeDEInfringementOLG = "de.inf.olg" - CodeDEInfringementBGH = "de.inf.bgh" - CodeDENullityBPatG = "de.null.bpatg" - CodeDENullityBGH = "de.null.bgh" - CodeEPAGrant = "epa.grant.exa" - CodeEPAOpposition = "epa.opp.opd" - CodeEPAOppositionAppeal = "epa.opp.boa" - CodeDPMAOpposition = "dpma.opp.dpma" - CodeDPMAAppealBPatG = "dpma.appeal.bpatg" - CodeDPMAAppealBGH = "dpma.appeal.bgh" + CodeUPCInfringement = lp.CodeUPCInfringement + CodeUPCRevocation = lp.CodeUPCRevocation + CodeUPCCounterclaim = lp.CodeUPCCounterclaim + CodeUPCPreliminary = lp.CodeUPCPreliminary + CodeUPCDamages = lp.CodeUPCDamages + CodeUPCDiscovery = lp.CodeUPCDiscovery + CodeUPCAppealMerits = lp.CodeUPCAppealMerits + CodeUPCAppealOrder = lp.CodeUPCAppealOrder + CodeUPCAppealCost = lp.CodeUPCAppealCost + CodeDEInfringementLG = lp.CodeDEInfringementLG + CodeDEInfringementOLG = lp.CodeDEInfringementOLG + CodeDEInfringementBGH = lp.CodeDEInfringementBGH + CodeDENullityBPatG = lp.CodeDENullityBPatG + CodeDENullityBGH = lp.CodeDENullityBGH + CodeEPAGrant = lp.CodeEPAGrant + CodeEPAOpposition = lp.CodeEPAOpposition + CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal + CodeDPMAOpposition = lp.CodeDPMAOpposition + CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG + CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH ) // MapLitigationToFristenrechner returns the fristenrechner code + // condition flags implied by a (litigationCode, jurisdiction) pair. -// -// Inputs are case-sensitive — pass the canonical upper-snake form -// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous -// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero -// fristenrechner code; callers should treat that as "no narrowing" -// and leave the cascade wide-open rather than auto-pick. -// -// Condition flags are returned as a slice so callers can apply them -// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr, -// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag -// context applies. +// Delegates to litigationplanner.MapLitigationToFristenrechner. func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) { - switch litigationCode { - case "INF": - switch jurisdiction { - case "UPC": - return CodeUPCInfringement, nil, true - case "DE": - return CodeDEInfringementLG, nil, true - } - case "REV": - switch jurisdiction { - case "UPC": - return CodeUPCRevocation, nil, true - case "DE": - return CodeDENullityBPatG, nil, true - } - case "CCR": - // Counterclaim revocation — UPC fold-in is structural (the - // counterclaim lives inside an upc.inf.cfi proceeding with the - // with_ccr flag). DE Nichtigkeit is conceptually the same - // adversarial-validity test, no separate flag. - switch jurisdiction { - case "UPC": - return CodeUPCInfringement, []string{"with_ccr"}, true - case "DE": - return CodeDENullityBPatG, nil, true - } - case "AMD": - // Amendment-application bundled into upc.inf.cfi via with_amend. - // No DE / EPA / DPMA analogue today. - if jurisdiction == "UPC" { - return CodeUPCInfringement, []string{"with_amend"}, true - } - case "APP": - // Appeal is ambiguous in DE (OLG vs BGH) and the project - // model doesn't carry the instance hint we'd need to - // disambiguate. UPC is unambiguous — upc.apl.merits covers - // the merits appeal track for inf/rev/ccr/damages. - if jurisdiction == "UPC" { - return CodeUPCAppealMerits, nil, true - } - case "APM": - // Preliminary injunction / urgency procedure — UPC-only - // concept in the fristenrechner taxonomy. - if jurisdiction == "UPC" { - return CodeUPCPreliminary, nil, true - } - case "OPP": - // Opposition — primarily EPA. DPMA has dpma.opp.dpma but it - // doesn't surface from the litigation vocabulary today. - if jurisdiction == "EPA" { - return CodeEPAOpposition, nil, true - } - } - return "", nil, false + return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction) } -// ResolveCounterclaimRouting handles the determinator's -// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown -// for taxonomic completeness, but no rules are attached to it. When the -// cascade resolves to upc.ccr.cfi we route the rule lookup back to -// upc.inf.cfi with a default with_ccr=true flag — see -// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1. -// -// `code` is the proceeding code the cascade resolved to. If it's -// upc.ccr.cfi, the function returns (CodeUPCInfringement, -// []string{"with_ccr"}, true). For any other code the function returns -// (code, nil, false) and callers proceed with the code unchanged. The -// boolean signals "routing was applied"; the caller can surface the hint -// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin -// weiter." in the UI. +// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi +// illustrative-peer route. Delegates to +// litigationplanner.ResolveCounterclaimRouting. func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) { - if route, ok := SubTrackRoutings[code]; ok { - return route.ParentCode, route.DefaultFlags, true - } - return code, nil, false + return lp.ResolveCounterclaimRouting(code) } -// SubTrackRouting describes a proceeding type that has no native rules -// of its own and is normally rendered inside a parent proceeding's flow -// with one or more condition flags enabled. The Procedure Roadmap -// (verfahrensablauf) routes calc requests for these codes to the parent -// proceeding + default flags, but preserves the user-picked code/name -// in the response identity and surfaces a contextual note explaining -// the framing — see m/paliad#58 and the design doc cited above. -// -// Adding a new sub-track is a data-only change here: extend -// SubTrackRoutings with the (code, parent, flags, note) tuple and the -// renderer picks it up automatically. The note copy lives in this file -// because it's semantic to the routing, not UI chrome. -type SubTrackRouting struct { - // Code is the user-picked proceeding code (e.g. "upc.ccr.cfi"). - Code string - // ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi"). - ParentCode string - // DefaultFlags are merged into the user's flag set so the - // gated rules render. Order is preserved. - DefaultFlags []string - // NoteDE / NoteEN are the contextual banner above the timeline, - // explaining that the proceeding type is normally a sub-track. - // Plain text — the frontend renders them as a banner. - NoteDE string - NoteEN string -} - -// SubTrackRoutings — single-source-of-truth registry. Today: just CCR. -// The pattern generalises to other "sub-track" proceeding types (e.g. -// R.30 application to amend the patent as a standalone roadmap, R.46 -// preliminary objection) once they have a proceeding-type code of their -// own. New entries here are picked up by the spawn-as-standalone -// renderer in FristenrechnerService.Calculate without further wiring. -var SubTrackRoutings = map[string]SubTrackRouting{ - CodeUPCCounterclaim: { - Code: CodeUPCCounterclaim, - ParentCode: CodeUPCInfringement, - DefaultFlags: []string{"with_ccr"}, - NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.", - NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.", - }, -} +// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting +// is aliased in fristenrechner.go. +var SubTrackRoutings = lp.SubTrackRoutings // LookupSubTrackRouting returns the sub-track routing for a proceeding -// code, or (zero, false) if the code is not a sub-track. Used by the -// fristenrechner Calculate path to spawn the parent flow with the sub- -// track's default flags. +// code, or (zero, false) if the code is not a sub-track. Delegates to +// litigationplanner.LookupSubTrackRouting. func LookupSubTrackRouting(code string) (SubTrackRouting, bool) { - r, ok := SubTrackRoutings[code] - return r, ok + return lp.LookupSubTrackRouting(code) } diff --git a/pkg/litigationplanner/catalog.go b/pkg/litigationplanner/catalog.go new file mode 100644 index 0000000..e5755e3 --- /dev/null +++ b/pkg/litigationplanner/catalog.go @@ -0,0 +1,49 @@ +package litigationplanner + +import "context" + +// Catalog supplies proceeding-type metadata + rules for the calculator. +// +// Implementations: +// - paliad: reads paliad.deadline_rules + paliad.proceeding_types, +// filtered to lifecycle_state='published' AND is_active=true. +// ProjectHint scopes future per-project rule merges. +// - embedded/upc (Slice C): in-memory map keyed by code, populated +// once at init from the embedded JSON snapshot. +// +// All methods return ErrUnknownProceedingType / ErrUnknownRule when the +// caller asks for a code/id that doesn't exist in the catalog. +type Catalog interface { + // LoadProceeding returns the proceeding-type metadata + the full + // rule list (sorted by sequence_order). Caller passes the user- + // facing proceeding code (e.g. "upc.inf.cfi"). The hint scopes a + // future per-project rule merge — implementations that don't + // support projects ignore it. + LoadProceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error) + + // LoadProceedingByID is the resolver used by CalculateRule when it + // has a rule + needs the rule's parent proceeding metadata. + LoadProceedingByID(ctx context.Context, id int) (*ProceedingType, error) + + // LoadRuleByID resolves a rule UUID to the rule row. Used by + // CalculateRule when the caller supplies CalcRuleParams.RuleID. + LoadRuleByID(ctx context.Context, ruleID string) (*Rule, error) + + // LoadRuleByCode resolves a rule by (proceedingCode, submissionCode) + // + returns the parent proceeding for use in the response identity. + // Used by CalculateRule when the caller supplies the (code, local) + // pair from a concept-card pill. + LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*Rule, *ProceedingType, error) + + // LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted + // rules (rules whose trigger_event_id matches). Used by + // EventDeadlineService → Calculate via CalcOptions.TriggerEventIDFilter. + LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]Rule, error) + + // LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows + // for the conditional-label override (t-paliad-294 / + // m/paliad#126). Returns a map keyed by event id; missing ids + // are simply absent (caller treats absence as "no override"). + // Empty input returns an empty map without a DB roundtrip. + LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error) +} diff --git a/pkg/litigationplanner/courts.go b/pkg/litigationplanner/courts.go new file mode 100644 index 0000000..f584ce9 --- /dev/null +++ b/pkg/litigationplanner/courts.go @@ -0,0 +1,49 @@ +package litigationplanner + +// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its +// (country, regime) tuple, which drives non-working-day adjustment. +// +// Implementations: +// - paliad: reads paliad.courts (CourtService.CountryRegime). +// - embedded/upc (Slice C): in-memory map populated from the embedded +// JSON snapshot. +// +// Empty courtID falls back to (defaultCountry, defaultRegime) so callers +// without a court_id (the abstract Verfahrensablauf path) still get +// sensible behaviour. Returns an error when courtID is non-empty and +// not in the registry. +type CourtRegistry interface { + CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) +} + +// Country and regime constants — keep in sync with the paliad.countries +// seed list and the holidays_regime_chk / courts_regime_chk constraints. +const ( + CountryDE = "DE" + RegimeUPC = "UPC" + RegimeEPO = "EPO" +) + +// 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, "" + } +} diff --git a/pkg/litigationplanner/doc.go b/pkg/litigationplanner/doc.go new file mode 100644 index 0000000..58b5a18 --- /dev/null +++ b/pkg/litigationplanner/doc.go @@ -0,0 +1,17 @@ +// Package litigationplanner is the canonical Fristen / Verfahrensablauf +// compute engine — the deadline-rule model, the calendar arithmetic, the +// condition-expression gate, the sub-track routing, and the timeline +// composer that drives Paliad's /tools/fristenrechner, +// /tools/verfahrensablauf, and the per-project SmartTimeline. +// +// The package owns its types (Rule, ProceedingType, Timeline, +// TimelineEntry, CalcOptions, …) and exposes three interfaces for the +// stateful inputs: Catalog (proceeding + rule lookup), HolidayCalendar +// (non-working-day adjustment), and CourtRegistry (court → country/regime +// resolution). Paliad implements them against its Postgres database; +// downstream consumers (youpc.org) implement them against an embedded +// JSON snapshot of the UPC subset. +// +// See docs/design-litigation-planner-2026-05-26.md (t-paliad-292 / +// m/paliad#124) for the full design. +package litigationplanner diff --git a/pkg/litigationplanner/durations.go b/pkg/litigationplanner/durations.go new file mode 100644 index 0000000..c4c93ea --- /dev/null +++ b/pkg/litigationplanner/durations.go @@ -0,0 +1,76 @@ +package litigationplanner + +import "time" + +// ApplyDuration is the unified date-arithmetic helper used by every +// calculator path (proceeding-tree, trigger-event, CalculateRule single- +// rule). Phase 3 Slice 4 (t-paliad-185) replaced the prior split +// between addDuration (proceeding-tree, no timing / working_days) and +// ApplyDurationOnCalendar (Pipeline-C, full support) with this single +// helper. +// +// 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 HolidayCalendar, +) (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 HolidayCalendar) 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 +} diff --git a/pkg/litigationplanner/engine.go b/pkg/litigationplanner/engine.go new file mode 100644 index 0000000..3ee06e1 --- /dev/null +++ b/pkg/litigationplanner/engine.go @@ -0,0 +1,908 @@ +package litigationplanner + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Calculate renders the full UI timeline for a proceeding type + trigger date. +// Preserves the pre-Phase-C in-memory calculator's classification: +// +// - Rules with duration_value = 0 and no parent_id → IsRootEvent +// (due date = trigger date) +// - Rules with duration_value = 0 and a parent_id → IsCourtSet +// (due date empty, UI shows "court-set" placeholder) +// - All other rules → calculate from either the trigger date (no parent) +// or the previously-computed date for their parent rule. +// +// Audit-driven extensions: +// +// - opts.Flags can flip flag-conditioned rules onto their alt_* values +// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). +// - opts.PriorityDateStr overrides the anchor for rules with +// anchor_alt='priority_date' (e.g. epa.grant.exa publication date +// is 18mo from priority, not filing). +// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the +// caller redirect a downstream rule's parent anchor to a user-set +// date. +func Calculate( + ctx context.Context, + proceedingCode string, + triggerDateStr string, + opts CalcOptions, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*Timeline, error) { + // Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven + // branch (Pipeline-C unified rules). proceedingCode is ignored on + // this path. + if opts.TriggerEventIDFilter != nil { + return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts) + } + + triggerDate, err := time.Parse("2006-01-02", triggerDateStr) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) + } + + var priorityDate *time.Time + if opts.PriorityDateStr != "" { + pd, err := time.Parse("2006-01-02", opts.PriorityDateStr) + if err != nil { + return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err) + } + priorityDate = &pd + } + flagSet := make(map[string]struct{}, len(opts.Flags)) + for _, f := range opts.Flags { + flagSet[f] = struct{}{} + } + // v1 simplification (t-paliad-265): when any IncludeCCRFor entry + // exists, we treat with_ccr as set in the flag context. + if len(opts.IncludeCCRFor) > 0 { + flagSet["with_ccr"] = struct{}{} + } + + // Parse anchor overrides up-front so a malformed date errors out + // before we start walking rules. + overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides)) + for code, dateStr := range opts.AnchorOverrides { + od, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err) + } + overrideDates[code] = od + } + + // Look up proceeding type metadata. + pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint) + if err != nil { + return nil, err + } + + // Sub-track routing (m/paliad#58). When the user picks a proceeding + // that has no native rules and is normally a sub-track of another + // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route + // rule lookup to the parent and merge the default flags into the + // user's flag set. The response identity stays on the user-picked + // proceeding so the page header still reads "Counterclaim for + // Revocation", but the timeline body is the parent's full flow with + // the sub-track flag enabled. + var subTrackNote SubTrackRouting + var hasSubTrackNote bool + pt := pickedProceeding + if route, ok := LookupSubTrackRouting(proceedingCode); ok { + subTrackNote = route + hasSubTrackNote = true + parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint) + if err != nil { + return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err) + } + pt = parentPt + rules = parentRules + // Merge default flags into the user's flag set so the gated + // rules render. User-supplied flags win on conflict. + for _, f := range route.DefaultFlags { + if _, exists := flagSet[f]; !exists { + flagSet[f] = struct{}{} + } + } + } + + // Resolve (country, regime) for non-working-day adjustment. Court + // wins when supplied; otherwise default by proceeding regime. + defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) + country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime) + if err != nil { + return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) + } + + if len(opts.RuleOverrides) > 0 { + rules = ApplyRuleOverrides(rules, opts.RuleOverrides) + } + + // ruleByID lets the conditional-rendering branches resolve a parent + // rule's display fields (submission_code, name, name_en) for the + // "abhängig von " chip without re-scanning the rules + // slice on every iteration. (t-paliad-289) + ruleByID := make(map[uuid.UUID]Rule, len(rules)) + for _, r := range rules { + ruleByID[r.ID] = r + } + + // triggerEventByID powers the trigger-event override on the + // conditional-label chip (m/paliad#126 / t-paliad-294). When a rule + // carries a real paliad.trigger_events row, that catalog event — + // not the rule's parent_id — is the rule's actual semantic anchor. + // The override fires below when stamping ParentRule* on the wire so + // the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit + // gegenüber der Öffentlichkeit" for R.262(2) — instead of the + // (misleading) parent_id-derived "abhängig von Klageerhebung". + // + // Bulk-loaded in one round-trip; trees in the live corpus carry at + // most a handful of trigger_event_id-bearing rules (2 today on + // upc.inf.cfi), so the IN(...) is small. + var triggerIDs []int64 + seenTrigger := make(map[int64]struct{}, len(rules)) + for _, r := range rules { + if r.TriggerEventID == nil { + continue + } + if _, ok := seenTrigger[*r.TriggerEventID]; ok { + continue + } + seenTrigger[*r.TriggerEventID] = struct{}{} + triggerIDs = append(triggerIDs, *r.TriggerEventID) + } + triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs) + if err != nil { + return nil, fmt.Errorf("load trigger events for conditional labels: %w", err) + } + + // Walk the rule list in sequence_order (already sorted by the + // catalog 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([]TimelineEntry, 0, len(rules)) + + skipRules := opts.SkipRules + perCardAppellant := opts.PerCardAppellant + skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) + hiddenCount := 0 + appellantContext := make(map[uuid.UUID]string, len(rules)) + + for _, r := range 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. + gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet) + if !gateMet && r.AltDurationValue == nil { + continue + } + + // SkipRules suppression (t-paliad-265). + // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, + // we re-surface the directly-skipped row (faded via IsHidden) + // instead of dropping it. + var isHidden bool + if r.SubmissionCode != nil { + if _, skipped := skipRules[*r.SubmissionCode]; skipped { + hiddenCount++ + if !opts.IncludeHidden { + skippedIDs[r.ID] = struct{}{} + continue + } + isHidden = true + } + } + if r.ParentID != nil { + if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped { + skippedIDs[r.ID] = struct{}{} + continue + } + } + + // AppellantContext propagation. A rule with its own + // PerCardAppellant pick stamps its UUID with that value. + // Otherwise inherit from parent if the parent had a context. + var ctxVal string + if r.SubmissionCode != nil { + if v, ok := perCardAppellant[*r.SubmissionCode]; ok { + ctxVal = v + } + } + if ctxVal == "" && r.ParentID != nil { + if v, ok := appellantContext[*r.ParentID]; ok { + ctxVal = v + } + } + if ctxVal != "" { + appellantContext[r.ID] = ctxVal + } + + d := TimelineEntry{ + RuleID: r.ID.String(), + Name: r.Name, + NameEN: r.NameEN, + Priority: r.Priority, + ConditionExpr: json.RawMessage(r.ConditionExpr), + AppellantContext: ctxVal, + ChoicesOffered: json.RawMessage(r.ChoicesOffered), + IsHidden: isHidden, + } + if r.SubmissionCode != nil { + d.Code = *r.SubmissionCode + } + if r.PrimaryParty != nil { + d.Party = *r.PrimaryParty + } + if r.RuleCode != nil { + d.RuleRef = *r.RuleCode + } + if r.LegalSource != nil { + d.LegalSource = *r.LegalSource + d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) + d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) + } + if r.DeadlineNotes != nil { + d.Notes = *r.DeadlineNotes + } + if r.DeadlineNotesEn != nil { + d.NotesEN = *r.DeadlineNotesEn + } + + // Resolve the parent rule once so every conditional-rendering + // branch (incl. the optional-not-recorded path below) can stamp + // ParentRule* on the wire without re-scanning. Populated even + // for non-conditional rows — the frontend dependency-footer + // ("Folgt aus …") already consumes this on regular projected + // rows. (t-paliad-289) + var parentRule *Rule + if r.ParentID != nil { + if pr, ok := ruleByID[*r.ParentID]; ok { + parentRule = &pr + if pr.SubmissionCode != nil { + d.ParentRuleCode = *pr.SubmissionCode + } + d.ParentRuleName = pr.Name + d.ParentRuleNameEN = pr.NameEN + } + } + + // Trigger-event override on the user-facing dependency identity + // (m/paliad#126 / t-paliad-294). When a rule has a real + // trigger_event_id, that catalog event is the actual semantic + // anchor — not the parent_id node, which is only the calc-time + // arithmetic anchor. Only the user-facing wire fields shift; + // parentRule (and the parent_id chain feeding parentIsCourtSet + // and the calc-time arithmetic below) stays anchored on the + // rule tree. + if r.TriggerEventID != nil { + if te, ok := triggerEventByID[*r.TriggerEventID]; ok { + d.ParentRuleCode = te.Code + d.ParentRuleName = te.NameDE + d.ParentRuleNameEN = te.Name + } + } + + // Propagate court-set status from a parent rule whose date the + // court determines: if the anchor itself has no real date, + // nothing downstream can be computed either — UNLESS the user + // has supplied an override date for the parent. + parentOverridden := false + if r.ParentID != nil && courtSet[*r.ParentID] { + for _, prev := range rules { + if prev.ID == *r.ParentID { + if prev.SubmissionCode != nil { + if _, ok := overrideDates[*prev.SubmissionCode]; ok { + parentOverridden = true + } + } + break + } + } + } + parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden + + // Zero-duration rules fall into one of four buckets: + // 1. parent=nil, not court-determined → IsRootEvent (trigger anchor) + // 2. parent=nil, court-determined → IsCourtSet + // 3. parent set, court-determined → IsCourtSet (waypoint) + // 4. parent set, NOT court-determined → "filed-with-parent" + // + // AnchorOverrides: when the user has set a date for any zero- + // duration rule, that override wins over both the court-set + // placeholder and the parent-inheritance. + if r.DurationValue == 0 { + if r.SubmissionCode != nil { + if ov, ok := overrideDates[*r.SubmissionCode]; ok { + d.DueDate = ov.Format("2006-01-02") + d.OriginalDate = d.DueDate + d.IsOverridden = true + computed[*r.SubmissionCode] = ov + deadlines = append(deadlines, d) + continue + } + } + + if r.ParentID == nil && !r.IsCourtSet { + // Bucket 1: timeline anchor. + d.IsRootEvent = true + d.DueDate = triggerDateStr + d.OriginalDate = triggerDateStr + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = triggerDate + } + } else if r.ParentID != nil && !r.IsCourtSet { + // Bucket 4: filed-with-parent. Inherit parent's date. + if parentIsCourtSet { + // Indirect: rule isn't itself court-determined, + // it's blocked because its parent is. + d.IsCourtSet = true + d.IsCourtSetIndirect = true + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + } else { + var parentDate time.Time + var haveParentDate bool + for _, prev := range rules { + if prev.ID == *r.ParentID { + if prev.SubmissionCode != nil { + if ov, ok := overrideDates[*prev.SubmissionCode]; ok { + parentDate = ov + haveParentDate = true + } else if ref, ok := computed[*prev.SubmissionCode]; ok { + parentDate = ref + haveParentDate = true + } + } + break + } + } + if haveParentDate { + d.DueDate = parentDate.Format("2006-01-02") + d.OriginalDate = d.DueDate + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = parentDate + } + } else { + // Parent not yet computed (defensive). + d.IsCourtSet = true + d.IsCourtSetIndirect = true + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + } + } + } else { + // Buckets 2 + 3: court-determined directly. + d.IsCourtSet = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + } + deadlines = append(deadlines, d) + continue + } + + // If the parent is court-determined and not overridden we have + // no real anchor date; surface this rule as court-set too + // rather than fabricating one off the trigger date. IsConditional + // surfaces the "abhängig von " UX (t-paliad-289). + if parentIsCourtSet { + d.IsCourtSet = true + d.IsCourtSetIndirect = true + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + deadlines = append(deadlines, d) + continue + } + + // Anchor: prefer alt-anchor (e.g. priority_date for + // epa.grant.exa publish) when supplied, then parent's computed + // date (or user override), then trigger date. + baseDate := triggerDate + if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { + baseDate = *priorityDate + } else if r.ParentID != nil { + for _, prev := range rules { + if prev.ID == *r.ParentID { + if prev.SubmissionCode != nil { + if ov, ok := overrideDates[*prev.SubmissionCode]; ok { + baseDate = ov + } else if ref, ok := computed[*prev.SubmissionCode]; ok { + baseDate = ref + } + } + break + } + } + } + + // Flag-conditioned alt-swap (legacy with_ccr pattern): when the + // gate fires AND alt_* values exist, swap the primary duration + // to the alt values. This is distinct from combine_op below — + // alt-swap is a one-or-the-other choice keyed on flags, whereas + // combine_op computes both legs and picks max/min. + durationValue := r.DurationValue + durationUnit := r.DurationUnit + timing := "" + if r.Timing != nil { + timing = *r.Timing + } + if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil { + durationValue = *r.AltDurationValue + if r.AltDurationUnit != nil { + durationUnit = *r.AltDurationUnit + } + if r.AltRuleCode != nil { + d.RuleRef = *r.AltRuleCode + } + } + + // User override on this rule: replace the calculated date with + // the user's date. Skip holiday rollover — the user's date is + // authoritative. + if r.SubmissionCode != nil { + if ov, ok := overrideDates[*r.SubmissionCode]; ok { + d.OriginalDate = ov.Format("2006-01-02") + d.DueDate = ov.Format("2006-01-02") + d.WasAdjusted = false + d.AdjustmentReason = nil + d.IsOverridden = true + computed[*r.SubmissionCode] = ov + deadlines = append(deadlines, d) + continue + } + } + + origDate, adjusted, wasAdj, reason := ApplyDuration( + baseDate, durationValue, durationUnit, timing, country, regime, holidays, + ) + + // combine_op composite: compute the alt leg too, apply max/min. + if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { + altOrig, altAdj, altWasAdj, altReason := ApplyDuration( + baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays, + ) + switch *r.CombineOp { + case "max": + if altAdj.After(adjusted) { + origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason + } + case "min": + if altAdj.Before(adjusted) { + origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason + } + } + } + + d.OriginalDate = origDate.Format("2006-01-02") + d.DueDate = adjusted.Format("2006-01-02") + d.WasAdjusted = wasAdj + d.AdjustmentReason = reason + + // Optional-on-the-other-side detection (t-paliad-289 Symptom B). + // Rules with priority='optional' AND primary_party='both' whose + // data-model parent is the proceeding's trigger anchor (parent + // has parent_id=NULL and is not court-set, i.e. the SoC root + // rule) represent a rule whose REAL triggering event sits + // outside the rule data — e.g. R.262(2) Erwiderung auf + // Vertraulichkeitsantrag anchors on SoC in the data, but the + // real trigger is the opposing party's confidentiality motion + // which may never happen. Without an explicit anchor on the + // rule itself, the projection must NOT claim a concrete date. + if !d.IsOverridden && !d.IsConditional && + r.Priority == "optional" && + r.PrimaryParty != nil && *r.PrimaryParty == "both" && + parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet { + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + d.WasAdjusted = false + d.AdjustmentReason = nil + // Mark this rule's ID as having an uncertain anchor so + // rules chaining off it also surface conditional via the + // parentIsCourtSet path. + courtSet[r.ID] = true + } + + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = adjusted + } + deadlines = append(deadlines, d) + } + + // t-paliad-296: within consecutive runs of rules sharing the same + // trigger group (parent_id + trigger_event_id), reorder by duration + // ascending so optional events following the same anchor render in + // their likely-sequence order. Different trigger groups keep their + // proceeding-sequence position — the chunk walk only sorts adjacent + // same-group rows. Court-set / conditional rows sort LAST. + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + resp := &Timeline{ + ProceedingType: pickedProceeding.Code, + ProceedingName: pickedProceeding.Name, + ProceedingNameEN: pickedProceeding.NameEN, + TriggerDate: triggerDateStr, + Deadlines: deadlines, + HiddenCount: hiddenCount, + } + // Sub-track routing keeps the user-picked proceeding's identity, + // so the trigger-event label rides on `pickedProceeding`. + if pickedProceeding.TriggerEventLabelDE != nil { + resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE + } + if pickedProceeding.TriggerEventLabelEN != nil { + resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN + } + if hasSubTrackNote { + resp.ContextualNote = subTrackNote.NoteDE + resp.ContextualNoteEN = subTrackNote.NoteEN + } + return resp, nil +} + +// calculateByTriggerEvent renders the Pipeline-C timeline for an event +// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id +// chains), have no flag gating, no priority_date alt-anchor, no party +// classification, and no IsRootEvent / IsCourtSet semantics. The math +// is just: base + (timing-signed) duration → optional alt-leg combine +// → optional weekend/holiday rollover for calendar units. +// +// Timeline.ProceedingType / ProceedingName stay empty — +// EventDeadlineService owns the trigger-event metadata. +func calculateByTriggerEvent( + ctx context.Context, + triggerEventID int64, + triggerDateStr string, + opts CalcOptions, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*Timeline, error) { + triggerDate, err := time.Parse("2006-01-02", triggerDateStr) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) + } + + // Pipeline-C rules originate from youpc's UPC-flavoured deadline + // corpus — DE / UPC defaults match the legacy EventDeadlineService. + country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC) + if err != nil { + return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) + } + + rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID) + if err != nil { + return nil, err + } + if len(opts.RuleOverrides) > 0 { + rules = ApplyRuleOverrides(rules, opts.RuleOverrides) + } + + deadlines := make([]TimelineEntry, 0, len(rules)) + for _, r := range rules { + timing := "" + if r.Timing != nil { + timing = *r.Timing + } + baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration( + triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays, + ) + picked := baseAdj + original := baseRaw + wasAdj := baseChanged + reason := baseReason + + if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { + altRaw, altAdj, altChanged, altReason := ApplyDuration( + triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays, + ) + switch *r.CombineOp { + case "max": + if altAdj.After(baseAdj) { + picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason + } + case "min": + if altAdj.Before(baseAdj) { + picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason + } + } + } + + d := TimelineEntry{ + RuleID: r.ID.String(), + Name: r.Name, + NameEN: r.NameEN, + Priority: r.Priority, + ConditionExpr: json.RawMessage(r.ConditionExpr), + DueDate: picked.Format("2006-01-02"), + OriginalDate: original.Format("2006-01-02"), + WasAdjusted: wasAdj, + AdjustmentReason: reason, + } + if r.SubmissionCode != nil { + d.Code = *r.SubmissionCode + } + if r.PrimaryParty != nil { + d.Party = *r.PrimaryParty + } + if r.RuleCode != nil { + d.RuleRef = *r.RuleCode + } + if r.LegalSource != nil { + d.LegalSource = *r.LegalSource + d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) + d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) + } + if r.DeadlineNotes != nil { + d.Notes = *r.DeadlineNotes + } + if r.DeadlineNotesEn != nil { + d.NotesEN = *r.DeadlineNotesEn + } + deadlines = append(deadlines, d) + } + + return &Timeline{ + // Trigger-event responses don't carry proceeding metadata — + // EventDeadlineService.Calculate fills the trigger fields in + // the legacy CalculateResponse shape. Leaving these empty is + // the stable contract. + ProceedingType: "", + ProceedingName: "", + TriggerDate: triggerDateStr, + Deadlines: deadlines, + }, nil +} + +// CalculateRule computes a single deadline from a rule + trigger date. +// Used by the v4 result-card click flow. Distinct from Calculate: no +// parent-chain walk, no full-timeline rendering — just one date out. +// +// When the rule is court-determined, DueDate is empty and +// IsCourtSet=true; the caller should disable the "Add to project" CTA. +// +// When the rule has a condition_expr gate and the caller's Flags +// satisfy it AND alt_duration_value is non-NULL, the calc swaps to +// alt_*. When the gate is not satisfied, the calc still proceeds with +// the base duration_value and surfaces FlagsRequired. +func CalculateRule( + ctx context.Context, + params CalcRuleParams, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*RuleCalculation, error) { + triggerDate, err := time.Parse("2006-01-02", params.TriggerDate) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err) + } + + rule, pt, err := resolveRule(ctx, params, catalog) + if err != nil { + return nil, err + } + + mandWire, _ := wireFlagsFromPriority(rule.Priority) + out := &RuleCalculation{ + Rule: RuleCalculationRule{ + ID: rule.ID.String(), + NameDE: rule.Name, + NameEN: rule.NameEN, + DurationValue: rule.DurationValue, + DurationUnit: rule.DurationUnit, + IsMandatory: mandWire, + }, + Proceeding: RuleCalculationProceeding{ + Code: pt.Code, + NameDE: pt.Name, + NameEN: pt.NameEN, + }, + TriggerDate: params.TriggerDate, + } + if rule.SubmissionCode != nil { + out.Rule.LocalCode = *rule.SubmissionCode + } + if rule.RuleCode != nil { + out.Rule.RuleRef = *rule.RuleCode + } + if rule.LegalSource != nil { + out.Rule.LegalSource = *rule.LegalSource + out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource) + out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource) + } + if rule.PrimaryParty != nil { + out.Rule.Party = *rule.PrimaryParty + } + if rule.DeadlineNotes != nil { + out.Rule.NotesDE = *rule.DeadlineNotes + } + if rule.DeadlineNotesEn != nil { + out.Rule.NotesEN = *rule.DeadlineNotesEn + } + // Slice 9 (t-paliad-195) replacement for the dropped condition_flag + // text[] enumeration: walk the jsonb gate to pull out flag-leaf + // names. Returns nil on an unconditional rule. + out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr) + + // Court-determined: no calculable date. + if rule.IsCourtSet { + out.IsCourtSet = true + return out, nil + } + + // Resolve flag-conditional duration via the unified condition_expr + // evaluator. + flagSet := make(map[string]struct{}, len(params.Flags)) + for _, f := range params.Flags { + flagSet[f] = struct{}{} + } + durationValue := rule.DurationValue + durationUnit := rule.DurationUnit + gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet) + if gateMet && HasConditionExpr(rule.ConditionExpr) { + out.FlagsApplied = out.FlagsRequired + if rule.AltDurationValue != nil { + durationValue = *rule.AltDurationValue + } + if rule.AltDurationUnit != nil { + durationUnit = *rule.AltDurationUnit + } + if rule.AltRuleCode != nil { + out.Rule.RuleRef = *rule.AltRuleCode + } + } + + // Zero-duration non-court-determined rules are "filed at the same + // time as parent" markers: effectively mean "due on the trigger + // date itself". + if durationValue == 0 { + out.OriginalDate = params.TriggerDate + out.DueDate = params.TriggerDate + return out, nil + } + + defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) + country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime) + if err != nil { + return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err) + } + + timing := "" + if rule.Timing != nil { + timing = *rule.Timing + } + endDate, adjusted, wasAdj, reason := ApplyDuration( + triggerDate, durationValue, durationUnit, timing, country, regime, holidays, + ) + out.OriginalDate = endDate.Format("2006-01-02") + out.DueDate = adjusted.Format("2006-01-02") + out.WasAdjusted = wasAdj + out.AdjustmentReason = reason + + return out, nil +} + +// resolveRule resolves CalcRuleParams to a rule + its proceeding type. +// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The +// frontend uses the latter form (it has the pill context) and the +// programmatic / test caller can use the former. +func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) { + if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") { + return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required") + } + + if params.RuleID != "" { + rule, err := catalog.LoadRuleByID(ctx, params.RuleID) + if err != nil { + return nil, nil, err + } + if rule.ProceedingTypeID == nil { + return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID) + } + pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID) + if err != nil { + return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err) + } + return rule, pt, nil + } + + rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode) + if err != nil { + return nil, nil, err + } + return rule, pt, nil +} + +// ApplyRuleOverrides replaces rules whose ID appears in `overrides` +// with the override row, and appends any override whose ID isn't in +// the source list (net-new drafts the rule editor wants to preview). +// +// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor +// passes the draft as an override so Calculate runs against the +// proposed shape without writing to the DB. Empty overrides slice = +// pass-through. +func ApplyRuleOverrides(src, overrides []Rule) []Rule { + if len(overrides) == 0 { + return src + } + byID := make(map[uuid.UUID]Rule, len(overrides)) + for _, o := range overrides { + byID[o.ID] = o + } + out := make([]Rule, 0, len(src)+len(overrides)) + seen := make(map[uuid.UUID]bool, len(overrides)) + for _, r := range src { + if ov, ok := byID[r.ID]; ok { + out = append(out, ov) + seen[ov.ID] = true + continue + } + out = append(out, r) + } + for _, o := range overrides { + if seen[o.ID] { + continue + } + out = append(out, o) + } + return out +} + +// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional) +// pair from the unified priority enum so the wire shape stays +// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3): +// +// 'mandatory' → (true, false) +// 'optional' → (true, true) +// 'recommended' → (false, false) +// 'informational' → (false, false) +// (unknown) → (true, false) +func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { + switch priority { + case "mandatory": + return true, false + case "optional": + return true, true + case "recommended": + return false, false + case "informational": + return false, false + default: + return true, false + } +} + +// AllFlagsSet is retained as a tiny utility for callers that have a +// flat list of flag strings + a flag-set lookup. The new condition_expr +// gate is the canonical evaluator; this helper exists for forward- +// compat with any future caller that wants the legacy AND-over-list +// semantic without rebuilding the jsonb. +func AllFlagsSet(required []string, set map[string]struct{}) bool { + return allFlagsSet(required, set) +} + +// WireFlagsFromPriority is the public form of wireFlagsFromPriority so +// the paliad-side test suite (which historically asserted the mapping +// directly) can still test the contract. +func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { + return wireFlagsFromPriority(priority) +} diff --git a/pkg/litigationplanner/expr.go b/pkg/litigationplanner/expr.go new file mode 100644 index 0000000..9012c55 --- /dev/null +++ b/pkg/litigationplanner/expr.go @@ -0,0 +1,145 @@ +package litigationplanner + +import "encoding/json" + +// 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": ""} — leaf: true iff ∈ flags +// {"op": "and", "args": [...]} — true iff every arg evaluates true +// {"op": "or", "args": [...]} — true iff any arg evaluates true +// {"op": "not", "args": []} — 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":""},...]} 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 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":""} 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 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) + } +} diff --git a/pkg/litigationplanner/holidays.go b/pkg/litigationplanner/holidays.go new file mode 100644 index 0000000..d725d8c --- /dev/null +++ b/pkg/litigationplanner/holidays.go @@ -0,0 +1,25 @@ +package litigationplanner + +import "time" + +// HolidayCalendar adjusts dates onto working days for a given +// (country, regime) pair. The calculator only needs three primitives: +// +// - IsNonWorkingDay — used by the addWorkingDays walker +// - AdjustForNonWorkingDays — forward snap (timing='after') +// - AdjustForNonWorkingDaysBackward — backward snap (timing='before') +// - AdjustForNonWorkingDaysWithReason — like the forward snap but +// also returns *AdjustmentReason so the timeline can render the +// "rolled past holiday X" footer in TimelineEntry.AdjustmentReason. +// +// Implementations: +// - paliad: reads paliad.holidays, caches per-year, merges DE +// federal fallback. +// - embedded/upc (Slice C): in-memory year-keyed map populated from +// the embedded JSON snapshot. +type HolidayCalendar interface { + IsNonWorkingDay(date time.Time, country, regime string) bool + AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) + AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) + AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *AdjustmentReason) +} diff --git a/pkg/litigationplanner/legal_source.go b/pkg/litigationplanner/legal_source.go new file mode 100644 index 0000000..eac4de3 --- /dev/null +++ b/pkg/litigationplanner/legal_source.go @@ -0,0 +1,123 @@ +package litigationplanner + +import "strings" + +// FormatLegalSourceDisplay renders a structured legal_source code into +// the form HLC users read in pleadings: +// +// UPC.RoP.23.1 → "UPC RoP R.23(1)" +// UPC.RoP.139 → "UPC RoP R.139" +// DE.PatG.82.1 → "PatG §82(1)" +// DE.ZPO.276.1 → "ZPO §276(1)" +// EU.EPÜ.108 → "EPÜ Art.108" +// EU.EPC-R.79.1 → "EPC R.79(1)" +// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)" +// +// Returns the empty string for an empty input. Unknown jurisdictions +// fall through with the structured form preserved (caller decides +// whether to display). +func FormatLegalSourceDisplay(src string) string { + src = strings.TrimSpace(src) + if src == "" { + return "" + } + parts := strings.Split(src, ".") + if len(parts) < 3 { + // Malformed — return as-is so the caller still has something. + return src + } + code := parts[1] + rest := parts[2:] + var prefix string + switch code { + case "RoP": + prefix = "UPC RoP R." + case "PatG": + prefix = "PatG §" + case "ZPO": + prefix = "ZPO §" + case "EPÜ": + prefix = "EPÜ Art." + case "EPC-R": + prefix = "EPC R." + case "RPBA": + prefix = "RPBA Art." + default: + prefix = code + " " + } + var b strings.Builder + b.Grow(len(prefix) + len(src)) + b.WriteString(prefix) + b.WriteString(rest[0]) + for _, p := range rest[1:] { + b.WriteByte('(') + b.WriteString(p) + b.WriteByte(')') + } + return b.String() +} + +// BuildLegalSourceURL maps a structured legal_source code to a +// youpc.org/laws permalink when the cited body is hosted there. Today +// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national +// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc +// home yet, so the helper returns the empty string for those and the +// caller renders the display string as plain text. +// +// Inputs mirror FormatLegalSourceDisplay — structured dot-separated +// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond +// the law-number position are dropped; youpc resolves the page at +// . granularity. The law-number is zero-padded to 3 +// digits to match how youpc stores law_number (laws-data.json carries +// "001" / "023" / "220" forms). +// +// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023 +// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220 +// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029 +// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083 +// DE.ZPO.276.1 → "" (no youpc home — render display text plain) +func BuildLegalSourceURL(src string) string { + src = strings.TrimSpace(src) + if src == "" { + return "" + } + parts := strings.Split(src, ".") + if len(parts) < 3 { + return "" + } + var lawType string + switch parts[0] + "." + parts[1] { + case "UPC.RoP": + lawType = "UPCRoP" + case "UPC.UPCA": + lawType = "UPCA" + case "UPC.UPCS": + lawType = "UPCS" + default: + return "" + } + number := padLawNumber(parts[2]) + if number == "" { + return "" + } + return "https://youpc.org/laws#" + lawType + "." + number +} + +// padLawNumber zero-pads a pure-digit law-number segment to 3 digits. +// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art. +// 112a) pass through unchanged so the URL still resolves. Empty input +// returns the empty string. +func padLawNumber(s string) string { + if s == "" { + return "" + } + for _, c := range s { + if c < '0' || c > '9' { + return s + } + } + if len(s) >= 3 { + return s + } + return strings.Repeat("0", 3-len(s)) + s +} diff --git a/pkg/litigationplanner/proceeding_mapping.go b/pkg/litigationplanner/proceeding_mapping.go new file mode 100644 index 0000000..2ffcc0c --- /dev/null +++ b/pkg/litigationplanner/proceeding_mapping.go @@ -0,0 +1,139 @@ +package litigationplanner + +// proceeding_mapping bridges the two proceeding-type vocabularies in the +// codebase: the **litigation** conceptual category (INF / REV / APP / +// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding +// + Pipeline-A rules, and the **fristenrechner** code category +// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator +// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects +// bind to fristenrechner codes directly, but the litigation→fristenrechner +// mapping is still needed for the ~40 Pipeline-A rules that remain on +// litigation proceedings and for any other surface that thinks in +// litigation terms. +// +// The mapping table here is the single source of truth — see +// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the +// design rationale + ambiguity notes, and +// docs/design-proceeding-code-taxonomy-2026-05-18.md for the +// lowercase dot-separated naming convention applied by mig 096 +// (t-paliad-206). **Never silent FK promotion**: every ambiguous case +// returns ok=false so callers can degrade gracefully ("no narrowing") +// instead of guessing. + +// Stable code constants — the strings landed by mig 096. Use these +// throughout the codebase so a future rename only needs to touch this +// file. The id-anchored FKs (deadline_rules.proceeding_type_id, +// projects.proceeding_type_id) are unaffected by the rename. +const ( + CodeUPCInfringement = "upc.inf.cfi" + CodeUPCRevocation = "upc.rev.cfi" + CodeUPCCounterclaim = "upc.ccr.cfi" + CodeUPCPreliminary = "upc.pi.cfi" + CodeUPCDamages = "upc.dmgs.cfi" + CodeUPCDiscovery = "upc.disc.cfi" + CodeUPCAppealMerits = "upc.apl.merits" + CodeUPCAppealOrder = "upc.apl.order" + CodeUPCAppealCost = "upc.apl.cost" + CodeDEInfringementLG = "de.inf.lg" + CodeDEInfringementOLG = "de.inf.olg" + CodeDEInfringementBGH = "de.inf.bgh" + CodeDENullityBPatG = "de.null.bpatg" + CodeDENullityBGH = "de.null.bgh" + CodeEPAGrant = "epa.grant.exa" + CodeEPAOpposition = "epa.opp.opd" + CodeEPAOppositionAppeal = "epa.opp.boa" + CodeDPMAOpposition = "dpma.opp.dpma" + CodeDPMAAppealBPatG = "dpma.appeal.bpatg" + CodeDPMAAppealBGH = "dpma.appeal.bgh" +) + +// MapLitigationToFristenrechner returns the fristenrechner code + +// condition flags implied by a (litigationCode, jurisdiction) pair. +// +// Inputs are case-sensitive — pass the canonical upper-snake form +// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous +// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero +// fristenrechner code; callers should treat that as "no narrowing" +// and leave the cascade wide-open rather than auto-pick. +// +// Condition flags are returned as a slice so callers can apply them +// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr, +// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag +// context applies. +func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) { + switch litigationCode { + case "INF": + switch jurisdiction { + case "UPC": + return CodeUPCInfringement, nil, true + case "DE": + return CodeDEInfringementLG, nil, true + } + case "REV": + switch jurisdiction { + case "UPC": + return CodeUPCRevocation, nil, true + case "DE": + return CodeDENullityBPatG, nil, true + } + case "CCR": + // Counterclaim revocation — UPC fold-in is structural (the + // counterclaim lives inside an upc.inf.cfi proceeding with the + // with_ccr flag). DE Nichtigkeit is conceptually the same + // adversarial-validity test, no separate flag. + switch jurisdiction { + case "UPC": + return CodeUPCInfringement, []string{"with_ccr"}, true + case "DE": + return CodeDENullityBPatG, nil, true + } + case "AMD": + // Amendment-application bundled into upc.inf.cfi via with_amend. + // No DE / EPA / DPMA analogue today. + if jurisdiction == "UPC" { + return CodeUPCInfringement, []string{"with_amend"}, true + } + case "APP": + // Appeal is ambiguous in DE (OLG vs BGH) and the project + // model doesn't carry the instance hint we'd need to + // disambiguate. UPC is unambiguous — upc.apl.merits covers + // the merits appeal track for inf/rev/ccr/damages. + if jurisdiction == "UPC" { + return CodeUPCAppealMerits, nil, true + } + case "APM": + // Preliminary injunction / urgency procedure — UPC-only + // concept in the fristenrechner taxonomy. + if jurisdiction == "UPC" { + return CodeUPCPreliminary, nil, true + } + case "OPP": + // Opposition — primarily EPA. DPMA has dpma.opp.dpma but it + // doesn't surface from the litigation vocabulary today. + if jurisdiction == "EPA" { + return CodeEPAOpposition, nil, true + } + } + return "", nil, false +} + +// ResolveCounterclaimRouting handles the determinator's +// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown +// for taxonomic completeness, but no rules are attached to it. When the +// cascade resolves to upc.ccr.cfi we route the rule lookup back to +// upc.inf.cfi with a default with_ccr=true flag — see +// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1. +// +// `code` is the proceeding code the cascade resolved to. If it's +// upc.ccr.cfi, the function returns (CodeUPCInfringement, +// []string{"with_ccr"}, true). For any other code the function returns +// (code, nil, false) and callers proceed with the code unchanged. The +// boolean signals "routing was applied"; the caller can surface the hint +// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin +// weiter." in the UI. +func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) { + if route, ok := SubTrackRoutings[code]; ok { + return route.ParentCode, route.DefaultFlags, true + } + return code, nil, false +} diff --git a/pkg/litigationplanner/sort.go b/pkg/litigationplanner/sort.go new file mode 100644 index 0000000..a1ecd08 --- /dev/null +++ b/pkg/litigationplanner/sort.go @@ -0,0 +1,151 @@ +package litigationplanner + +import ( + "fmt" + "sort" + + "github.com/google/uuid" +) + +// SortDeadlinesByDurationWithinTriggerGroup is the public form of +// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's +// test suite (which historically reached the helper directly) can +// keep invoking it via a tiny wrapper. +func SortDeadlinesByDurationWithinTriggerGroup( + deadlines []TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) { + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) +} + +// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of +// deadlines whose underlying rule shares the same trigger group +// (parent_id + trigger_event_id) and reorders each run in place by +// duration ascending. Different trigger groups keep their original +// proceeding-sequence position — the walk only ever permutes adjacent +// same-group rows. +// +// Sort key (within a run): +// 1. Conditional / court-set rows (no concrete date in the duration +// ladder) sort LAST, tiebroken by submission_code. +// 2. duration_unit weight ASC: days/working_days < weeks < months < years +// 3. duration_value ASC +// 4. submission_code ASC (deterministic tiebreak) +// +// Issue: m/paliad#128 — post-decision optional events (R.151/R.353 +// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog +// order instead of likely-sequence order. (t-paliad-296) +func sortDeadlinesByDurationWithinTriggerGroup( + deadlines []TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) { + if len(deadlines) < 2 { + return + } + n := len(deadlines) + i := 0 + for i < n { + gid := triggerGroupKey(deadlines[i], ruleByID) + j := i + 1 + for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid { + j++ + } + // Root rules (no parent and no trigger_event) get gid="" and + // would otherwise collapse into one big run. Skip the sort for + // the "root" pseudo-group — each root rule represents its own + // anchor (SoC, oral hearing, decision …) and the proceeding- + // sequence order between them must be preserved. + if j-i > 1 && gid != "" { + chunk := deadlines[i:j] + sort.SliceStable(chunk, func(a, b int) bool { + return durationLessForSort(chunk[a], chunk[b], ruleByID) + }) + } + i = j + } +} + +// triggerGroupKey returns a string key identifying which trigger group +// a deadline belongs to. Same key = same group = candidates for sort. +// Empty string means "root" (no parent, no trigger_event) — used as a +// sentinel by the caller to skip sorting roots against each other. +func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string { + rid, err := uuid.Parse(d.RuleID) + if err != nil { + return "" + } + r, ok := ruleByID[rid] + if !ok { + return "" + } + if r.ParentID != nil { + return "p:" + r.ParentID.String() + } + if r.TriggerEventID != nil { + return fmt.Sprintf("t:%d", *r.TriggerEventID) + } + return "" +} + +// durationLessForSort compares two deadlines for the duration-ascending +// sort. Court-set / conditional rows (no concrete date) sort LAST +// regardless of duration — they don't fit the duration ladder. +func durationLessForSort( + a, b TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) bool { + aLast := a.IsCourtSet || a.IsConditional + bLast := b.IsCourtSet || b.IsConditional + if aLast != bLast { + return !aLast + } + if aLast && bLast { + return a.Code < b.Code + } + + ra := lookupRuleFromDeadline(a, ruleByID) + rb := lookupRuleFromDeadline(b, ruleByID) + + wa := durationUnitWeight(ra.DurationUnit) + wb := durationUnitWeight(rb.DurationUnit) + if wa != wb { + return wa < wb + } + if ra.DurationValue != rb.DurationValue { + return ra.DurationValue < rb.DurationValue + } + return a.Code < b.Code +} + +func lookupRuleFromDeadline( + d TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) Rule { + if d.RuleID == "" { + return Rule{} + } + rid, err := uuid.Parse(d.RuleID) + if err != nil { + return Rule{} + } + return ruleByID[rid] +} + +// durationUnitWeight maps a duration unit to its sort weight so the +// trigger-group sort can order shorter durations first. days and +// working_days share weight 0 (both are sub-week granularities); +// unknown units sort to the end so they're visible as a tail rather +// than silently winning. +func durationUnitWeight(unit string) int { + switch unit { + case "days", "working_days": + return 0 + case "weeks": + return 1 + case "months": + return 2 + case "years": + return 3 + } + return 4 +} diff --git a/pkg/litigationplanner/subtrack.go b/pkg/litigationplanner/subtrack.go new file mode 100644 index 0000000..ac8c379 --- /dev/null +++ b/pkg/litigationplanner/subtrack.go @@ -0,0 +1,53 @@ +package litigationplanner + +// SubTrackRouting describes a proceeding type that has no native rules +// of its own and is normally rendered inside a parent proceeding's flow +// with one or more condition flags enabled. The Procedure Roadmap +// (verfahrensablauf) routes calc requests for these codes to the parent +// proceeding + default flags, but preserves the user-picked code/name +// in the response identity and surfaces a contextual note explaining +// the framing — see m/paliad#58 and the design doc cited above. +// +// Adding a new sub-track is a data-only change here: extend +// SubTrackRoutings with the (code, parent, flags, note) tuple and the +// renderer picks it up automatically. The note copy lives in this file +// because it's semantic to the routing, not UI chrome. +type SubTrackRouting struct { + // Code is the user-picked proceeding code (e.g. "upc.ccr.cfi"). + Code string + // ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi"). + ParentCode string + // DefaultFlags are merged into the user's flag set so the + // gated rules render. Order is preserved. + DefaultFlags []string + // NoteDE / NoteEN are the contextual banner above the timeline, + // explaining that the proceeding type is normally a sub-track. + // Plain text — the frontend renders them as a banner. + NoteDE string + NoteEN string +} + +// SubTrackRoutings — single-source-of-truth registry. Today: just CCR. +// The pattern generalises to other "sub-track" proceeding types (e.g. +// R.30 application to amend the patent as a standalone roadmap, R.46 +// preliminary objection) once they have a proceeding-type code of their +// own. New entries here are picked up by the spawn-as-standalone +// renderer in Calculate without further wiring. +var SubTrackRoutings = map[string]SubTrackRouting{ + CodeUPCCounterclaim: { + Code: CodeUPCCounterclaim, + ParentCode: CodeUPCInfringement, + DefaultFlags: []string{"with_ccr"}, + NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.", + NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.", + }, +} + +// LookupSubTrackRouting returns the sub-track routing for a proceeding +// code, or (zero, false) if the code is not a sub-track. Used by the +// fristenrechner Calculate path to spawn the parent flow with the sub- +// track's default flags. +func LookupSubTrackRouting(code string) (SubTrackRouting, bool) { + r, ok := SubTrackRoutings[code] + return r, ok +} diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go new file mode 100644 index 0000000..01eca82 --- /dev/null +++ b/pkg/litigationplanner/types.go @@ -0,0 +1,428 @@ +package litigationplanner + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// NullableJSON is a jsonb column that may be NULL. json.RawMessage +// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value +// from Postgres breaks the row scan with "unsupported Scan, storing +// driver.Value type into type *json.RawMessage" — exactly the +// error that hid every approval_request from the inbox when m's first +// "create" lifecycle row arrived with NULL pre_image (m's dogfood +// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column +// fixes the scan and preserves inline JSON output (no base64 cast). +type NullableJSON []byte + +// Scan implements sql.Scanner. +func (n *NullableJSON) Scan(value any) error { + if value == nil { + *n = nil + return nil + } + switch v := value.(type) { + case []byte: + *n = append((*n)[:0], v...) + return nil + case string: + *n = []byte(v) + return nil + } + return fmt.Errorf("NullableJSON: unsupported scan type %T", value) +} + +// Value implements driver.Valuer. +func (n NullableJSON) Value() (driver.Value, error) { + if len(n) == 0 { + return nil, nil + } + return []byte(n), nil +} + +// MarshalJSON emits the raw JSON bytes (or "null"). +func (n NullableJSON) MarshalJSON() ([]byte, error) { + if len(n) == 0 { + return []byte("null"), nil + } + return []byte(n), nil +} + +// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil). +func (n *NullableJSON) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *n = nil + return nil + } + *n = append((*n)[:0], data...) + return nil +} + +// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.). +// +// JSON + db tags are intentionally identical to the historical +// paliad.deadline_rules row shape — sqlx scans onto Rule directly and +// the wire bytes the frontend reads are unchanged from the pre-extract +// shape. +type Rule struct { + ID uuid.UUID `db:"id" json:"id"` + ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"` + ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` + SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"` + Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` + Description *string `db:"description" json:"description,omitempty"` + PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` + EventType *string `db:"event_type" json:"event_type,omitempty"` + DurationValue int `db:"duration_value" json:"duration_value"` + DurationUnit string `db:"duration_unit" json:"duration_unit"` + Timing *string `db:"timing" json:"timing,omitempty"` + RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` + DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"` + DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"` + SequenceOrder int `db:"sequence_order" json:"sequence_order"` + AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` + AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` + AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` + AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"` + ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"` + // ConceptDefaultEventTypeID is the canonical paliad.event_types row for + // this rule's concept (joined via paliad.deadline_concept_event_types + // where is_default = true). Lets the deadline create form auto-populate + // the Typ chip when the user picks this rule. Hydrated by the service + // layer; not a column. NULL when the concept has no mapped event_type. + ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"` + LegalSource *string `db:"legal_source" json:"legal_source,omitempty"` + IsSpawn bool `db:"is_spawn" json:"is_spawn"` + SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` + IsActive bool `db:"is_active" json:"is_active"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + + // TriggerEventID points at paliad.trigger_events when this rule is + // event-rooted (Pipeline C unification, design §2.5). NULL on + // proceeding-rooted rules. + TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"` + + // SpawnProceedingTypeID is the cross-proceeding spawn target — + // when is_spawn=true and this is non-NULL, the calculator follows + // the FK and emits the target proceeding's root rule chain. + SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"` + + // CombineOp is 'max' or 'min' for composite-rule arithmetic + // (R.198 / R.213: "31d OR 20 working_days, whichever is longer"). + // NULL = single-anchor arithmetic. + CombineOp *string `db:"combine_op" json:"combine_op,omitempty"` + + // ConditionExpr is the jsonb gating expression. Grammar: + // {"flag": ""} + // {"op":"and"|"or", "args":[, ...]} + // {"op":"not", "args":[]} + // NULL or {} = unconditional. + ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"` + + // Priority is the 4-way unified enum: 'mandatory' (default), + // 'recommended', 'optional', 'informational'. + Priority string `db:"priority" json:"priority"` + + // IsCourtSet replaces the runtime heuristic (primary_party='court' + // OR event_type IN ('hearing','decision','order')). + IsCourtSet bool `db:"is_court_set" json:"is_court_set"` + + // LifecycleState drives the rule-editor flow: + // 'draft' | 'published' | 'archived'. + LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"` + + // DraftOf points at the published rule this draft will replace on + // publish. NULL on published / archived rows. + DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"` + + // PublishedAt records when the row entered LifecycleState='published'. + PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"` + + // ChoicesOffered declares which per-event-card choice-kinds this + // rule offers on the Verfahrensablauf timeline (mig 129, + // t-paliad-265). NULL = no caret affordance (default). + ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"` +} + +// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR +// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot- +// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — +// see docs/design-proceeding-code-taxonomy-2026-05-18.md. +type ProceedingType struct { + ID int `db:"id" json:"id"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` + Description *string `db:"description" json:"description,omitempty"` + Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"` + Category *string `db:"category" json:"category,omitempty"` + DefaultColor string `db:"default_color" json:"default_color"` + SortOrder int `db:"sort_order" json:"sort_order"` + IsActive bool `db:"is_active" json:"is_active"` + // TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf + // "Auslösendes Ereignis". When set, overrides the proceedingName fallback + // that fires when no rule has IsRootEvent=true. + TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"` + TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"` +} + +// AdjustmentReason describes why a date was rolled forward / backward +// off a non-working day. Populated by HolidayCalendar implementations +// when AdjustForNonWorkingDaysWithReason moves the date. +// +// Date fields are JSON-serialised as YYYY-MM-DD strings (matching +// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a +// separate RFC3339 parser. +type AdjustmentReason struct { + // Kind is the dominant cause; longest cause wins when several apply + // (vacation > public_holiday > weekend). + Kind string `json:"kind"` + // Holidays collects every named holiday encountered while walking + // past the non-working run, deduped by (date, name). May be empty + // when the only cause is a weekend. + Holidays []HolidayDTO `json:"holidays,omitempty"` + // VacationName, VacationStart and VacationEnd describe the + // contiguous vacation block the original date sits in. Populated + // only when Kind == "vacation". Span boundaries are the first/last + // vacation day in the block (excludes the weekends that pad it). + VacationName string `json:"vacationName,omitempty"` + VacationStart string `json:"vacationStart,omitempty"` + VacationEnd string `json:"vacationEnd,omitempty"` + // OriginalWeekday is the English weekday name of the original date — + // "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI + // can localise it. + OriginalWeekday string `json:"originalWeekday,omitempty"` +} + +// HolidayDTO is the JSON shape for a holiday emitted in +// AdjustmentReason — distinct from a DB-level Holiday row so dates +// serialise as YYYY-MM-DD strings. +type HolidayDTO struct { + Date string `json:"date"` + Name string `json:"name"` + IsVacation bool `json:"isVacation,omitempty"` + IsClosure bool `json:"isClosure,omitempty"` +} + +// 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"). Drive condition_expr evaluation + flag-keyed +// alt-swap. +// - 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. +// - 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. +// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C +// rules: when non-nil, the proceedingCode argument is ignored and +// the engine selects rules WHERE trigger_event_id = *filter. +// - RuleOverrides substitutes specific rules in the calculator's +// rule list with caller-supplied in-memory rows. Used by the +// rule-editor preview. +// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden +// drive per-event-card choice overlays (t-paliad-265, t-paliad-290). +// - ProjectHint scopes the catalog lookup to a project context +// (paliad's catalog uses this to merge in project-scoped rules +// in future slices; v1 catalogs may ignore it). +type CalcOptions struct { + PriorityDateStr string + Flags []string + AnchorOverrides map[string]string + CourtID string + TriggerEventIDFilter *int64 + RuleOverrides []Rule + + PerCardAppellant map[string]string + SkipRules map[string]struct{} + IncludeCCRFor map[string]struct{} + IncludeHidden bool + + ProjectHint ProjectHint +} + +// ProjectHint scopes a Catalog call to a specific project. Paliad's +// catalog uses ProjectID to merge in project-scoped rules in a future +// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26 +// decision; the field stays for forward-compat). Other catalogs (the +// embedded UPC snapshot used by youpc.org) ignore the hint. +// +// Zero value = no project context (the abstract Verfahrensablauf / +// public Fristenrechner case). +type ProjectHint struct { + ProjectID uuid.UUID +} + +// 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.submission_code + TriggerDate string // required — YYYY-MM-DD + Flags []string // optional — condition_flag inputs + CourtID string // optional — selects holiday calendar +} + +// Timeline is the package's structured return for Calculate. JSON tags +// are aligned with paliad's historical UIResponse so handlers can serve +// it directly — the wire bytes the frontend reads are unchanged. +type Timeline struct { + ProceedingType string `json:"proceedingType"` + ProceedingName string `json:"proceedingName"` + ProceedingNameEN string `json:"proceedingNameEN,omitempty"` + TriggerDate string `json:"triggerDate"` + Deadlines []TimelineEntry `json:"deadlines"` + ContextualNote string `json:"contextualNote,omitempty"` + ContextualNoteEN string `json:"contextualNoteEN,omitempty"` + TriggerEventLabel string `json:"triggerEventLabel,omitempty"` + TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` + HiddenCount int `json:"hiddenCount"` +} + +// TimelineEntry matches the frontend's CalculatedDeadline TypeScript +// interface (camelCase JSON to keep /tools/fristenrechner byte-identical). +type TimelineEntry struct { + RuleID string `json:"ruleId,omitempty"` + Code string `json:"code"` + Name string `json:"name"` + NameEN string `json:"nameEN"` + Party string `json:"party"` + Priority string `json:"priority"` + RuleRef string `json:"ruleRef"` + LegalSource string `json:"legalSource,omitempty"` + LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` + 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 json.RawMessage `json:"conditionExpr,omitempty"` + IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"` + // IsConditional signals the rule's anchor is uncertain — no + // concrete date can be projected. Set when the rule depends on: + // - a court-set ancestor whose date isn't anchored (overlaps + // with IsCourtSetIndirect; the two are kept distinct because + // IsCourtSet wraps a specific UX message "wird vom Gericht + // bestimmt", whereas IsConditional is the broader "render as + // 'abhängig von '" signal) + // - timing='before' rules whose forward anchor isn't set + // - optional opposing-side rules whose true triggering event + // hasn't been recorded for this project (e.g. R.262(2) + // Erwiderung auf Vertraulichkeitsantrag) + // When true, DueDate and OriginalDate are empty and the frontend + // renders an "abhängig von " chip in place of a + // date. Suppressed by an explicit user anchor. (t-paliad-289) + IsConditional bool `json:"isConditional,omitempty"` + // ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the + // parent's identity so the frontend can render + // "abhängig von " when IsConditional=true. + // Populated whenever the rule has a parent_id, not only when + // conditional — keeps the wire shape stable. Empty for root rules. + // When a rule has a real trigger_event_id, these fields are + // overridden to point at the trigger_events catalog row instead of + // the parent_id chain (t-paliad-294 / m/paliad#126). + ParentRuleCode string `json:"parentRuleCode,omitempty"` + ParentRuleName string `json:"parentRuleName,omitempty"` + ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"` + IsOverridden bool `json:"isOverridden,omitempty"` + ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"` + AppellantContext string `json:"appellantContext,omitempty"` + IsHidden bool `json:"isHidden,omitempty"` +} + +// RuleCalculation is the single-rule calc response that backs the +// result-card click → calc-panel flow. Distinct from TimelineEntry +// (which represents one rendered row inside a full-proceeding +// response): RuleCalculation is self-contained. +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 []string `json:"flagsApplied,omitempty"` + FlagsRequired []string `json:"flagsRequired,omitempty"` +} + +// RuleCalculationRule mirrors the small subset of Rule 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"` +} + +// 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"` +} + +// TriggerEvent is a UPC procedural event referenced by deadline rules +// whose semantic anchor is an event rather than a parent rule (the +// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is +// triggered by the opposing party's confidentiality application, not +// by the SoC parent rule). The conditional-rendering branch reads +// this when stamping ParentRule* on the wire. +type TriggerEvent struct { + ID int64 `db:"id" json:"id"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + NameDE string `db:"name_de" json:"name_de"` + Description string `db:"description" json:"description"` + IsActive bool `db:"is_active" json:"is_active"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// Sentinel errors surfaced by Calculate / CalculateRule / Catalog +// implementations. Handlers map these to HTTP statuses. +var ( + ErrUnknownProceedingType = errors.New("unknown proceeding type") + ErrUnknownRule = errors.New("unknown rule") +)