feat(t-paliad-184): FristenrechnerService.calculateByTriggerEvent

Phase 3 Slice 3 calculator-side rewire. Adds the Pipeline-C branch
to FristenrechnerService so the unified backend can serve
event-driven deadlines:

  - CalcOptions.TriggerEventIDFilter *int64 — when non-nil, Calculate
    dispatches to calculateByTriggerEvent (proceedingCode ignored).
  - calculateByTriggerEvent — flat-rule calculator: SELECT rules
    WHERE trigger_event_id = X, compute each via the new
    applyDurationOnCalendar helper (handles timing='before',
    working_days, combine_op alt-leg max/min). No parent_id chains,
    no flag gating, no IsRootEvent / IsCourtSet semantics — those
    are Pipeline-A concerns.
  - applyDurationOnCalendar + addWorkingDays — package-level helpers
    that the proceeding-tree calculator's existing addDuration
    doesn't cover. Slice 4 will fold them into a single unified
    helper when the proceeding-tree side also reads timing +
    working_days from the unified rule shape.
  - DeadlineRuleService.ListByTriggerEvent — SELECT rules scoped to
    a single trigger_event_id, ORDER BY sequence_order (preserves
    the 1000 + ed.id ordering mig 085 wrote). Skips
    hydrateConceptDefaultEventTypes since Pipeline-C rules don't
    carry concept_id today.

UIResponse for trigger-event calls returns empty ProceedingType /
ProceedingName — EventDeadlineService owns the trigger metadata in
the legacy CalculateResponse shape. That's a stable contract for
the caller and avoids polluting UIResponse with trigger-event-only
fields.
This commit is contained in:
mAi
2026-05-15 00:41:10 +02:00
parent ee2caf9d79
commit 5f9a8b2ef4
2 changed files with 227 additions and 0 deletions

View File

