feat(t-paliad-191): CalcOptions.RuleOverrides + applyRuleOverrides

Phase 3 Slice 11a calculator hook for the rule-editor preview
(design §4.5, Q-H-4 option (a)). CalcOptions gains RuleOverrides
[]models.DeadlineRule. When non-empty, FristenrechnerService.Calculate
substitutes any rule with matching .ID in the rule list with the
override row, and appends overrides whose ID doesn't match an
existing rule (net-new drafts the editor wants to preview).

Wired into:
  - FristenrechnerService.Calculate (proceeding-tree path)
  - FristenrechnerService.calculateByTriggerEvent (Pipeline-C path)

Helper: applyRuleOverrides(src, overrides) — small linear scan since
the override slice is 1 row in practice (the draft being previewed).
Empty overrides → pass-through (existing behaviour unchanged).

No DB writes; pure simulation. The editor's "what would this rule
do?" affordance uses this to preview the draft against the rest of
the proceeding's rules without mutating the live corpus.
This commit is contained in:
mAi
2026-05-15 01:49:43 +02:00
parent 5d22e5db21
commit 358c64d172

View File

@@ -141,6 +141,17 @@ type CalcOptions struct {
// matches paliad.trigger_events.id (bigint, mig 028). See design
// §3.D (calculator unification).
TriggerEventIDFilter *int64
// RuleOverrides substitutes specific rules in the calculator's
// rule list with caller-supplied in-memory rows. Used by the
// rule-editor preview (Slice 11a, t-paliad-191): the admin's
// draft replaces its published peer (matched by rule.ID) so the
// editor sees "what would this rule do?" without writing to the
// DB. Net-new drafts (no draft_of peer) get appended to the rule
// list so their effect lights up on a fresh evaluation.
//
// Empty / nil = no override (default). Overrides apply equally to
// the proceeding-tree and trigger-event branches.
RuleOverrides []models.DeadlineRule
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -240,6 +251,9 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if err != nil {
return nil, err
}
if len(opts.RuleOverrides) > 0 {
rules = applyRuleOverrides(rules, opts.RuleOverrides)
}
// Walk the rule list in sequence_order (already sorted by the query) and
// compute each entry, keeping a code→date map so RelativeTo / parent_id
@@ -969,6 +983,43 @@ func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
}
}
// applyRuleOverrides replaces rules whose ID appears in `overrides`
// with the override row, and appends any override whose ID isn't in
// the source list (net-new drafts the rule editor wants to preview).
//
// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor
// passes the draft as an override so Calculate runs against the
// proposed shape without writing to the DB. Empty overrides slice =
// pass-through (Calculate's existing behaviour for non-preview
// callers). The override slice is small (1 row in practice — the
// draft being previewed) so the linear scan is fine.
func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule {
if len(overrides) == 0 {
return src
}
byID := make(map[uuid.UUID]models.DeadlineRule, len(overrides))
for _, o := range overrides {
byID[o.ID] = o
}
out := make([]models.DeadlineRule, 0, len(src)+len(overrides))
seen := make(map[uuid.UUID]bool, len(overrides))
for _, r := range src {
if ov, ok := byID[r.ID]; ok {
out = append(out, ov)
seen[ov.ID] = true
continue
}
out = append(out, r)
}
for _, o := range overrides {
if seen[o.ID] {
continue
}
out = append(out, o)
}
return out
}
// applyDuration is the unified date-arithmetic helper used by every
// calculator path (Pipeline-A proceeding-tree, Pipeline-C trigger-event,
// CalculateRule single-rule). Phase 3 Slice 4 (t-paliad-185) replaces
@@ -1071,6 +1122,9 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
if err != nil {
return nil, err
}
if len(opts.RuleOverrides) > 0 {
rules = applyRuleOverrides(rules, opts.RuleOverrides)
}
deadlines := make([]UIDeadline, 0, len(rules))
for _, r := range rules {