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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user