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
|
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.
|
// ListProceedingTypes returns active proceeding types ordered by sort_order.
|
||||||
func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) {
|
func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) {
|
||||||
var types []models.ProceedingType
|
var types []models.ProceedingType
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ type CalcOptions struct {
|
|||||||
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
||||||
// behaviour for callers that don't yet send a court.
|
// behaviour for callers that don't yet send a court.
|
||||||
CourtID string
|
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.
|
// 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
|
// date. Used for court-extended deadlines and for entering
|
||||||
// court-set decision dates post-hoc.
|
// court-set decision dates post-hoc.
|
||||||
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
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)
|
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
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
|
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||||
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
|
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
|
||||||
// holiday lookup should default to when the caller didn't pass an explicit
|
// holiday lookup should default to when the caller didn't pass an explicit
|
||||||
|
|||||||
Reference in New Issue
Block a user