343 lines
11 KiB
Go
343 lines
11 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"`
|
|
Notes string `json:"notes,omitempty"`
|
|
NotesEN string `json:"notesEN,omitempty"`
|
|
DueDate string `json:"dueDate"`
|
|
OriginalDate string `json:"originalDate"`
|
|
WasAdjusted bool `json:"wasAdjusted"`
|
|
IsRootEvent bool `json:"isRootEvent"`
|
|
IsCourtSet bool `json:"isCourtSet"`
|
|
}
|
|
|
|
// 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"). When a
|
|
// rule's condition_flag is in this slice, the rule's alt_duration_* and
|
|
// alt_rule_code take precedence over the default values.
|
|
type CalcOptions struct {
|
|
PriorityDateStr string
|
|
Flags []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 (PR-3 of t-paliad-086):
|
|
//
|
|
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
|
|
// (e.g. UPC_INF inf.reply / inf.rejoin under "with_ccr").
|
|
// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
|
|
// set (e.g. EP_GRANT publication date is 18mo from priority, not filing).
|
|
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{}{}
|
|
}
|
|
|
|
// 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 {
|
|
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.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.
|
|
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID]
|
|
|
|
// 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).
|
|
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 {
|
|
d.IsCourtSet = true
|
|
d.DueDate = ""
|
|
d.OriginalDate = ""
|
|
courtSet[r.ID] = true
|
|
}
|
|
deadlines = append(deadlines, d)
|
|
continue
|
|
}
|
|
|
|
// If the parent is court-determined 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.
|
|
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, 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 {
|
|
if ref, ok := computed[*prev.Code]; ok {
|
|
baseDate = ref
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flag-conditioned alt: if the rule names a condition_flag and the
|
|
// caller passed it, swap in alt_duration_value/unit and alt_rule_code.
|
|
durationValue := r.DurationValue
|
|
durationUnit := r.DurationUnit
|
|
if r.ConditionFlag != nil {
|
|
if _, on := flagSet[*r.ConditionFlag]; on {
|
|
if r.AltDurationValue != nil {
|
|
durationValue = *r.AltDurationValue
|
|
}
|
|
if r.AltDurationUnit != nil {
|
|
durationUnit = *r.AltDurationUnit
|
|
}
|
|
if r.AltRuleCode != nil {
|
|
d.RuleRef = *r.AltRuleCode
|
|
}
|
|
}
|
|
}
|
|
|
|
endDate := addDuration(baseDate, durationValue, durationUnit)
|
|
origDate := endDate
|
|
adjusted, _, wasAdj := s.holidays.AdjustForNonWorkingDays(endDate)
|
|
|
|
d.OriginalDate = origDate.Format("2006-01-02")
|
|
d.DueDate = adjusted.Format("2006-01-02")
|
|
d.WasAdjusted = wasAdj
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|