Phase 3 Slice 9 Go cleanup. With mig 091's column drops live, the
service layer stops reading + emitting the legacy shape:
- models.DeadlineRule: drop IsMandatory, IsOptional, ConditionFlag,
ConditionRuleID fields. Comment block flags Slice 9 as the
closeout slice.
- DeadlineRuleService.ruleColumns: SELECT no longer enumerates the
dropped columns. The post-Slice-9 schema is the live shape.
- FristenrechnerService.UIDeadline: drops IsMandatory + IsOptional
fields. Frontend reads `priority` directly post-Slice-8; the
legacy emit was kept "for one release" and that release is now.
- evalConditionExpr signature: drops the conditionFlag fallback
param. NULL / "null" expressions return true (unconditional);
the legacy text[] fallback was the only reason for the second
param. New helpers hasConditionExpr + extractFlagsFromExpr fill
the gaps (alt-swap guard + RuleCalculation.FlagsRequired list).
- FristenrechnerService.Calculate + calculateByTriggerEvent +
EventTriggerService.Trigger: switched to the new (single-arg)
evalConditionExpr; alt-swap guard now uses
hasConditionExpr(r.ConditionExpr) instead of the dropped
len(r.ConditionFlag) > 0 check.
- FristenrechnerService.CalculateRule: RuleCalculationRule.IsMandatory
derived from priority via wireFlagsFromPriority (kept for the
result-card panel TS contract). FlagsRequired walks the jsonb
gate tree to enumerate {"flag":"X"} leaves (replaces the
dropped condition_flag enumeration).
- RuleEditorService.Create + CloneAsDraft INSERT statements:
dropped is_mandatory / is_optional / condition_flag from the
column lists. Live shape only.
Test fixtures (projection_service_test.go, rule_editor_service_test.go,
fristenrechner_test.go) all updated to write the live shape on
seed; the evalConditionExpr table-driven test dropped its legacy
fallback cases (the fallback no longer exists) and now exercises
20 pure-jsonb scenarios across AND/OR/NOT compositions.
The deadline_rule_service_test backfill assertion lost its
(is_mandatory, is_optional) bucket cross-check (those columns are
gone); the priority-non-NULL invariant still holds via the CHECK
constraint. condition_flag cross-check now joins the pre-mig-091
snapshot table (when present) instead of the live row.
291 lines
9.7 KiB
Go
291 lines
9.7 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// EventTriggerService backs POST /api/tools/event-trigger — Phase 3
|
|
// Slice 6 (t-paliad-187, design §5). Given an event-type or a concept
|
|
// (or both), it discovers the deadline rules triggered by the input
|
|
// and computes their dates via the unified Phase-3 helpers
|
|
// (applyDuration + evalConditionExpr).
|
|
//
|
|
// Distinct from the legacy /api/tools/event-deadlines surface (which
|
|
// is keyed exclusively on paliad.trigger_events bigints): this
|
|
// endpoint accepts either UUID paliad.event_types.id (Pipeline-C
|
|
// rules, via the trigger_event_id bridge on event_types) OR UUID
|
|
// paliad.deadline_concepts.id (Pipeline-A rules linked via the
|
|
// concept_id FK on deadline_rules). When both are passed the
|
|
// resulting rule set is UNIONed and deduped by rule.id.
|
|
//
|
|
// Distinct from FristenrechnerService.Calculate (proceeding-tree):
|
|
// no parent_id chain walk, no IsRootEvent / IsCourtSet
|
|
// classification, no AnchorOverrides — rules fire flat off the
|
|
// trigger date. The math, gate evaluation, and party-perspective
|
|
// filter all reuse Slice-4's unified helpers so the response shape
|
|
// stays calibrated against the proceeding-tree calculator.
|
|
type EventTriggerService struct {
|
|
db *sqlx.DB
|
|
rules *DeadlineRuleService
|
|
holidays *HolidayService
|
|
courts *CourtService
|
|
}
|
|
|
|
// NewEventTriggerService wires the service to its dependencies.
|
|
func NewEventTriggerService(db *sqlx.DB, rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *EventTriggerService {
|
|
return &EventTriggerService{db: db, rules: rules, holidays: holidays, courts: courts}
|
|
}
|
|
|
|
// EventTriggerInput is the parsed request body. At least one of
|
|
// EventTypeID / ConceptID must be set (validated in Trigger).
|
|
type EventTriggerInput struct {
|
|
// EventTypeID resolves through paliad.event_types.id →
|
|
// trigger_event_id (bigint) → SELECT deadline_rules WHERE
|
|
// trigger_event_id matches. Nil = no event-type leg.
|
|
EventTypeID *uuid.UUID
|
|
// ConceptID matches deadline_rules.concept_id directly (the
|
|
// Pipeline-A cascade leaf semantic that the result-card click
|
|
// flow uses). Nil = no concept leg.
|
|
ConceptID *uuid.UUID
|
|
// TriggerDate is the anchor for the calculator. Required.
|
|
// Format: YYYY-MM-DD.
|
|
TriggerDate string
|
|
// Flags is the caller's flag set used by evalConditionExpr to
|
|
// gate / swap rules (e.g. with_ccr → alt-swap on flag-met).
|
|
Flags []string
|
|
// CourtID picks the (country, regime) tuple for non-working-day
|
|
// arithmetic. Empty falls back to DE / UPC (UPC München default).
|
|
CourtID string
|
|
// Perspective filters opposing-side rules out of the response.
|
|
// Empty = no filter (return rules for every party).
|
|
Perspective string
|
|
}
|
|
|
|
// Trigger discovers rules and computes their deadlines, returning
|
|
// the same UIResponse shape as FristenrechnerService.Calculate so
|
|
// the frontend can render with one renderer. Mutates no state.
|
|
func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInput) (*UIResponse, error) {
|
|
if input.EventTypeID == nil && input.ConceptID == nil {
|
|
return nil, fmt.Errorf("%w: event_type_id or concept_id required", ErrInvalidInput)
|
|
}
|
|
|
|
triggerDate, err := time.Parse("2006-01-02", input.TriggerDate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: trigger_date must be YYYY-MM-DD (got %q)", ErrInvalidInput, input.TriggerDate)
|
|
}
|
|
|
|
// Pipeline-C rules originate from the UPC-flavoured corpus —
|
|
// default DE / UPC for the holiday calendar so this surface
|
|
// matches EventDeadlineService.Calculate's behaviour when the
|
|
// caller doesn't pick a specific court.
|
|
country, regime, err := s.courts.CountryRegime(input.CourtID, CountryDE, RegimeUPC)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", input.CourtID, err)
|
|
}
|
|
|
|
rules, err := s.discoverRules(ctx, input.EventTypeID, input.ConceptID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
flagSet := make(map[string]struct{}, len(input.Flags))
|
|
for _, f := range input.Flags {
|
|
flagSet[f] = struct{}{}
|
|
}
|
|
|
|
deadlines := make([]UIDeadline, 0, len(rules))
|
|
for _, r := range rules {
|
|
if !matchesPerspective(r.PrimaryParty, input.Perspective) {
|
|
continue
|
|
}
|
|
|
|
gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
|
if !gateMet && r.AltDurationValue == nil {
|
|
continue
|
|
}
|
|
|
|
timing := ""
|
|
if r.Timing != nil {
|
|
timing = *r.Timing
|
|
}
|
|
|
|
// Legacy alt-swap (flag-keyed) is mutually exclusive with
|
|
// combine_op composite in the live corpus; the same guard
|
|
// FristenrechnerService.Calculate uses applies here.
|
|
durationValue := r.DurationValue
|
|
durationUnit := r.DurationUnit
|
|
if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
|
durationValue = *r.AltDurationValue
|
|
if r.AltDurationUnit != nil {
|
|
durationUnit = *r.AltDurationUnit
|
|
}
|
|
}
|
|
|
|
origDate, adjusted, wasAdj, reason := applyDuration(
|
|
triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays,
|
|
)
|
|
|
|
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
|
altOrig, altAdj, altWasAdj, altReason := applyDuration(
|
|
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
|
|
)
|
|
switch *r.CombineOp {
|
|
case "max":
|
|
if altAdj.After(adjusted) {
|
|
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
|
}
|
|
case "min":
|
|
if altAdj.Before(adjusted) {
|
|
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slice 9 (t-paliad-195): Priority is the canonical wire signal.
|
|
// Legacy IsMandatory/IsOptional fields dropped from UIDeadline
|
|
// along with the underlying column drop.
|
|
d := UIDeadline{
|
|
RuleID: r.ID.String(),
|
|
Name: r.Name,
|
|
NameEN: r.NameEN,
|
|
Priority: r.Priority,
|
|
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
|
IsCourtSet: r.IsCourtSet,
|
|
DueDate: adjusted.Format("2006-01-02"),
|
|
OriginalDate: origDate.Format("2006-01-02"),
|
|
WasAdjusted: wasAdj,
|
|
AdjustmentReason: reason,
|
|
}
|
|
if r.Code != nil {
|
|
d.Code = *r.Code
|
|
}
|
|
if r.PrimaryParty != nil {
|
|
d.Party = *r.PrimaryParty
|
|
}
|
|
if r.RuleCode != nil {
|
|
d.RuleRef = *r.RuleCode
|
|
}
|
|
if r.LegalSource != nil {
|
|
d.LegalSource = *r.LegalSource
|
|
}
|
|
if r.DeadlineNotes != nil {
|
|
d.Notes = *r.DeadlineNotes
|
|
}
|
|
if r.DeadlineNotesEn != nil {
|
|
d.NotesEN = *r.DeadlineNotesEn
|
|
}
|
|
// Court-set rules surface IsCourtSet=true and clear the
|
|
// computed date — matches the proceeding-tree calculator's
|
|
// "wird vom Gericht bestimmt" rendering.
|
|
if r.IsCourtSet {
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
d.WasAdjusted = false
|
|
d.AdjustmentReason = nil
|
|
}
|
|
deadlines = append(deadlines, d)
|
|
}
|
|
|
|
return &UIResponse{
|
|
// Event-trigger responses don't carry proceeding metadata —
|
|
// the caller already has the event_type / concept context
|
|
// (they're in the request). Leaving these empty is the
|
|
// stable contract; FristenrechnerService.calculateByTriggerEvent
|
|
// (the Pipeline-C delegate) does the same.
|
|
ProceedingType: "",
|
|
ProceedingName: "",
|
|
TriggerDate: input.TriggerDate,
|
|
Deadlines: deadlines,
|
|
}, nil
|
|
}
|
|
|
|
// discoverRules returns the UNION of rules triggered by the
|
|
// event-type and concept inputs, deduped by rule.id. Either input
|
|
// may be nil — the corresponding branch is skipped.
|
|
func (s *EventTriggerService) discoverRules(ctx context.Context, eventTypeID, conceptID *uuid.UUID) ([]models.DeadlineRule, error) {
|
|
seen := make(map[uuid.UUID]struct{})
|
|
out := make([]models.DeadlineRule, 0, 16)
|
|
|
|
if eventTypeID != nil {
|
|
// event_types.trigger_event_id is nullable on the column but
|
|
// every active row in the corpus today carries a bigint here
|
|
// (the row is the bridge to the Pipeline-C corpus). NULL is
|
|
// possible for future hand-edited event_types; treat as "no
|
|
// rules triggered" rather than an error.
|
|
var triggerEventID sql.NullInt64
|
|
err := s.db.GetContext(ctx, &triggerEventID,
|
|
`SELECT trigger_event_id
|
|
FROM paliad.event_types
|
|
WHERE id = $1 AND archived_at IS NULL`, *eventTypeID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: event_type_id=%s not found", ErrInvalidInput, *eventTypeID)
|
|
}
|
|
return nil, fmt.Errorf("lookup event_type: %w", err)
|
|
}
|
|
if triggerEventID.Valid {
|
|
byTrigger, err := s.rules.ListByTriggerEvent(ctx, triggerEventID.Int64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range byTrigger {
|
|
if _, ok := seen[r.ID]; ok {
|
|
continue
|
|
}
|
|
seen[r.ID] = struct{}{}
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
if conceptID != nil {
|
|
byConcept, err := s.rules.ListByConcept(ctx, *conceptID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range byConcept {
|
|
if _, ok := seen[r.ID]; ok {
|
|
continue
|
|
}
|
|
seen[r.ID] = struct{}{}
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// matchesPerspective returns true iff a rule whose primary_party is
|
|
// `party` (may be nil/empty) should render under the given
|
|
// perspective filter. Empty perspective passes everything through.
|
|
// Rules without a party (NULL primary_party) always render — the
|
|
// caller didn't ask the system to take a side for these.
|
|
//
|
|
// The drop-only-on-explicit-mismatch policy keeps 'both' / 'court'
|
|
// / NULL rules visible and only filters claimant↔defendant pairs.
|
|
func matchesPerspective(party *string, perspective string) bool {
|
|
if perspective == "" || party == nil {
|
|
return true
|
|
}
|
|
switch perspective {
|
|
case "claimant":
|
|
return *party != "defendant"
|
|
case "defendant":
|
|
return *party != "claimant"
|
|
default:
|
|
// Unknown perspective: pass-through. Phase 3 Slice 8 will
|
|
// surface the allowed set; until then the API is forgiving.
|
|
return true
|
|
}
|
|
}
|