Merge: t-paliad-298 — Slice A: extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (m/paliad#124)
This commit is contained in:
1144
docs/design-litigation-planner-2026-05-26.md
Normal file
1144
docs/design-litigation-planner-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,63 +4,20 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
// NullableJSON is a jsonb column that may be NULL. Canonical definition
|
||||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler)
|
||||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
// lives in pkg/litigationplanner — kept here as a type alias so every
|
||||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
// existing models.NullableJSON reference continues to compile.
|
||||||
// error that hid every approval_request from the inbox when m's first
|
type NullableJSON = litigationplanner.NullableJSON
|
||||||
// "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
|
|
||||||
}
|
|
||||||
|
|
||||||
// User extends auth.users with firm-specific profile fields. Created by the
|
// 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.
|
// 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.).
|
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||||
type DeadlineRule struct {
|
// Canonical definition lives in pkg/litigationplanner.Rule — kept here
|
||||||
ID uuid.UUID `db:"id" json:"id"`
|
// as a type alias so every existing models.DeadlineRule reference (sqlx
|
||||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
// scans, hydration, projection service) continues to compile.
|
||||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
type DeadlineRule = litigationplanner.Rule
|
||||||
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": "<name>"}
|
|
||||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
|
||||||
// {"op":"not", "args":[<node>]}
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||||
// append-only audit log for every change to paliad.deadline_rules.
|
// 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"`
|
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
// ProceedingType is one of the litigation conceptual codes (INF / REV /
|
||||||
// management) or the lowercase dot-separated fristenrechner codes
|
// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated
|
||||||
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical
|
||||||
type ProceedingType struct {
|
// definition lives in pkg/litigationplanner.ProceedingType — kept here
|
||||||
ID int `db:"id" json:"id"`
|
// as a type alias so every existing models.ProceedingType reference
|
||||||
Code string `db:"code" json:"code"`
|
// continues to compile.
|
||||||
Name string `db:"name" json:"name"`
|
type ProceedingType = litigationplanner.ProceedingType
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||||
// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven
|
// whose semantic anchor is an event rather than a parent rule.
|
||||||
// lookup, mirrored from youpc data.events).
|
// Canonical definition lives in pkg/litigationplanner.TriggerEvent.
|
||||||
type TriggerEvent struct {
|
type TriggerEvent = litigationplanner.TriggerEvent
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
|
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
|
||||||
// youpc data.deadlines + the trigger half of data.deadline_events.
|
// youpc data.deadlines + the trigger half of data.deadline_events.
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
|||||||
choices_offered`
|
choices_offered`
|
||||||
|
|
||||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
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.
|
// List returns active rules, optionally filtered by proceeding type.
|
||||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
|
|
||||||
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeadlineSearchService backs the unified Fristenrechner search bar
|
// DeadlineSearchService backs the unified Fristenrechner search bar
|
||||||
@@ -921,130 +923,15 @@ func roundScore(v float64) float64 {
|
|||||||
return float64(int(v*10000+0.5)) / 10000
|
return float64(int(v*10000+0.5)) / 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
|
||||||
// the form HLC users read in pleadings:
|
// defined in pkg/litigationplanner — kept here as thin re-exports so
|
||||||
//
|
// the existing in-package + handler call-sites compile unchanged.
|
||||||
// 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 {
|
func FormatLegalSourceDisplay(src string) string {
|
||||||
src = strings.TrimSpace(src)
|
return lp.FormatLegalSourceDisplay(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
|
|
||||||
// <type>.<number> 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 {
|
func BuildLegalSourceURL(src string) string {
|
||||||
src = strings.TrimSpace(src)
|
return lp.BuildLegalSourceURL(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshSearchView re-populates the materialised view. Safe to call on
|
// RefreshSearchView re-populates the materialised view. Safe to call on
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Country and regime constants — keep in sync with the paliad.countries
|
// 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
|
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
|
||||||
// math bug. See t-paliad-119.
|
// math bug. See t-paliad-119.
|
||||||
//
|
//
|
||||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
|
// Canonical AdjustmentReason + HolidayDTO definitions live in
|
||||||
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
|
// pkg/litigationplanner — kept here as type aliases so every existing
|
||||||
// separate RFC3339 parser. Holidays carries the same string-date shape.
|
// reference (HolidayService methods, JSON serialisation, projection
|
||||||
type AdjustmentReason struct {
|
// service) continues to compile.
|
||||||
// Kind is the dominant cause; longest cause wins when several apply
|
type (
|
||||||
// (vacation > public_holiday > weekend).
|
AdjustmentReason = litigationplanner.AdjustmentReason
|
||||||
Kind string `json:"kind"`
|
HolidayDTO = litigationplanner.HolidayDTO
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
||||||
// explanation. Reason is nil when wasAdjusted is false.
|
// explanation. Reason is nil when wasAdjusted is false.
|
||||||
|
|||||||
@@ -1,191 +1,63 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
// 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
|
// proceeding_mapping bridges the two proceeding-type vocabularies in
|
||||||
// throughout the codebase so a future rename only needs to touch this
|
// the codebase. The canonical implementations now live in
|
||||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
// pkg/litigationplanner — this file keeps the existing service-level
|
||||||
// projects.proceeding_type_id) are unaffected by the rename.
|
// 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 (
|
const (
|
||||||
CodeUPCInfringement = "upc.inf.cfi"
|
CodeUPCInfringement = lp.CodeUPCInfringement
|
||||||
CodeUPCRevocation = "upc.rev.cfi"
|
CodeUPCRevocation = lp.CodeUPCRevocation
|
||||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
CodeUPCCounterclaim = lp.CodeUPCCounterclaim
|
||||||
CodeUPCPreliminary = "upc.pi.cfi"
|
CodeUPCPreliminary = lp.CodeUPCPreliminary
|
||||||
CodeUPCDamages = "upc.dmgs.cfi"
|
CodeUPCDamages = lp.CodeUPCDamages
|
||||||
CodeUPCDiscovery = "upc.disc.cfi"
|
CodeUPCDiscovery = lp.CodeUPCDiscovery
|
||||||
CodeUPCAppealMerits = "upc.apl.merits"
|
CodeUPCAppealMerits = lp.CodeUPCAppealMerits
|
||||||
CodeUPCAppealOrder = "upc.apl.order"
|
CodeUPCAppealOrder = lp.CodeUPCAppealOrder
|
||||||
CodeUPCAppealCost = "upc.apl.cost"
|
CodeUPCAppealCost = lp.CodeUPCAppealCost
|
||||||
CodeDEInfringementLG = "de.inf.lg"
|
CodeDEInfringementLG = lp.CodeDEInfringementLG
|
||||||
CodeDEInfringementOLG = "de.inf.olg"
|
CodeDEInfringementOLG = lp.CodeDEInfringementOLG
|
||||||
CodeDEInfringementBGH = "de.inf.bgh"
|
CodeDEInfringementBGH = lp.CodeDEInfringementBGH
|
||||||
CodeDENullityBPatG = "de.null.bpatg"
|
CodeDENullityBPatG = lp.CodeDENullityBPatG
|
||||||
CodeDENullityBGH = "de.null.bgh"
|
CodeDENullityBGH = lp.CodeDENullityBGH
|
||||||
CodeEPAGrant = "epa.grant.exa"
|
CodeEPAGrant = lp.CodeEPAGrant
|
||||||
CodeEPAOpposition = "epa.opp.opd"
|
CodeEPAOpposition = lp.CodeEPAOpposition
|
||||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal
|
||||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
CodeDPMAOpposition = lp.CodeDPMAOpposition
|
||||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG
|
||||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH
|
||||||
)
|
)
|
||||||
|
|
||||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||||
//
|
// Delegates to litigationplanner.MapLitigationToFristenrechner.
|
||||||
// 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) {
|
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||||
switch litigationCode {
|
return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction)
|
||||||
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
|
// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi
|
||||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
// illustrative-peer route. Delegates to
|
||||||
// for taxonomic completeness, but no rules are attached to it. When the
|
// litigationplanner.ResolveCounterclaimRouting.
|
||||||
// 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) {
|
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||||
if route, ok := SubTrackRoutings[code]; ok {
|
return lp.ResolveCounterclaimRouting(code)
|
||||||
return route.ParentCode, route.DefaultFlags, true
|
|
||||||
}
|
|
||||||
return code, nil, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubTrackRouting describes a proceeding type that has no native rules
|
// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting
|
||||||
// of its own and is normally rendered inside a parent proceeding's flow
|
// is aliased in fristenrechner.go.
|
||||||
// with one or more condition flags enabled. The Procedure Roadmap
|
var SubTrackRoutings = lp.SubTrackRoutings
|
||||||
// (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.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
// code, or (zero, false) if the code is not a sub-track. Delegates to
|
||||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
// litigationplanner.LookupSubTrackRouting.
|
||||||
// track's default flags.
|
|
||||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||||
r, ok := SubTrackRoutings[code]
|
return lp.LookupSubTrackRouting(code)
|
||||||
return r, ok
|
|
||||||
}
|
}
|
||||||
|
|||||||
49
pkg/litigationplanner/catalog.go
Normal file
49
pkg/litigationplanner/catalog.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
49
pkg/litigationplanner/courts.go
Normal file
49
pkg/litigationplanner/courts.go
Normal file
@@ -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, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
17
pkg/litigationplanner/doc.go
Normal file
17
pkg/litigationplanner/doc.go
Normal file
@@ -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
|
||||||
76
pkg/litigationplanner/durations.go
Normal file
76
pkg/litigationplanner/durations.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
908
pkg/litigationplanner/engine.go
Normal file
908
pkg/litigationplanner/engine.go
Normal file
@@ -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 <ParentRuleName>" chip without re-scanning the rules
|
||||||
|
// slice on every iteration. (t-paliad-289)
|
||||||
|
ruleByID := make(map[uuid.UUID]Rule, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
ruleByID[r.ID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerEventByID powers the trigger-event override on the
|
||||||
|
// conditional-label chip (m/paliad#126 / t-paliad-294). When a rule
|
||||||
|
// carries a real paliad.trigger_events row, that catalog event —
|
||||||
|
// not the rule's parent_id — is the rule's actual semantic anchor.
|
||||||
|
// The override fires below when stamping ParentRule* on the wire so
|
||||||
|
// the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit
|
||||||
|
// gegenüber der Öffentlichkeit" for R.262(2) — instead of the
|
||||||
|
// (misleading) parent_id-derived "abhängig von Klageerhebung".
|
||||||
|
//
|
||||||
|
// Bulk-loaded in one round-trip; trees in the live corpus carry at
|
||||||
|
// most a handful of trigger_event_id-bearing rules (2 today on
|
||||||
|
// upc.inf.cfi), so the IN(...) is small.
|
||||||
|
var triggerIDs []int64
|
||||||
|
seenTrigger := make(map[int64]struct{}, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.TriggerEventID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTrigger[*r.TriggerEventID] = struct{}{}
|
||||||
|
triggerIDs = append(triggerIDs, *r.TriggerEventID)
|
||||||
|
}
|
||||||
|
triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the rule list in 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 <ParentRuleName>" UX (t-paliad-289).
|
||||||
|
if parentIsCourtSet {
|
||||||
|
d.IsCourtSet = true
|
||||||
|
d.IsCourtSetIndirect = true
|
||||||
|
d.IsConditional = true
|
||||||
|
d.DueDate = ""
|
||||||
|
d.OriginalDate = ""
|
||||||
|
courtSet[r.ID] = true
|
||||||
|
deadlines = append(deadlines, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||||
|
// epa.grant.exa publish) when supplied, then parent's computed
|
||||||
|
// date (or user override), then trigger date.
|
||||||
|
baseDate := triggerDate
|
||||||
|
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||||
|
baseDate = *priorityDate
|
||||||
|
} else if r.ParentID != nil {
|
||||||
|
for _, prev := range rules {
|
||||||
|
if prev.ID == *r.ParentID {
|
||||||
|
if prev.SubmissionCode != nil {
|
||||||
|
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||||
|
baseDate = ov
|
||||||
|
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
||||||
|
baseDate = ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
|
||||||
|
// gate fires AND alt_* values exist, swap the primary duration
|
||||||
|
// to the alt values. This is distinct from combine_op below —
|
||||||
|
// alt-swap is a one-or-the-other choice keyed on flags, whereas
|
||||||
|
// combine_op computes both legs and picks max/min.
|
||||||
|
durationValue := r.DurationValue
|
||||||
|
durationUnit := r.DurationUnit
|
||||||
|
timing := ""
|
||||||
|
if r.Timing != nil {
|
||||||
|
timing = *r.Timing
|
||||||
|
}
|
||||||
|
if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
||||||
|
durationValue = *r.AltDurationValue
|
||||||
|
if r.AltDurationUnit != nil {
|
||||||
|
durationUnit = *r.AltDurationUnit
|
||||||
|
}
|
||||||
|
if r.AltRuleCode != nil {
|
||||||
|
d.RuleRef = *r.AltRuleCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User override on this rule: replace the calculated date with
|
||||||
|
// the user's date. Skip holiday rollover — the user's date is
|
||||||
|
// authoritative.
|
||||||
|
if r.SubmissionCode != nil {
|
||||||
|
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
||||||
|
d.OriginalDate = ov.Format("2006-01-02")
|
||||||
|
d.DueDate = ov.Format("2006-01-02")
|
||||||
|
d.WasAdjusted = false
|
||||||
|
d.AdjustmentReason = nil
|
||||||
|
d.IsOverridden = true
|
||||||
|
computed[*r.SubmissionCode] = ov
|
||||||
|
deadlines = append(deadlines, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
origDate, adjusted, wasAdj, reason := ApplyDuration(
|
||||||
|
baseDate, durationValue, durationUnit, timing, country, regime, holidays,
|
||||||
|
)
|
||||||
|
|
||||||
|
// combine_op composite: compute the alt leg too, apply max/min.
|
||||||
|
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||||
|
altOrig, altAdj, altWasAdj, altReason := ApplyDuration(
|
||||||
|
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
||||||
|
)
|
||||||
|
switch *r.CombineOp {
|
||||||
|
case "max":
|
||||||
|
if altAdj.After(adjusted) {
|
||||||
|
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||||
|
}
|
||||||
|
case "min":
|
||||||
|
if altAdj.Before(adjusted) {
|
||||||
|
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.OriginalDate = origDate.Format("2006-01-02")
|
||||||
|
d.DueDate = adjusted.Format("2006-01-02")
|
||||||
|
d.WasAdjusted = wasAdj
|
||||||
|
d.AdjustmentReason = reason
|
||||||
|
|
||||||
|
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
|
||||||
|
// Rules with priority='optional' AND primary_party='both' whose
|
||||||
|
// data-model parent is the proceeding's trigger anchor (parent
|
||||||
|
// has parent_id=NULL and is not court-set, i.e. the SoC root
|
||||||
|
// rule) represent a rule whose REAL triggering event sits
|
||||||
|
// outside the rule data — e.g. R.262(2) Erwiderung auf
|
||||||
|
// Vertraulichkeitsantrag anchors on SoC in the data, but the
|
||||||
|
// real trigger is the opposing party's confidentiality motion
|
||||||
|
// which may never happen. Without an explicit anchor on the
|
||||||
|
// rule itself, the projection must NOT claim a concrete date.
|
||||||
|
if !d.IsOverridden && !d.IsConditional &&
|
||||||
|
r.Priority == "optional" &&
|
||||||
|
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
|
||||||
|
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
|
||||||
|
d.IsConditional = true
|
||||||
|
d.DueDate = ""
|
||||||
|
d.OriginalDate = ""
|
||||||
|
d.WasAdjusted = false
|
||||||
|
d.AdjustmentReason = nil
|
||||||
|
// Mark this rule's ID as having an uncertain anchor so
|
||||||
|
// rules chaining off it also surface conditional via the
|
||||||
|
// parentIsCourtSet path.
|
||||||
|
courtSet[r.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.SubmissionCode != nil {
|
||||||
|
computed[*r.SubmissionCode] = adjusted
|
||||||
|
}
|
||||||
|
deadlines = append(deadlines, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
145
pkg/litigationplanner/expr.go
Normal file
145
pkg/litigationplanner/expr.go
Normal file
@@ -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": "<name>"} — leaf: true iff <name> ∈ flags
|
||||||
|
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
||||||
|
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
||||||
|
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
||||||
|
//
|
||||||
|
// NULL / empty / "null" expression → true (unconditional). Malformed
|
||||||
|
// JSON → true (defensive: the rule still renders, the lawyer sees
|
||||||
|
// it even if the gate is broken).
|
||||||
|
//
|
||||||
|
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
||||||
|
// text[] column; the fallback that AND'd over it is gone. Any future
|
||||||
|
// row needing array-of-flags semantics writes the equivalent
|
||||||
|
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
||||||
|
func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
||||||
|
if len(expr) == 0 || string(expr) == "null" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return EvalConditionExprNode(expr, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalConditionExprNode walks one node of the condition_expr jsonb
|
||||||
|
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
||||||
|
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
||||||
|
// 2-arg AND (mig 084).
|
||||||
|
func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
||||||
|
var node struct {
|
||||||
|
Flag string `json:"flag"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Args []json.RawMessage `json:"args"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &node); err != nil {
|
||||||
|
// Malformed → unconditional. The Slice 11 editor's validation
|
||||||
|
// will block such writes; in the live corpus today mig 084's
|
||||||
|
// jsonb_build_object output is well-formed by construction.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if node.Flag != "" {
|
||||||
|
_, ok := flags[node.Flag]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
switch node.Op {
|
||||||
|
case "and":
|
||||||
|
for _, a := range node.Args {
|
||||||
|
if !EvalConditionExprNode(a, flags) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "or":
|
||||||
|
for _, a := range node.Args {
|
||||||
|
if EvalConditionExprNode(a, flags) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case "not":
|
||||||
|
if len(node.Args) != 1 {
|
||||||
|
// Malformed NOT — fall through to unconditional rather
|
||||||
|
// than risk suppressing a rule the lawyer expects to see.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !EvalConditionExprNode(node.Args[0], flags)
|
||||||
|
}
|
||||||
|
// Unknown op (forward-compat with editor extensions): treat as
|
||||||
|
// unconditional so the rule still renders.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasConditionExpr returns true when the rule carries a non-empty,
|
||||||
|
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
||||||
|
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
||||||
|
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
||||||
|
// when the gate flips to met, swap to alt".
|
||||||
|
func HasConditionExpr(expr NullableJSON) bool {
|
||||||
|
if len(expr) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := string(expr)
|
||||||
|
return s != "null" && s != "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFlagsFromExpr walks the jsonb gate and returns the unique
|
||||||
|
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
||||||
|
// CalculateRule's response (FlagsRequired) so the result-card calc
|
||||||
|
// panel can render flag checkboxes for each gate input. Replaces the
|
||||||
|
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
||||||
|
// expression or one that contains no flag leaves.
|
||||||
|
func ExtractFlagsFromExpr(expr 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
pkg/litigationplanner/holidays.go
Normal file
25
pkg/litigationplanner/holidays.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
123
pkg/litigationplanner/legal_source.go
Normal file
123
pkg/litigationplanner/legal_source.go
Normal file
@@ -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
|
||||||
|
// <type>.<number> 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
|
||||||
|
}
|
||||||
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
151
pkg/litigationplanner/sort.go
Normal file
151
pkg/litigationplanner/sort.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
53
pkg/litigationplanner/subtrack.go
Normal file
53
pkg/litigationplanner/subtrack.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
428
pkg/litigationplanner/types.go
Normal file
428
pkg/litigationplanner/types.go
Normal file
@@ -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 <nil> 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": "<name>"}
|
||||||
|
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||||
|
// {"op":"not", "args":[<node>]}
|
||||||
|
// 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 <parent>'" 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 <ParentRuleName>" 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 <ParentRuleName>" when IsConditional=true.
|
||||||
|
// Populated whenever the rule has a parent_id, not only when
|
||||||
|
// conditional — keeps the wire shape stable. Empty for root rules.
|
||||||
|
// 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")
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user