Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.
Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.
New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.
Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
804 lines
30 KiB
Go
804 lines
30 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
|
|
courts *CourtService
|
|
}
|
|
|
|
// NewFristenrechnerService wires the service to its dependencies.
|
|
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
|
|
return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
|
|
}
|
|
|
|
// 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
|
|
// CourtID picks the forum the proceeding is filed in (e.g. "upc-ld-paris",
|
|
// "de-bgh"). The calculator resolves it to (country, regime) for non-
|
|
// working-day computation. Empty falls back to UPC München (DE/UPC) for
|
|
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
|
// behaviour for callers that don't yet send a court.
|
|
CourtID 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"`
|
|
Jurisdiction *string `db:"jurisdiction"`
|
|
}
|
|
err = s.rules.db.GetContext(ctx, &pt,
|
|
`SELECT id, code, name, name_en, jurisdiction
|
|
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)
|
|
}
|
|
|
|
// Resolve (country, regime) for non-working-day adjustment. Court wins
|
|
// when supplied; otherwise default by proceeding regime. UPC proceedings
|
|
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
|
|
// DE proceedings default to DE (no supranational regime).
|
|
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
|
country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, 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, country, regime)
|
|
|
|
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
|
|
CourtID string // optional — selects holiday calendar; defaults via proceeding's jurisdiction
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
|
country, regime, err := s.courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
|
|
}
|
|
|
|
endDate := addDuration(triggerDate, durationValue, durationUnit)
|
|
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate, country, regime)
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// CourtID. UPC proceedings get DE+UPC (München LD is HLC's most common
|
|
// venue, German federal holidays plus UPC vacations apply); DE / DPMA / EPA
|
|
// get DE-only (German federal). Future EPA-specific closures will require
|
|
// callers to pick an EPA court explicitly so the EPO regime kicks in.
|
|
//
|
|
// Helper kept tiny and stateless — when a caller passes a real CourtID,
|
|
// these defaults are bypassed entirely and the court's actual country +
|
|
// regime are used.
|
|
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
|
if jurisdiction == nil {
|
|
return CountryDE, ""
|
|
}
|
|
switch *jurisdiction {
|
|
case "UPC":
|
|
return CountryDE, RegimeUPC
|
|
default:
|
|
return CountryDE, ""
|
|
}
|
|
}
|