Files
paliad/internal/services/fristenrechner.go
m 78966ec098 feat(t-paliad-131): Phase A — concept layer + AnchorOverrides + click-to-edit dates
PR-1 of the Unified Fristenrechner. Purely additive: new search-grouping
layer + per-rule date override capability. No coverage changes yet
(those land in PR-2 = Phase B1 UPC counterclaim cross-flows).

Migrations:
- 037: paliad.deadline_concepts (id, slug, name_de/en, aliases text[],
  party, category, sort_order). Trigram + GIN indexes for the search bar.
- 038: deadline_rules.concept_id (uuid FK), legal_source (text);
  event_deadlines.legal_source; trigger_events.concept_id (text slug,
  soft-link — youpc imports keep their bigint PK).
- 039: deadline_rules.condition_flag text → text[] (USING ARRAY[old]).
  Semantic: rule renders iff every element is in CalcOptions.Flags.
  Single-element arrays preserve the legacy with_ccr swap exactly.
- 040: seed 30 concept rows + backfill all 74 fristenrechner deadline_rules
  with concept_id; backfill legal_source from existing rule_code
  (e.g. 'RoP.023' → 'UPC.RoP.23.1', '§ 276 ZPO' → 'DE.ZPO.276.1',
  'Art. 108 EPÜ' → 'EU.EPÜ.108', 'R. 79(1) EPÜ' → 'EU.EPC-R.79.1').

Calculator (services/fristenrechner.go):
- ConditionFlag is now pq.StringArray (matches text[] schema). New
  allFlagsSet() helper gates rule rendering; rules with multi-element
  flags require ALL of them set (prep for Phase B1 with_amend ∧ with_cci).
- CalcOptions.AnchorOverrides map[string]string (rule_code → YYYY-MM-DD).
  The tree-walk consults overrideDates[parent.code] before reading the
  computed-date map; lets a downstream rule re-anchor on a user-set date.
- IsCourtSet rows that get an override stop being placeholder and emit
  the user's date as a real anchor (so downstream cost_app etc. compute
  off it). New IsOverridden flag in UIDeadline so the UI can highlight
  user-edited rows.
- LegalSource surfaced on UIDeadline for future search-card display.

UI (frontend/src/client/fristenrechner.ts + global.css + i18n):
- Each timeline / column rule date is click-to-edit. Click → inline
  date input → blur or Enter → POST with anchorOverrides → re-render.
  Empty value clears the override. Escape cancels. Root-event rows
  (the trigger anchor) stay non-editable — that's the trigger-date input.
- Override map cleared on proceeding switch / reset; persists across
  trigger-date / flag toggle changes within the same proceeding.
- New CSS: subtle hover underline on .frist-date-edit; lime border on
  .timeline-date--overridden + .frist-date-edit-input.
- New i18n key deadlines.date.edit.hint (DE + EN).

Handler (handlers/fristenrechner.go):
- POST body gains optional anchorOverrides map<string,string>; passed
  through to CalcOptions.

Tests:
- TestAllFlagsSet covers single-flag legacy semantic, two-flag AND
  semantic, empty-required unconditional, extra-flags-no-effect.
- Existing TestIsCourtDeterminedRule unchanged.

Phase A ships standalone — Phase B1 (UPC counterclaim cross-flows) and
Phase C/D (search backend + concept-card UI) follow.
2026-05-05 00:05:12 +02:00

468 lines
16 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 either anchor the timeline (trigger date) or
// represent court-set waypoints with no calculable date. The court
// path covers two flavours:
// 1. zero-duration with a parent_id (waypoint chained off another
// rule, original behaviour).
// 2. zero-duration with no parent but flagged as a court-driven
// event (Zwischenverfahren / Mündliche Verhandlung /
// Entscheidung etc.) — without this, those rendered as
// IsRootEvent and emitted the trigger date as their own date,
// which then leaked into any downstream rule that chained off
// them (e.g. RoP.151 Antrag auf Kostenentscheidung).
//
// AnchorOverrides: when the user has set a date for this court-set
// rule, surface it as a real date and propagate it as the anchor
// for downstream rules.
if r.DurationValue == 0 {
if r.ParentID == nil && !isCourtDeterminedRule(r) {
d.IsRootEvent = true
d.DueDate = triggerDateStr
d.OriginalDate = triggerDateStr
if r.Code != nil {
computed[*r.Code] = triggerDate
}
} else if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
// User has filled in this court-set date; treat as a
// real anchor. Don't apply holiday adjustment — the
// user's date is authoritative, even if it lands on
// a non-working day (real-world example: a court
// decision can be issued on a Saturday).
d.DueDate = ov.Format("2006-01-02")
d.OriginalDate = d.DueDate
d.IsOverridden = true
computed[*r.Code] = ov
} else {
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
}
} else {
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
}
// 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
}
}