Files
paliad/internal/services/fristenrechner.go
m b54e938bdf feat(t-paliad-136): Phase B — card-click → calc panel → add to project
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.

Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
  either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
  optional condition flags. Returns rule metadata + computed dueDate +
  originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
  + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
  t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
  Sommerferien skip both apply automatically. Court-determined rules
  (party='court' or event_type ∈ hearing/decision/order) return
  IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
  hasn't supplied the flag — the UI uses this to render checkboxes;
  toggling recomputes live. With all flags satisfied + alt_duration_*
  present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
  and error paths (skipped without TEST_DATABASE_URL).

Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
  card (only one open at a time). Pill picker (radio chips) appears
  when the card has 2+ rule pills; first preselected. Trigger date
  defaults to today (m's Q3). Flag checkboxes auto-render from the
  rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
  shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
  Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
  /api/projects/{id}/deadlines/bulk with a single-element array using
  source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
  drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
  (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
  have a single rule to compute.
- Escape collapses the open card.

CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.

UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
2026-05-05 14:04:54 +02:00

756 lines
27 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
// FristenrechnerService renders the Paliad public Fristenrechner's response
// shape from DB-stored rules. It sits on top of DeadlineRuleService and
// HolidayService and produces the bilingual, rule-code + notes-rich payload
// that /tools/fristenrechner's client expects.
//
// The UI-facing response is distinct from the plain calculator in
// DeadlineCalculator: it adds IsRootEvent, IsCourtSet, RuleRef, Notes,
// party color classes, and keeps the result ordered by sequence_order
// within each proceeding type.
type FristenrechnerService struct {
rules *DeadlineRuleService
holidays *HolidayService
}
// NewFristenrechnerService wires the service to its dependencies.
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService) *FristenrechnerService {
return &FristenrechnerService{rules: rules, holidays: holidays}
}
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
type UIDeadline struct {
RuleID string `json:"ruleId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Party string `json:"party"`
IsMandatory bool `json:"isMandatory"`
RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,omitempty"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"`
OriginalDate string `json:"originalDate"`
WasAdjusted bool `json:"wasAdjusted"`
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsRootEvent bool `json:"isRootEvent"`
IsCourtSet bool `json:"isCourtSet"`
IsOverridden bool `json:"isOverridden,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
type UIResponse struct {
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
var ErrUnknownProceedingType = errors.New("unknown proceeding type")
// CalcOptions carries optional inputs for Calculate. Callers can leave fields
// empty/nil for the legacy behaviour.
//
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt =
// 'priority_date' (e.g. EP_GRANT.ep_grant.publish per Art. 93 EPÜ) use
// this date as their base instead of the parent's adjusted date / the
// trigger date.
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
// "with_amend", "with_cci"). A rule with a non-empty condition_flag
// array renders iff EVERY element of that array is in Flags. When all
// are present AND alt_duration_value is non-NULL, the calculator
// swaps to alt_*; when set + flags not satisfied, the rule is
// suppressed entirely (skipped from the result list).
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
// of the computed deadline date. When a child rule chains off a
// parent whose code is in AnchorOverrides, the override date is
// used as the anchor instead of the parent's calculated date. Lets
// the user set a real court-extended deadline, or a court-set
// decision date once known, and have downstream rules re-flow.
type CalcOptions struct {
PriorityDateStr string
Flags []string
AnchorOverrides map[string]string
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
// Preserves the pre-Phase-C in-memory calculator's classification:
//
// - Rules with duration_value = 0 and no parent_id → IsRootEvent
// (due date = trigger date)
// - Rules with duration_value = 0 and a parent_id → IsCourtSet
// (due date empty, UI shows "court-set" placeholder)
// - All other rules → calculate from either the trigger date (no parent)
// or the previously-computed date for their parent rule.
//
// Audit-driven extensions:
//
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
// (e.g. UPC_INF inf.reply / inf.rejoin under "with_ccr"). When a
// rule's condition_flag array is non-empty, the rule renders iff
// EVERY element is in opts.Flags; rules that fail this gate are
// suppressed entirely (used by Phase B1 cross-flow rules that should
// only appear with their flag).
// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
// set (e.g. EP_GRANT publication date is 18mo from priority, not filing).
// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
// caller redirect a downstream rule's parent anchor to a user-set
// 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) {
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
}
var priorityDate *time.Time
if opts.PriorityDateStr != "" {
pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
if err != nil {
return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
}
priorityDate = &pd
}
flagSet := make(map[string]struct{}, len(opts.Flags))
for _, f := range opts.Flags {
flagSet[f] = struct{}{}
}
// Parse anchor overrides up-front so a malformed date errors out
// before we start walking rules.
overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides))
for code, dateStr := range opts.AnchorOverrides {
od, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err)
}
overrideDates[code] = od
}
// Look up proceeding type metadata.
var pt struct {
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, proceedingCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownProceedingType
}
if err != nil {
return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
}
rules, err := s.rules.List(ctx, &pt.ID)
if err != nil {
return nil, err
}
// 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
// references resolve to the adjusted predecessor date.
computed := make(map[string]time.Time, len(rules))
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]UIDeadline, 0, len(rules))
for _, r := range rules {
// Flag-gate: rule with a non-empty condition_flag array renders
// iff every element is in flagSet. Suppressed rules don't appear
// at all (distinct from the alt-* swap, which still renders).
// Single-element arrays preserve the old "swap to alt" semantic
// when alt_duration_value is non-NULL — see allFlagsSet docs.
if len(r.ConditionFlag) > 0 && !allFlagsSet(r.ConditionFlag, flagSet) {
// When the rule has alt_duration_value, it's a "swap-on-flag"
// rule (legacy with_ccr pattern): always render, just don't
// apply the swap. When alt_duration_value is NULL, the rule
// is purely conditional — suppress entirely.
if r.AltDurationValue == nil {
continue
}
}
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
IsMandatory: r.IsMandatory,
}
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
}
// Propagate court-set status from a parent rule whose date the
// court determines: if the anchor itself has no real date,
// nothing downstream can be computed either — UNLESS the user
// has supplied an override date for the parent (which they can
// once they know the real decision date).
parentOverridden := false
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if _, ok := overrideDates[*prev.Code]; ok {
parentOverridden = true
}
}
break
}
}
}
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden
// Zero-duration rules fall into one of four buckets:
// 1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
// 2. parent=nil, court-determined → IsCourtSet (Zwischenverfahren /
// Mündliche Verhandlung / Entscheidung etc.)
// 3. parent set, court-determined → IsCourtSet (waypoint)
// 4. parent set, NOT court-determined → "filed-with-parent"
// semantic: rule is filed AT THE SAME TIME as its parent
// (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — R.49(2) says
// Application to amend / Counterclaim for infringement are
// INCLUDED in the Defence to revocation). Use the parent's
// computed date.
//
// AnchorOverrides: when the user has set a date for any
// zero-duration rule, that override wins over both the
// court-set placeholder and the parent-inheritance.
if r.DurationValue == 0 {
// User override always wins.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
d.DueDate = ov.Format("2006-01-02")
d.OriginalDate = d.DueDate
d.IsOverridden = true
computed[*r.Code] = ov
deadlines = append(deadlines, d)
continue
}
}
if r.ParentID == nil && !isCourtDeterminedRule(r) {
// Bucket 1: timeline anchor.
d.IsRootEvent = true
d.DueDate = triggerDateStr
d.OriginalDate = triggerDateStr
if r.Code != nil {
computed[*r.Code] = triggerDate
}
} else if r.ParentID != nil && !isCourtDeterminedRule(r) {
// Bucket 4: filed-with-parent. Inherit parent's date.
// If parent is court-set, we have nothing to inherit —
// fall through to court-set marking.
if parentIsCourtSet {
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
} else {
var parentDate time.Time
var haveParentDate bool
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if ov, ok := overrideDates[*prev.Code]; ok {
parentDate = ov
haveParentDate = true
} else if ref, ok := computed[*prev.Code]; ok {
parentDate = ref
haveParentDate = true
}
}
break
}
}
if haveParentDate {
d.DueDate = parentDate.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.Code != nil {
computed[*r.Code] = parentDate
}
} else {
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
}
}
} else {
// Buckets 2 + 3: court-determined.
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
}
deadlines = append(deadlines, d)
continue
}
// If the parent is court-determined and not overridden we have no
// real anchor date; surface this rule as court-set too rather
// than fabricating one off the trigger date. The user can re-run
// with the actual decision date once the court issues it (or
// supplied via AnchorOverrides).
if parentIsCourtSet {
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
deadlines = append(deadlines, d)
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for EP_GRANT publish)
// when supplied, then parent's computed date (or user override),
// then trigger date.
baseDate := triggerDate
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
baseDate = *priorityDate
} else if r.ParentID != nil {
// Linear scan is fine — rule trees are < 20 entries.
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
// User override on the parent rule wins over
// the calculated date — lets the user redirect
// downstream from a real (court-extended,
// court-set) date.
if ov, ok := overrideDates[*prev.Code]; ok {
baseDate = ov
} else if ref, ok := computed[*prev.Code]; ok {
baseDate = ref
}
}
break
}
}
}
// Flag-conditioned alt: when every flag in condition_flag is in
// flagSet AND alt_duration_value is non-NULL, swap to alt_*.
// (Suppression of all-flags-not-set rules already handled above.)
durationValue := r.DurationValue
durationUnit := r.DurationUnit
if len(r.ConditionFlag) > 0 && allFlagsSet(r.ConditionFlag, flagSet) {
if r.AltDurationValue != nil {
durationValue = *r.AltDurationValue
}
if r.AltDurationUnit != nil {
durationUnit = *r.AltDurationUnit
}
if r.AltRuleCode != nil {
d.RuleRef = *r.AltRuleCode
}
}
// User override on this rule: replace the calculated date with
// the user's date. Skip holiday rollover — the user's date is
// authoritative. Downstream rules that chain off this rule will
// see the override via the parent-anchor lookup above.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
d.OriginalDate = ov.Format("2006-01-02")
d.DueDate = ov.Format("2006-01-02")
d.WasAdjusted = false
d.AdjustmentReason = nil
d.IsOverridden = true
computed[*r.Code] = ov
deadlines = append(deadlines, d)
continue
}
}
endDate := addDuration(baseDate, durationValue, durationUnit)
origDate := endDate
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
d.OriginalDate = origDate.Format("2006-01-02")
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
if r.Code != nil {
computed[*r.Code] = adjusted
}
deadlines = append(deadlines, d)
}
return &UIResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
}
// ErrUnknownRule is returned when CalculateRule can't resolve the
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
var ErrUnknownRule = errors.New("unknown rule")
// CalcRuleParams identifies a single rule and the inputs needed to
// compute one deadline from it. Caller supplies either RuleID OR the
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
// hand from the concept-card pill it just received a click on.
type CalcRuleParams struct {
RuleID string // optional — UUID
ProceedingCode string // optional — used with RuleLocalCode
RuleLocalCode string // optional — paliad.deadline_rules.code
TriggerDate string // required — YYYY-MM-DD
Flags []string // optional — condition_flag inputs
}
// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc
// response that backs the result-card click → calc-panel flow. Distinct
// from UIDeadline (which represents one rendered timeline row inside a
// full-proceeding response): RuleCalculation is self-contained — caller
// gets the rule metadata + the computed date in one payload, no separate
// proceeding-types lookup needed.
//
// Trigger semantics: TriggerDate is the immediate parent event's
// effective date — i.e. when the user clicks "Duplik" in the card and
// types "2026-05-05", they mean "I received the Replik on 2026-05-05".
// We do NOT walk the parent chain; callers wanting the full timeline
// for a proceeding still go through Calculate.
type RuleCalculation struct {
Rule RuleCalculationRule `json:"rule"`
Proceeding RuleCalculationProceeding `json:"proceeding"`
TriggerDate string `json:"triggerDate"`
OriginalDate string `json:"originalDate"`
DueDate string `json:"dueDate"`
WasAdjusted bool `json:"wasAdjusted"`
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsCourtSet bool `json:"isCourtSet"`
// FlagsApplied lists the condition_flag values from the rule that
// the caller's Flags satisfied. Empty when the rule has no
// condition_flag, OR when the caller didn't satisfy the gate. Lets
// the frontend show "Mit Nichtigkeitswiderklage angewandt" hints.
FlagsApplied []string `json:"flagsApplied,omitempty"`
// FlagsRequired is the rule's condition_flag in canonical order so
// the frontend can render checkboxes for each flag the rule gates on.
FlagsRequired []string `json:"flagsRequired,omitempty"`
}
// RuleCalculationRule mirrors the small subset of DeadlineRule the
// frontend needs to render the calc panel.
type RuleCalculationRule struct {
ID string `json:"id"`
LocalCode string `json:"localCode,omitempty"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
RuleRef string `json:"ruleRef,omitempty"`
LegalSource string `json:"legalSource,omitempty"`
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
DurationValue int `json:"durationValue"`
DurationUnit string `json:"durationUnit"`
Party string `json:"party,omitempty"`
IsMandatory bool `json:"isMandatory"`
NotesDE string `json:"notesDE,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
}
// RuleCalculationProceeding identifies the proceeding context for the
// rule. Used by the frontend for display + by the add-to-project flow.
type RuleCalculationProceeding struct {
Code string `json:"code"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
}
// CalculateRule computes a single deadline from a rule + trigger date.
// Used by the v4 result-card click flow. Distinct from Calculate: no
// parent-chain walk, no full-timeline rendering — just one date out.
//
// When the rule is court-determined (primary_party='court' or event_type
// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true;
// the caller should disable the "Add to project" CTA in that case.
//
// When the rule has condition_flag and the caller's Flags satisfy every
// element AND alt_duration_value is non-NULL, the calc swaps to alt_*
// (matches the existing flag-conditional semantics in Calculate).
//
// When the rule has condition_flag and the caller's Flags do NOT satisfy
// every element, the calc still proceeds with the base duration_value
// and surfaces FlagsRequired so the frontend can render the gating
// checkboxes. The result IS the date the rule would be due if the user
// confirmed the flag — letting the user toggle the checkbox and see the
// duration change live.
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) {
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
}
rule, pt, err := s.resolveRule(ctx, params)
if err != nil {
return nil, err
}
out := &RuleCalculation{
Rule: RuleCalculationRule{
ID: rule.ID.String(),
NameDE: rule.Name,
NameEN: rule.NameEN,
DurationValue: rule.DurationValue,
DurationUnit: rule.DurationUnit,
IsMandatory: rule.IsMandatory,
},
Proceeding: RuleCalculationProceeding{
Code: pt.Code,
NameDE: pt.Name,
NameEN: pt.NameEN,
},
TriggerDate: params.TriggerDate,
}
if rule.Code != nil {
out.Rule.LocalCode = *rule.Code
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
}
if rule.LegalSource != nil {
out.Rule.LegalSource = *rule.LegalSource
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
}
if rule.PrimaryParty != nil {
out.Rule.Party = *rule.PrimaryParty
}
if rule.DeadlineNotes != nil {
out.Rule.NotesDE = *rule.DeadlineNotes
}
if rule.DeadlineNotesEn != nil {
out.Rule.NotesEN = *rule.DeadlineNotesEn
}
if len(rule.ConditionFlag) > 0 {
out.FlagsRequired = []string(rule.ConditionFlag)
}
// Court-determined: no calculable date.
if isCourtDeterminedRule(*rule) {
out.IsCourtSet = true
return out, nil
}
// Resolve flag-conditional duration. Same semantics as Calculate
// (services/fristenrechner.go:368): all flags satisfied + alt
// values present → swap; otherwise use base values.
flagSet := make(map[string]struct{}, len(params.Flags))
for _, f := range params.Flags {
flagSet[f] = struct{}{}
}
durationValue := rule.DurationValue
durationUnit := rule.DurationUnit
if len(rule.ConditionFlag) > 0 && allFlagsSet(rule.ConditionFlag, flagSet) {
out.FlagsApplied = []string(rule.ConditionFlag)
if rule.AltDurationValue != nil {
durationValue = *rule.AltDurationValue
}
if rule.AltDurationUnit != nil {
durationUnit = *rule.AltDurationUnit
}
if rule.AltRuleCode != nil {
out.Rule.RuleRef = *rule.AltRuleCode
}
}
// Zero-duration non-court-determined rules are "filed at the same
// time as parent" markers (UPC_REV.app_to_amend, UPC_REV.cc_inf):
// effectively mean "due on the trigger date itself". The card-click
// flow doesn't need to surface those as a calc panel — but if it
// does, returning the trigger date is the right answer.
if durationValue == 0 {
out.OriginalDate = params.TriggerDate
out.DueDate = params.TriggerDate
return out, nil
}
endDate := addDuration(triggerDate, durationValue, durationUnit)
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
out.OriginalDate = endDate.Format("2006-01-02")
out.DueDate = adjusted.Format("2006-01-02")
out.WasAdjusted = wasAdj
out.AdjustmentReason = reason
return out, nil
}
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
// frontend uses the latter form (it has the pill context) and the
// programmatic / test caller can use the former.
func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) {
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
}
const ptCols = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`
var rule models.DeadlineRule
var pt models.ProceedingType
if params.RuleID != "" {
err := s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE id = $1 AND is_active = true`, params.RuleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
}
if err != nil {
return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err)
}
if rule.ProceedingTypeID == nil {
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT `+ptCols+`
FROM paliad.proceeding_types
WHERE id = $1`, *rule.ProceedingTypeID)
if err != nil {
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
}
return &rule, &pt, nil
}
err := s.rules.db.GetContext(ctx, &pt,
`SELECT `+ptCols+`
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, params.ProceedingCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownProceedingType
}
if err != nil {
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err)
}
err = s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
pt.ID, params.RuleLocalCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
}
if err != nil {
return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err)
}
return &rule, &pt, nil
}
// ListFristenrechnerTypes returns the proceeding types that populate the
// Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order.
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) {
rows, err := s.rules.db.QueryxContext(ctx, `
SELECT code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND is_active = true
ORDER BY sort_order`)
if err != nil {
return nil, fmt.Errorf("list fristenrechner types: %w", err)
}
defer rows.Close()
var out []FristenrechnerType
for rows.Next() {
var t FristenrechnerType
var juris sql.NullString
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
return nil, err
}
if juris.Valid {
t.Group = juris.String
}
out = append(out, t)
}
return out, rows.Err()
}
// FristenrechnerType mirrors the /api/tools/proceeding-types response metadata.
type FristenrechnerType struct {
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Group string `json:"group"`
}
// isCourtDeterminedRule returns true when a deadline rule represents an
// event the court (not a party) sets the date for — Zwischenverfahren,
// Mündliche Verhandlung, Entscheidung, Beschluss, etc. These have no
// statutory deadline that can be calculated; the date depends on the
// court's docket and is only known once the court communicates it.
//
// Discriminator: primary_party = 'court' OR event_type ∈ {hearing,
// decision, order}. Both signals are populated by migration 012; we
// accept either so future rules don't have to set both to be detected.
func isCourtDeterminedRule(r models.DeadlineRule) bool {
if r.PrimaryParty != nil && *r.PrimaryParty == "court" {
return true
}
if r.EventType != nil {
switch *r.EventType {
case "hearing", "decision", "order":
return true
}
}
return false
}
// allFlagsSet returns true when every element of `required` is present in
// `set`. Empty `required` returns true (no condition). Used by the
// flag-conditional rule machinery to decide whether to apply a rule's
// alt_* swap (legacy single-flag with_ccr pattern still works because a
// single-element array {"with_ccr"} matches iff "with_ccr" is set).
func allFlagsSet(required []string, set map[string]struct{}) bool {
for _, f := range required {
if _, ok := set[f]; !ok {
return false
}
}
return true
}
// addDuration adds a signed duration value/unit to a base date.
func addDuration(base time.Time, value int, unit string) time.Time {
switch unit {
case "days":
return base.AddDate(0, 0, value)
case "weeks":
return base.AddDate(0, 0, value*7)
case "months":
return base.AddDate(0, value, 0)
default:
return base
}
}