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 } }