Files
KanzlAI-mGMT/backend/internal/services/determine_service.go
m a89ef26ebd feat: UPC deadline determination — event-driven model with proceeding timeline
Full event-driven deadline determination system ported from youpc.org:

Backend:
- DetermineService: walks proceeding event tree, calculates cascading
  dates with holiday adjustment and conditional logic
- GET /api/proceeding-types/{code}/timeline — full event tree structure
- POST /api/deadlines/determine — calculate timeline with conditions
- POST /api/cases/{caseID}/deadlines/batch — batch-create deadlines
- DeadlineRule model: added is_spawn, spawn_label fields
- GetFullTimeline: recursive CTE following cross-type spawn branches
- Conditional deadlines: condition_rule_id toggles alt_duration/rule_code
  (e.g. Reply changes from RoP.029b to RoP.029a when CCR is filed)
- Seed SQL with full UPC event trees (INF, REV, CCR, APM, APP, AMD)

Frontend:
- DeadlineWizard: interactive proceeding timeline with step-by-step flow
  1. Select proceeding type (visual cards)
  2. Enter trigger event date
  3. Toggle conditional branches (CCR, Appeal, Amend)
  4. See full calculated timeline with color-coded urgency
  5. Batch-create all deadlines on a selected case
- Visual timeline tree with party icons, rule codes, duration badges
- Kept existing DeadlineCalculator as "Schnell" quick mode

Also resolved merge conflicts across 6 files (auth, router, handlers)
merging role-based permissions + audit trail features.
2026-03-30 11:33:59 +02:00

237 lines
7.0 KiB
Go

