Merge: t-paliad-292 — Slice B2: multi-axis catalog query API (LookupEvents, 5-axis AND filter, depth toggle) (m/paliad#124 §18.2)
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -13,6 +15,17 @@ import (
|
|||||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isValidPartyForLookup mirrors the four-value primary_party vocab the
|
||||||
|
// engine knows about. B2 inlines this check; B3 will add a canonical
|
||||||
|
// lp.IsValidPrimaryParty + tighten the column to a CHECK constraint.
|
||||||
|
func isValidPartyForLookup(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "claimant", "defendant", "court", "both":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// FristenrechnerService renders the Paliad public Fristenrechner's
|
// FristenrechnerService renders the Paliad public Fristenrechner's
|
||||||
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
||||||
// is a thin adapter: the compute engine + types live in
|
// is a thin adapter: the compute engine + types live in
|
||||||
@@ -223,6 +236,296 @@ func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64)
|
|||||||
return c.rules.LoadTriggerEventsByIDs(ctx, ids)
|
return c.rules.LoadTriggerEventsByIDs(ctx, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LookupEvents queries paliad.deadline_rules for rules matching the
|
||||||
|
// requested axes, then walks the parent_id graph in Go to honour the
|
||||||
|
// requested depth. Slice B2 (m/paliad#124 §18.2).
|
||||||
|
//
|
||||||
|
// Filter axes apply at the SQL layer:
|
||||||
|
// - Jurisdiction: WHERE paliad.proceeding_types.jurisdiction = $X
|
||||||
|
// - ProceedingTypeID: WHERE deadline_rules.proceeding_type_id = $X
|
||||||
|
// - Party: WHERE deadline_rules.primary_party = $X
|
||||||
|
// - EventCategoryID: EXISTS subquery on
|
||||||
|
// paliad.event_category_concepts joined via concept_id
|
||||||
|
// - AppealTarget: WHERE $X = ANY(deadline_rules.applies_to_target)
|
||||||
|
//
|
||||||
|
// Depth is applied post-fetch: for EventLookupDepthNext, anchor rules
|
||||||
|
// (matched directly) are returned at depth=1 + their immediate
|
||||||
|
// children (parent_id IN matched-set) at depth=2. For
|
||||||
|
// EventLookupDepthAllFollowing, the parent_id walk continues
|
||||||
|
// recursively. The walk stays within the per-proceeding rule set
|
||||||
|
// (cross-proceeding spawn following is handled by the engine, not by
|
||||||
|
// LookupEvents).
|
||||||
|
//
|
||||||
|
// "published + active" gate: lifecycle_state='published' AND
|
||||||
|
// is_active=true (matches LoadProceeding's WHERE clause).
|
||||||
|
func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
|
||||||
|
// Validate axis values up front; unknown values fall through as
|
||||||
|
// "no filter on this axis" so a stale frontend chip doesn't
|
||||||
|
// silently drop the entire result set.
|
||||||
|
jurisdiction := axes.Jurisdiction
|
||||||
|
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
|
||||||
|
jurisdiction != "EPA" && jurisdiction != "DPMA" {
|
||||||
|
jurisdiction = ""
|
||||||
|
}
|
||||||
|
party := axes.Party
|
||||||
|
if party != "" && !isValidPartyForLookup(party) {
|
||||||
|
party = ""
|
||||||
|
}
|
||||||
|
appealTarget := axes.AppealTarget
|
||||||
|
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
|
||||||
|
appealTarget = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the WHERE clause progressively. Each axis adds a $N
|
||||||
|
// placeholder + appends to the args slice.
|
||||||
|
where := []string{
|
||||||
|
"dr.is_active = true",
|
||||||
|
"dr.lifecycle_state = 'published'",
|
||||||
|
"pt.is_active = true",
|
||||||
|
}
|
||||||
|
args := []any{}
|
||||||
|
add := func(clause string, val any) {
|
||||||
|
args = append(args, val)
|
||||||
|
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||||
|
}
|
||||||
|
if jurisdiction != "" {
|
||||||
|
add("pt.jurisdiction = $%d", jurisdiction)
|
||||||
|
}
|
||||||
|
if axes.ProceedingTypeID != nil {
|
||||||
|
add("dr.proceeding_type_id = $%d", *axes.ProceedingTypeID)
|
||||||
|
}
|
||||||
|
if party != "" {
|
||||||
|
add("dr.primary_party = $%d", party)
|
||||||
|
}
|
||||||
|
if axes.EventCategoryID != nil {
|
||||||
|
// Junction-table EXISTS: the rule's concept_id must appear in
|
||||||
|
// paliad.event_category_concepts with the matching
|
||||||
|
// event_category_id.
|
||||||
|
add(`EXISTS (
|
||||||
|
SELECT 1 FROM paliad.event_category_concepts ecc
|
||||||
|
WHERE ecc.event_category_id = $%d
|
||||||
|
AND ecc.concept_id = dr.concept_id
|
||||||
|
)`, *axes.EventCategoryID)
|
||||||
|
}
|
||||||
|
if appealTarget != "" {
|
||||||
|
add("$%d = ANY(dr.applies_to_target)", appealTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT ` + ruleColumns + `,
|
||||||
|
pt.id AS pt_id, pt.code AS pt_code, pt.name AS pt_name,
|
||||||
|
pt.name_en AS pt_name_en, pt.description AS pt_description,
|
||||||
|
pt.jurisdiction AS pt_jurisdiction, pt.category AS pt_category,
|
||||||
|
pt.default_color AS pt_default_color, pt.sort_order AS pt_sort_order,
|
||||||
|
pt.is_active AS pt_is_active,
|
||||||
|
pt.trigger_event_label_de AS pt_trigger_event_label_de,
|
||||||
|
pt.trigger_event_label_en AS pt_trigger_event_label_en,
|
||||||
|
pt.appeal_target AS pt_appeal_target
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
WHERE ` + strings.Join(where, "\n AND ") + `
|
||||||
|
ORDER BY dr.proceeding_type_id, dr.sequence_order`
|
||||||
|
|
||||||
|
var rows []lookupEventsRow
|
||||||
|
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("lookup events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return []lp.EventMatch{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchedIDs is the set of rule IDs that satisfied the axes (the
|
||||||
|
// "anchor" matches at depth=1). For EventLookupDepthNext we add
|
||||||
|
// their direct children. For EventLookupDepthAllFollowing we walk
|
||||||
|
// the parent_id chain transitively.
|
||||||
|
matchedIDs := make(map[uuid.UUID]bool, len(rows))
|
||||||
|
anchorMatch := make(map[uuid.UUID]bool, len(rows))
|
||||||
|
rowByID := make(map[uuid.UUID]lookupEventsRow, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
matchedIDs[r.ID] = true
|
||||||
|
anchorMatch[r.ID] = true
|
||||||
|
rowByID[r.ID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// For depth control we need the full per-proceeding rule corpus
|
||||||
|
// (so we can find children whose parent_id ∈ matchedIDs even when
|
||||||
|
// those children don't match the axes themselves). Skip this when
|
||||||
|
// depth is empty (treated as "anchors only" — undocumented but
|
||||||
|
// useful as a degenerate case).
|
||||||
|
expandFromCorpus := func(corpus []models.DeadlineRule, joinedFor map[int]lookupEventsRow) {
|
||||||
|
// We loop until no new descendants are added (transitive
|
||||||
|
// closure under parent_id ∈ matchedIDs). EventLookupDepthNext
|
||||||
|
// stops after one pass; AllFollowing iterates to fixpoint.
|
||||||
|
for {
|
||||||
|
grew := false
|
||||||
|
for _, r := range corpus {
|
||||||
|
if r.ParentID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchedIDs[*r.ParentID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matchedIDs[r.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedIDs[r.ID] = true
|
||||||
|
j := joinedFor[*r.ProceedingTypeID]
|
||||||
|
rowByID[r.ID] = lookupEventsRow{
|
||||||
|
DeadlineRule: r,
|
||||||
|
PTID: j.PTID,
|
||||||
|
PTCode: j.PTCode,
|
||||||
|
PTName: j.PTName,
|
||||||
|
PTNameEN: j.PTNameEN,
|
||||||
|
PTDescription: j.PTDescription,
|
||||||
|
PTJurisdiction: j.PTJurisdiction,
|
||||||
|
PTCategory: j.PTCategory,
|
||||||
|
PTDefaultColor: j.PTDefaultColor,
|
||||||
|
PTSortOrder: j.PTSortOrder,
|
||||||
|
PTIsActive: j.PTIsActive,
|
||||||
|
PTTriggerEventLabelDE: j.PTTriggerEventLabelDE,
|
||||||
|
PTTriggerEventLabelEN: j.PTTriggerEventLabelEN,
|
||||||
|
PTAppealTarget: j.PTAppealTarget,
|
||||||
|
}
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
if !grew || depth == lp.EventLookupDepthNext {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
|
||||||
|
// Load the proceeding-scoped corpus for every proceeding_type
|
||||||
|
// that appeared in the anchor set. The walk needs full
|
||||||
|
// visibility into each proceeding's rule tree so it can
|
||||||
|
// resolve parent_id chains.
|
||||||
|
procIDs := make(map[int]struct{})
|
||||||
|
joinedFor := make(map[int]lookupEventsRow)
|
||||||
|
for _, r := range rows {
|
||||||
|
procIDs[r.PTID] = struct{}{}
|
||||||
|
if _, ok := joinedFor[r.PTID]; !ok {
|
||||||
|
joinedFor[r.PTID] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ptID := range procIDs {
|
||||||
|
corpus, err := c.rules.List(ctx, &ptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lookup events: load proceeding %d corpus: %w", ptID, err)
|
||||||
|
}
|
||||||
|
expandFromCorpus(corpus, joinedFor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// depths[id] = sequence-depth from the closest anchor ancestor.
|
||||||
|
// Anchors are depth=1; their direct children are depth=2; etc.
|
||||||
|
depths := computeDepths(rowByID, anchorMatch)
|
||||||
|
|
||||||
|
// Compose the result slice ordered by (PTID, sequence_order).
|
||||||
|
type withKey struct {
|
||||||
|
match lp.EventMatch
|
||||||
|
key int64
|
||||||
|
}
|
||||||
|
items := make([]withKey, 0, len(matchedIDs))
|
||||||
|
for id := range matchedIDs {
|
||||||
|
r := rowByID[id]
|
||||||
|
var parentRuleID *uuid.UUID
|
||||||
|
if r.ParentID != nil && matchedIDs[*r.ParentID] {
|
||||||
|
p := *r.ParentID
|
||||||
|
parentRuleID = &p
|
||||||
|
}
|
||||||
|
items = append(items, withKey{
|
||||||
|
match: lp.EventMatch{
|
||||||
|
Rule: r.DeadlineRule,
|
||||||
|
ProceedingType: lp.ProceedingType{
|
||||||
|
ID: r.PTID,
|
||||||
|
Code: r.PTCode,
|
||||||
|
Name: r.PTName,
|
||||||
|
NameEN: r.PTNameEN,
|
||||||
|
Description: r.PTDescription,
|
||||||
|
Jurisdiction: r.PTJurisdiction,
|
||||||
|
Category: r.PTCategory,
|
||||||
|
DefaultColor: r.PTDefaultColor,
|
||||||
|
SortOrder: r.PTSortOrder,
|
||||||
|
IsActive: r.PTIsActive,
|
||||||
|
TriggerEventLabelDE: r.PTTriggerEventLabelDE,
|
||||||
|
TriggerEventLabelEN: r.PTTriggerEventLabelEN,
|
||||||
|
AppealTarget: r.PTAppealTarget,
|
||||||
|
},
|
||||||
|
Priority: r.Priority,
|
||||||
|
DepthFromAnchor: depths[id],
|
||||||
|
ParentRuleID: parentRuleID,
|
||||||
|
},
|
||||||
|
key: int64(r.PTID)*1_000_000 + int64(r.SequenceOrder),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(a, b int) bool { return items[a].key < items[b].key })
|
||||||
|
out := make([]lp.EventMatch, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = it.match
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupEventsRow is the joined SELECT shape for LookupEvents — one
|
||||||
|
// deadline_rules row plus its proceeding_types parent columns. Kept
|
||||||
|
// at package scope so computeDepths can reference it.
|
||||||
|
type lookupEventsRow struct {
|
||||||
|
models.DeadlineRule
|
||||||
|
PTID int `db:"pt_id"`
|
||||||
|
PTCode string `db:"pt_code"`
|
||||||
|
PTName string `db:"pt_name"`
|
||||||
|
PTNameEN string `db:"pt_name_en"`
|
||||||
|
PTDescription *string `db:"pt_description"`
|
||||||
|
PTJurisdiction *string `db:"pt_jurisdiction"`
|
||||||
|
PTCategory *string `db:"pt_category"`
|
||||||
|
PTDefaultColor string `db:"pt_default_color"`
|
||||||
|
PTSortOrder int `db:"pt_sort_order"`
|
||||||
|
PTIsActive bool `db:"pt_is_active"`
|
||||||
|
PTTriggerEventLabelDE *string `db:"pt_trigger_event_label_de"`
|
||||||
|
PTTriggerEventLabelEN *string `db:"pt_trigger_event_label_en"`
|
||||||
|
PTAppealTarget *string `db:"pt_appeal_target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeDepths walks from each rule up the parent_id chain until it
|
||||||
|
// hits an anchor match (or runs out). The depth of the anchor is 1;
|
||||||
|
// each step away adds one. Rules whose entire chain has no anchor
|
||||||
|
// (defensive — shouldn't happen given the expand-from-corpus walk
|
||||||
|
// only adds children of matched parents) get depth=1.
|
||||||
|
//
|
||||||
|
// Iteration-bounded by the corpus size to prevent infinite loops on
|
||||||
|
// hypothetical parent_id cycles (mig 134 + the schema CHECKs already
|
||||||
|
// preclude cycles, but the bound is cheap insurance).
|
||||||
|
func computeDepths(
|
||||||
|
rowByID map[uuid.UUID]lookupEventsRow,
|
||||||
|
anchors map[uuid.UUID]bool,
|
||||||
|
) map[uuid.UUID]int {
|
||||||
|
depths := make(map[uuid.UUID]int, len(rowByID))
|
||||||
|
for id := range rowByID {
|
||||||
|
if anchors[id] {
|
||||||
|
depths[id] = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Walk parents until we find an anchor or run out.
|
||||||
|
d := 1
|
||||||
|
cur := id
|
||||||
|
maxIter := len(rowByID) + 1
|
||||||
|
for i := 0; i < maxIter; i++ {
|
||||||
|
r := rowByID[cur]
|
||||||
|
if r.ParentID == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
d++
|
||||||
|
cur = *r.ParentID
|
||||||
|
if anchors[cur] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depths[id] = d
|
||||||
|
}
|
||||||
|
return depths
|
||||||
|
}
|
||||||
|
|
||||||
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
||||||
var _ lp.Catalog = (*paliadCatalog)(nil)
|
var _ lp.Catalog = (*paliadCatalog)(nil)
|
||||||
|
|
||||||
|
|||||||
159
internal/services/lookup_events_test.go
Normal file
159
internal/services/lookup_events_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/db"
|
||||||
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLookupEvents covers the multi-axis catalog query API from Slice
|
||||||
|
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
|
||||||
|
// mirroring TestCalculateRule.
|
||||||
|
//
|
||||||
|
// Cases:
|
||||||
|
// - jurisdiction=UPC, depth=all-following → every active+published
|
||||||
|
// UPC rule, anchor depth=1 for all (no parent_id outside the
|
||||||
|
// filtered set lights up depth>1 because the entire UPC subset is
|
||||||
|
// a single anchor cohort).
|
||||||
|
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
|
||||||
|
// → defendant rules in upc.inf.cfi at depth=1 + direct children
|
||||||
|
// of those at depth=2.
|
||||||
|
// - unknown jurisdiction value → silently ignored, no filter applied.
|
||||||
|
// - empty axes → all rules (no filter on any axis).
|
||||||
|
func TestLookupEvents(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||||
|
}
|
||||||
|
if err := db.ApplyMigrations(url); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := sqlx.Connect("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
rules := NewDeadlineRuleService(pool)
|
||||||
|
catalog := &paliadCatalog{rules: rules}
|
||||||
|
|
||||||
|
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
|
||||||
|
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "UPC",
|
||||||
|
}, lp.EventLookupDepthAllFollowing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
t.Fatal("expected non-empty UPC corpus")
|
||||||
|
}
|
||||||
|
// Every match must be a UPC rule.
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||||
|
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
|
||||||
|
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
|
||||||
|
}
|
||||||
|
if m.DepthFromAnchor < 1 {
|
||||||
|
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
|
||||||
|
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "UPC",
|
||||||
|
Party: "defendant",
|
||||||
|
}, lp.EventLookupDepthNext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
t.Fatal("expected at least one defendant rule across the UPC corpus")
|
||||||
|
}
|
||||||
|
// Anchor matches (depth=1) must be primary_party=defendant.
|
||||||
|
// Depth=2 children appear under EventLookupDepthNext only as
|
||||||
|
// expansion from anchors — they may carry any party.
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.DepthFromAnchor != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||||
|
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
|
||||||
|
m.Rule.Name, m.Rule.PrimaryParty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
|
||||||
|
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
|
||||||
|
lp.EventLookupDepthAllFollowing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents (all): %v", err)
|
||||||
|
}
|
||||||
|
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "XX-not-a-real-jurisdiction",
|
||||||
|
}, lp.EventLookupDepthAllFollowing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents (unknown): %v", err)
|
||||||
|
}
|
||||||
|
if len(matchesAll) != len(matchesUnknown) {
|
||||||
|
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
|
||||||
|
len(matchesUnknown), len(matchesAll))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
|
||||||
|
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "UPC",
|
||||||
|
AppealTarget: lp.AppealTargetEndentscheidung,
|
||||||
|
}, lp.EventLookupDepthAllFollowing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents: %v", err)
|
||||||
|
}
|
||||||
|
// Should hit the 7 rules under the unified upc.apl that
|
||||||
|
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||||
|
if len(matches) == 0 {
|
||||||
|
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.DepthFromAnchor != 1 {
|
||||||
|
continue // children of anchors may be from other targets
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, t := range m.Rule.AppliesToTarget {
|
||||||
|
if t == lp.AppealTargetEndentscheidung {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("anchor row %s missing endentscheidung target: %v",
|
||||||
|
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||||
|
}
|
||||||
|
if m.ProceedingType.Code != "upc.apl" {
|
||||||
|
t.Errorf("anchor row %s came from %s, want upc.apl",
|
||||||
|
m.Rule.Name, m.ProceedingType.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) {
|
||||||
|
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "UPC",
|
||||||
|
AppealTarget: lp.AppealTargetSchadensbemessung,
|
||||||
|
}, lp.EventLookupDepthAllFollowing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -46,4 +46,17 @@ type Catalog interface {
|
|||||||
// are simply absent (caller treats absence as "no override").
|
// are simply absent (caller treats absence as "no override").
|
||||||
// Empty input returns an empty map without a DB roundtrip.
|
// Empty input returns an empty map without a DB roundtrip.
|
||||||
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
|
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
|
||||||
|
|
||||||
|
// LookupEvents returns deadline rules matching any subset of the
|
||||||
|
// requested axes, at the requested sequence depth (Slice B2,
|
||||||
|
// m/paliad#124 §18.2). Used by the Determinator cascade, the
|
||||||
|
// scenarios surface (Slice D), and any future "show me events
|
||||||
|
// matching X" query. Empty result is NOT an error.
|
||||||
|
//
|
||||||
|
// Implementations must respect the catalog's "published + active"
|
||||||
|
// rule gate (rules with lifecycle_state='draft' or is_active=false
|
||||||
|
// must NEVER appear in the result). Sort order is
|
||||||
|
// (proceeding_type_id, sequence_order) so the frontend can render
|
||||||
|
// without re-sorting.
|
||||||
|
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,6 +426,76 @@ type FristenrechnerType struct {
|
|||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventLookupAxes carries the optional filter axes for
|
||||||
|
// Catalog.LookupEvents (Slice B2, m/paliad#124 §18.2). All fields are
|
||||||
|
// optional; the empty value (or nil pointer) is "no filter on this
|
||||||
|
// axis". When multiple axes are set the catalog applies them as AND —
|
||||||
|
// a rule must match every non-zero axis to be returned. An axis set
|
||||||
|
// to an unknown value (jurisdiction="XX", party="foo") is treated the
|
||||||
|
// same as "no filter on this axis" so a stale frontend doesn't
|
||||||
|
// silently drop the entire result set.
|
||||||
|
//
|
||||||
|
// AppealTarget narrows to rules whose AppliesToTarget contains the
|
||||||
|
// requested slug (same semantic as CalcOptions.AppealTarget). Useful
|
||||||
|
// for the unified UPC Berufung lookup.
|
||||||
|
type EventLookupAxes struct {
|
||||||
|
// Jurisdiction filters by paliad.proceeding_types.jurisdiction
|
||||||
|
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
|
||||||
|
Jurisdiction string
|
||||||
|
// ProceedingTypeID narrows to one proceeding. nil = any.
|
||||||
|
ProceedingTypeID *int
|
||||||
|
// Party filters by paliad.deadline_rules.primary_party
|
||||||
|
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
|
||||||
|
// Validated against PrimaryParties before the SQL pass; unknown
|
||||||
|
// values fall through as "no filter".
|
||||||
|
Party string
|
||||||
|
// EventCategoryID narrows to rules associated with one
|
||||||
|
// event_categories row via the
|
||||||
|
// deadline_concept_event_types junction. nil = any.
|
||||||
|
EventCategoryID *uuid.UUID
|
||||||
|
// AppealTarget filters by Rule.AppliesToTarget containing the
|
||||||
|
// requested slug (e.g. "endentscheidung"). Empty = any.
|
||||||
|
// Validated against AppealTargets before the SQL pass.
|
||||||
|
AppealTarget string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventLookupDepth controls the sequence-depth of the returned events.
|
||||||
|
type EventLookupDepth string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EventLookupDepthNext returns immediate children of the matched
|
||||||
|
// anchor (1 hop downstream via parent_id). Useful for "what comes
|
||||||
|
// next from this point?" queries.
|
||||||
|
EventLookupDepthNext EventLookupDepth = "next"
|
||||||
|
// EventLookupDepthAllFollowing returns the entire downstream
|
||||||
|
// chain (parent_id walk to leaves). Useful for "show me the
|
||||||
|
// whole sequence from here onward" queries.
|
||||||
|
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventMatch is one result row from Catalog.LookupEvents.
|
||||||
|
type EventMatch struct {
|
||||||
|
// Rule carries the full deadline-rule row including parent_id,
|
||||||
|
// duration_value/_unit, condition_expr, applies_to_target, etc.
|
||||||
|
Rule Rule `json:"rule"`
|
||||||
|
// ProceedingType is the owning proceeding metadata. Lets the
|
||||||
|
// frontend render the "from <proceeding>" badge without a second
|
||||||
|
// roundtrip.
|
||||||
|
ProceedingType ProceedingType `json:"proceedingType"`
|
||||||
|
// Priority surfaces Rule.Priority at the top level for
|
||||||
|
// convenience — the four-value vocab (mandatory / recommended /
|
||||||
|
// optional / informational).
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
// DepthFromAnchor is 1 for the immediate match, 2+ for deeper
|
||||||
|
// descendants returned under EventLookupDepthAllFollowing.
|
||||||
|
// Always >= 1 for any returned row.
|
||||||
|
DepthFromAnchor int `json:"depthFromAnchor"`
|
||||||
|
// ParentRuleID is the parent rule's UUID when that parent is
|
||||||
|
// itself in the returned result set (so the frontend can render
|
||||||
|
// a tree). nil when the parent is outside the returned set.
|
||||||
|
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||||
// whose semantic anchor is an event rather than a parent rule (the
|
// whose semantic anchor is an event rather than a parent rule (the
|
||||||
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
||||||
|
|||||||
Reference in New Issue
Block a user