@@ -211,6 +211,30 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
return rules, nil
}
// ListByTriggerEvent returns active rules scoped to a single trigger
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
// no parent_id chain.
//
// Distinct from List: List filters by proceeding_type_id and runs
// hydrateConceptDefaultEventTypes (which assumes a proceeding-type FK).
// Pipeline-C rules don't have that FK, so hydration is skipped here.
//
// Order by sequence_order so the data-move's (1000 + ed.id) offset
// preserves the original event_deadlines.id ordering.
func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE trigger_event_id = $1
AND is_active = true
ORDER BY sequence_order`, triggerEventID); err != nil {
return nil, fmt.Errorf("list deadline rules by trigger_event_id=%d: %w", triggerEventID, err)
}
return rules, nil
}
// ListProceedingTypes returns active proceeding types ordered by sort_order.
func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) {
var types []models.ProceedingType

View File

@@ -110,6 +110,15 @@ type CalcOptions struct {
// UPC-flavoured proceedings, DE for everything else — preserves legacy
// behaviour for callers that don't yet send a court.
CourtID string
// TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
// rules: when non-nil, the proceedingCode argument is ignored and the
// service selects rules WHERE trigger_event_id = *TriggerEventIDFilter
// instead of WHERE proceeding_type_id = .... Set by
// EventDeadlineService.Calculate so the unified backend can serve the
// "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width
// matches paliad.trigger_events.id (bigint, mig 028). See design
// §3.D (calculator unification).
TriggerEventIDFilter *int64
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -137,6 +146,16 @@ type CalcOptions struct {
// date. Used for court-extended deadlines and for entering
// court-set decision dates post-hoc.
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
// branch (Pipeline-C unified rules; mig 085 moved 77 rows out of
// paliad.event_deadlines into paliad.deadline_rules carrying a
// non-NULL trigger_event_id). proceedingCode is ignored on this
// path. EventDeadlineService.Calculate is the sole caller today;
// future "event-trigger" surfaces (design §5) plug in here too.
if opts.TriggerEventIDFilter != nil {
return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts)
}
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
@@ -817,6 +836,190 @@ func addDuration(base time.Time, value int, unit string) time.Time {
}
}
// applyDurationOnCalendar is the Pipeline-C calculator's per-leg helper.
// Returns (raw, adjusted, didAdjust):
//
// - raw is the date pre-rollover (what the rule strictly says).
// - adjusted is the date after weekend / holiday rollover for calendar
// units (days, weeks, months). 'working_days' lands on a working day
// by construction, so raw == adjusted there.
// - didAdjust is true when the rollover moved the date.
//
// timing='before' negates the sign. Both 'before' and 'working_days' are
// exclusive to Pipeline C in today's corpus; the legacy proceeding-tree
// path (addDuration) doesn't need them. Slice 4 will collapse the two
// helpers into one when the proceeding-tree calculator also reads timing
// + working_days from the unified rule shape.
func applyDurationOnCalendar(
base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService,
) (raw, adjusted time.Time, didAdjust bool) {
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.
return raw, raw, false
default:
raw = base
}
adjusted, _, didAdjust = holidays.AdjustForNonWorkingDays(raw, country, regime)
return raw, adjusted, didAdjust
}
// addWorkingDays advances from `from` by `n` working days, skipping
// weekends and holidays applicable to the given country/regime. Negative
// n walks backward. n=0 keeps the input date as-is (caller decides
// whether to roll forward via AdjustForNonWorkingDays).
//
// Bounded by an inner 30-step skip per advance — vacation runs in our
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
if n == 0 {
return from
}
step := 1
if n < 0 {
step = -1
n = -n
}
cur := from
for i := 0; i < n; i++ {
cur = cur.AddDate(0, 0, step)
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
cur = cur.AddDate(0, 0, step)
}
}
return cur
}
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
// chains), have no flag gating, no priority_date alt-anchor, no party
// classification, and no IsRootEvent / IsCourtSet semantics. The math
// is just: base + (timing-signed) duration → optional alt-leg combine
// → optional weekend/holiday rollover for calendar units.
//
// UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService
// owns the trigger-event metadata (it's the caller that needed it
// pre-Slice-3 and continues to load it for the legacy CalculateResponse
// shape). Callers that don't need those fields can ignore them.
func (s *FristenrechnerService) calculateByTriggerEvent(
ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions,
) (*UIResponse, error) {
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
}
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
country, regime, err := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
if err != nil {
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
}
rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID)
if err != nil {
return nil, err
}
deadlines := make([]UIDeadline, 0, len(rules))
for _, r := range rules {
timing := ""
if r.Timing != nil {
timing = *r.Timing
}
baseRaw, baseAdj, baseChanged := applyDurationOnCalendar(
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, s.holidays,
)
picked := baseAdj
original := baseRaw
wasAdj := baseChanged
var reason *AdjustmentReason
if wasAdj {
// Re-compute with the reason variant when the rollover fired
// so the UI can show "Wochenende → Montag" etc. Cheaper than
// a second full applyDuration call: just re-roll the same raw.
_, _, _, reason = s.holidays.AdjustForNonWorkingDaysWithReason(baseRaw, country, regime)
}
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
altRaw, altAdj, altChanged := applyDurationOnCalendar(
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
)
switch *r.CombineOp {
case "max":
if altAdj.After(baseAdj) {
picked, original, wasAdj = altAdj, altRaw, altChanged
reason = nil
if altChanged {
_, _, _, reason = s.holidays.AdjustForNonWorkingDaysWithReason(altRaw, country, regime)
}
}
case "min":
if altAdj.Before(baseAdj) {
picked, original, wasAdj = altAdj, altRaw, altChanged
reason = nil
if altChanged {
_, _, _, reason = s.holidays.AdjustForNonWorkingDaysWithReason(altRaw, country, regime)
}
}
}
}
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
IsMandatory: r.IsMandatory,
IsOptional: r.IsOptional,
DueDate: picked.Format("2006-01-02"),
OriginalDate: original.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
}
deadlines = append(deadlines, d)
}
return &UIResponse{
// Trigger-event responses don't carry proceeding metadata —
// EventDeadlineService.Calculate fills the trigger fields in the
// legacy CalculateResponse shape. Leaving these empty is the
// stable contract.
ProceedingType: "",
ProceedingName: "",
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
}
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
// holiday lookup should default to when the caller didn't pass an explicit