package services
import (
"fmt"
"time"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DetermineService handles event-driven deadline determination.
// It walks the proceeding event tree and calculates cascading dates.
type DetermineService struct {
rules *DeadlineRuleService
calculator *DeadlineCalculator
}
// NewDetermineService creates a new determine service
func NewDetermineService(db *sqlx.DB, calculator *DeadlineCalculator) *DetermineService {
return &DetermineService{
rules: NewDeadlineRuleService(db),
calculator: calculator,
}
}
// TimelineEvent represents a calculated event in the proceeding timeline
type TimelineEvent struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
IsMandatory bool `json:"is_mandatory"`
DurationValue int `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
RuleCode string `json:"rule_code,omitempty"`
DeadlineNotes string `json:"deadline_notes,omitempty"`
IsSpawn bool `json:"is_spawn"`
SpawnLabel string `json:"spawn_label,omitempty"`
HasCondition bool `json:"has_condition"`
ConditionRuleID string `json:"condition_rule_id,omitempty"`
AltRuleCode string `json:"alt_rule_code,omitempty"`
AltDurationValue *int `json:"alt_duration_value,omitempty"`
AltDurationUnit string `json:"alt_duration_unit,omitempty"`
Date string `json:"date,omitempty"`
OriginalDate string `json:"original_date,omitempty"`
WasAdjusted bool `json:"was_adjusted"`
Children []TimelineEvent `json:"children,omitempty"`
}
// DetermineRequest is the input for POST /api/deadlines/determine
type DetermineRequest struct {
ProceedingType string `json:"proceeding_type"`
TriggerEventDate string `json:"trigger_event_date"`
Conditions map[string]bool `json:"conditions"`
}
// DetermineResponse is the output of the determine endpoint
type DetermineResponse struct {
ProceedingType string `json:"proceeding_type"`
ProceedingName string `json:"proceeding_name"`
ProceedingColor string `json:"proceeding_color"`
TriggerDate string `json:"trigger_event_date"`
Timeline []TimelineEvent `json:"timeline"`
TotalDeadlines int `json:"total_deadlines"`
}
// GetTimeline returns the proceeding event tree (without date calculations)
func (s *DetermineService) GetTimeline(proceedingTypeCode string) ([]TimelineEvent, *models.ProceedingType, error) {
rules, pt, err := s.rules.GetFullTimeline(proceedingTypeCode)
if err != nil {
return nil, nil, err
}
tree := buildTimelineTree(rules)
return tree, pt, nil
}
// Determine calculates the full timeline with cascading dates
func (s *DetermineService) Determine(req DetermineRequest) (*DetermineResponse, error) {
timeline, pt, err := s.GetTimeline(req.ProceedingType)
if err != nil {
return nil, fmt.Errorf("loading timeline: %w", err)
}
triggerDate, err := time.Parse("2006-01-02", req.TriggerEventDate)
if err != nil {
return nil, fmt.Errorf("invalid trigger_event_date: %w", err)
}
conditions := req.Conditions
if conditions == nil {
conditions = make(map[string]bool)
}
total := s.calculateDates(timeline, triggerDate, conditions)
return &DetermineResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
ProceedingColor: pt.DefaultColor,
TriggerDate: req.TriggerEventDate,
Timeline: timeline,
TotalDeadlines: total,
}, nil
}
// calculateDates walks the tree and calculates dates for each node
func (s *DetermineService) calculateDates(events []TimelineEvent, parentDate time.Time, conditions map[string]bool) int {
total := 0
for i := range events {
ev := &events[i]
// Skip inactive spawns: if this is a spawn node and conditions don't include it, skip
if ev.IsSpawn && !conditions[ev.ID] {
continue
}
durationValue := ev.DurationValue
durationUnit := ev.DurationUnit
ruleCode := ev.RuleCode
// Apply conditional logic
if ev.HasCondition && ev.ConditionRuleID != "" {
if conditions[ev.ConditionRuleID] {
if ev.AltDurationValue != nil {
durationValue = *ev.AltDurationValue
}
if ev.AltDurationUnit != "" {
durationUnit = ev.AltDurationUnit
}
if ev.AltRuleCode != "" {
ruleCode = ev.AltRuleCode
}
}
}
// Calculate this node's date
if durationValue > 0 {
rule := models.DeadlineRule{
DurationValue: durationValue,
DurationUnit: durationUnit,
}
adjusted, original, wasAdjusted := s.calculator.CalculateEndDate(parentDate, rule)
ev.Date = adjusted.Format("2006-01-02")
ev.OriginalDate = original.Format("2006-01-02")
ev.WasAdjusted = wasAdjusted
} else {
ev.Date = parentDate.Format("2006-01-02")
ev.OriginalDate = parentDate.Format("2006-01-02")
}
ev.RuleCode = ruleCode
total++
// Recurse: children's dates cascade from this node's date
if len(ev.Children) > 0 {
childDate, _ := time.Parse("2006-01-02", ev.Date)
total += s.calculateDates(ev.Children, childDate, conditions)
}
}
return total
}
// buildTimelineTree converts flat rules to a tree of TimelineEvents
func buildTimelineTree(rules []models.DeadlineRule) []TimelineEvent {
nodeMap := make(map[string]*TimelineEvent, len(rules))
var roots []TimelineEvent
// Create event nodes
for _, r := range rules {
ev := ruleToEvent(r)
nodeMap[r.ID.String()] = &ev
}
// Build tree by parent_id
for _, r := range rules {
ev := nodeMap[r.ID.String()]
if r.ParentID != nil {
parentKey := r.ParentID.String()
if parent, ok := nodeMap[parentKey]; ok {
parent.Children = append(parent.Children, *ev)
continue
}
}
roots = append(roots, *ev)
}
return roots
}
func ruleToEvent(r models.DeadlineRule) TimelineEvent {
ev := TimelineEvent{
ID: r.ID.String(),
Name: r.Name,
IsMandatory: r.IsMandatory,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
IsSpawn: r.IsSpawn,
HasCondition: r.ConditionRuleID != nil,
}
if r.Code != nil {
ev.Code = *r.Code
}
if r.Description != nil {
ev.Description = *r.Description
}
if r.PrimaryParty != nil {
ev.PrimaryParty = *r.PrimaryParty
}
if r.EventType != nil {
ev.EventType = *r.EventType
}
if r.RuleCode != nil {
ev.RuleCode = *r.RuleCode
}
if r.DeadlineNotes != nil {
ev.DeadlineNotes = *r.DeadlineNotes
}
if r.SpawnLabel != nil {
ev.SpawnLabel = *r.SpawnLabel
}
if r.ConditionRuleID != nil {
ev.ConditionRuleID = r.ConditionRuleID.String()
}
if r.AltRuleCode != nil {
ev.AltRuleCode = *r.AltRuleCode
}
ev.AltDurationValue = r.AltDurationValue
if r.AltDurationUnit != nil {
ev.AltDurationUnit = *r.AltDurationUnit
}
return ev
}