From d1909c766ea517e019a000eb9e0dcab691244b1f Mon Sep 17 00:00:00 2001 From: m Date: Thu, 16 Apr 2026 17:11:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=20C=20=E2=80=94=20Fristenrechner?= =?UTF-8?q?=20=E2=86=92=20DB-backed=20via=20FristenrechnerService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete internal/calc/deadlines.go/deadline_rules.go/holidays.go (ported to services) - fristenrechner handler routes through FristenrechnerService when pool present - Returns 503 with German message when DATABASE_URL unset (page still renders) - Migration 012: add name_en columns + seed 9 UI-facing proceeding types - Commit captures cronus's work after session termination --- cmd/server/main.go | 12 +- internal/calc/deadline_rules.go | 177 -------------- internal/calc/deadlines.go | 122 ---------- internal/calc/deadlines_test.go | 183 -------------- internal/calc/holidays.go | 76 ------ .../012_fristenrechner_rules.down.sql | 16 ++ .../012_fristenrechner_rules.up.sql | 230 ++++++++++++++++++ internal/handlers/akten.go | 11 +- internal/handlers/fristenrechner.go | 60 +++-- internal/handlers/handlers.go | 24 +- internal/models/models.go | 5 +- internal/services/deadline_rule_service.go | 12 +- internal/services/fristenrechner.go | 228 +++++++++++++++++ 13 files changed, 553 insertions(+), 603 deletions(-) delete mode 100644 internal/calc/deadline_rules.go delete mode 100644 internal/calc/deadlines.go delete mode 100644 internal/calc/deadlines_test.go delete mode 100644 internal/calc/holidays.go create mode 100644 internal/db/migrations/012_fristenrechner_rules.down.sql create mode 100644 internal/db/migrations/012_fristenrechner_rules.up.sql create mode 100644 internal/services/fristenrechner.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 082114e..ef22dd4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,12 +49,14 @@ func main() { holidays := services.NewHolidayService(pool) users := services.NewUserService(pool) akteSvc := services.NewAkteService(pool, users) + rules := services.NewDeadlineRuleService(pool) svcBundle = &handlers.Services{ - Akte: akteSvc, - Parteien: services.NewParteienService(pool, akteSvc), - Rules: services.NewDeadlineRuleService(pool), - Calculator: services.NewDeadlineCalculator(holidays), - Users: users, + Akte: akteSvc, + Parteien: services.NewParteienService(pool, akteSvc), + Rules: rules, + Calculator: services.NewDeadlineCalculator(holidays), + Users: users, + Fristenrechner: services.NewFristenrechnerService(rules, holidays), } log.Println("Phase B services initialised") } else { diff --git a/internal/calc/deadline_rules.go b/internal/calc/deadline_rules.go deleted file mode 100644 index 143b95a..0000000 --- a/internal/calc/deadline_rules.go +++ /dev/null @@ -1,177 +0,0 @@ -package calc - -// DeadlineRule defines a single deadline step within a proceeding. -type DeadlineRule struct { - Code string `json:"code"` - Name string `json:"name"` - NameEN string `json:"nameEN"` - Party string `json:"party"` // "claimant", "defendant", "court", "both" - Duration int `json:"duration"` - Unit string `json:"unit"` // "months", "weeks", "days", "" (court-set) - IsMandatory bool `json:"isMandatory"` - RuleRef string `json:"ruleRef"` - Notes string `json:"notes,omitempty"` - RelativeTo string `json:"relativeTo"` // Code of previous rule; empty = root -} - -// ProceedingType groups deadline rules for a type of legal proceeding. -type ProceedingType struct { - Code string `json:"code"` - Name string `json:"name"` - NameEN string `json:"nameEN"` - Group string `json:"group"` // "UPC", "DE", "EPA" - Rules []DeadlineRule `json:"rules"` -} - -// AllProceedingTypes returns all supported proceeding types with rules. -func AllProceedingTypes() []ProceedingType { - return []ProceedingType{ - upcInfringement(), - upcRevocation(), - upcProvisionalMeasures(), - upcAppeal(), - deInfringement(), - deNullity(), - epaOpposition(), - epaAppeal(), - epGrant(), - } -} - -// ProceedingTypeByCode returns a proceeding type by its code, or nil. -func ProceedingTypeByCode(code string) *ProceedingType { - for _, pt := range AllProceedingTypes() { - if pt.Code == code { - return &pt - } - } - return nil -} - -func upcInfringement() ProceedingType { - return ProceedingType{ - Code: "UPC_INF", Name: "Verletzungsverfahren", NameEN: "Infringement Action", Group: "UPC", - Rules: []DeadlineRule{ - {Code: "inf.soc", Name: "Klageerhebung", NameEN: "Statement of Claim", Party: "claimant", IsMandatory: true}, - {Code: "inf.sod", Name: "Klageerwiderung", NameEN: "Statement of Defence", Party: "defendant", Duration: 3, Unit: "months", IsMandatory: true, RuleRef: "RoP 23", RelativeTo: "inf.soc"}, - {Code: "inf.reply", Name: "Replik", NameEN: "Reply to Defence", Party: "claimant", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "RoP 29b", RelativeTo: "inf.sod"}, - {Code: "inf.rejoin", Name: "Duplik", NameEN: "Rejoinder", Party: "defendant", Duration: 1, Unit: "months", IsMandatory: true, RuleRef: "RoP 29c", RelativeTo: "inf.reply"}, - {Code: "inf.interim", Name: "Zwischenverfahren", NameEN: "Interim Conference", Party: "court", IsMandatory: true, Notes: "Termin vom Gericht bestimmt"}, - {Code: "inf.oral", Name: "M\u00fcndliche Verhandlung", NameEN: "Oral Hearing", Party: "court", IsMandatory: true}, - {Code: "inf.decision", Name: "Entscheidung", NameEN: "Decision", Party: "court", IsMandatory: true}, - }, - } -} - -func upcRevocation() ProceedingType { - return ProceedingType{ - Code: "UPC_REV", Name: "Nichtigkeitsklage", NameEN: "Revocation Action", Group: "UPC", - Rules: []DeadlineRule{ - {Code: "rev.app", Name: "Nichtigkeitsklage", NameEN: "Application for Revocation", Party: "claimant", IsMandatory: true}, - {Code: "rev.defence", Name: "Klageerwiderung", NameEN: "Defence to Revocation", Party: "defendant", Duration: 3, Unit: "months", IsMandatory: true, RelativeTo: "rev.app"}, - {Code: "rev.reply", Name: "Replik", NameEN: "Reply", Party: "claimant", Duration: 2, Unit: "months", IsMandatory: true, RelativeTo: "rev.defence"}, - {Code: "rev.rejoin", Name: "Duplik", NameEN: "Rejoinder", Party: "defendant", Duration: 2, Unit: "months", IsMandatory: true, RelativeTo: "rev.reply"}, - {Code: "rev.interim", Name: "Zwischenverfahren", NameEN: "Interim Conference", Party: "court", IsMandatory: true}, - {Code: "rev.oral", Name: "M\u00fcndliche Verhandlung", NameEN: "Oral Hearing", Party: "court", IsMandatory: true}, - {Code: "rev.decision", Name: "Entscheidung", NameEN: "Decision", Party: "court", IsMandatory: true}, - }, - } -} - -func upcProvisionalMeasures() ProceedingType { - return ProceedingType{ - Code: "UPC_PI", Name: "Einstweilige Ma\u00dfnahmen", NameEN: "Provisional Measures", Group: "UPC", - Rules: []DeadlineRule{ - {Code: "pi.app", Name: "Antrag", NameEN: "Application", Party: "claimant", IsMandatory: true}, - {Code: "pi.response", Name: "Erwiderung", NameEN: "Response", Party: "defendant", IsMandatory: true, Notes: "Frist vom Gericht bestimmt"}, - {Code: "pi.oral", Name: "M\u00fcndliche Verhandlung", NameEN: "Oral Hearing", Party: "court", IsMandatory: true}, - {Code: "pi.order", Name: "Beschluss", NameEN: "Order", Party: "court", IsMandatory: true}, - }, - } -} - -func upcAppeal() ProceedingType { - return ProceedingType{ - Code: "UPC_APP", Name: "Berufung", NameEN: "Appeal", Group: "UPC", - Rules: []DeadlineRule{ - {Code: "app.notice", Name: "Berufungseinlegung", NameEN: "Notice of Appeal", Party: "both", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "RoP 220.1"}, - {Code: "app.grounds", Name: "Berufungsbegr\u00fcndung", NameEN: "Statement of Grounds", Party: "both", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "RoP 220.1", RelativeTo: "app.notice"}, - {Code: "app.response", Name: "Berufungserwiderung", NameEN: "Response to Appeal", Party: "both", Duration: 2, Unit: "months", IsMandatory: true, RelativeTo: "app.grounds"}, - {Code: "app.oral", Name: "M\u00fcndliche Verhandlung", NameEN: "Oral Hearing", Party: "court", IsMandatory: true}, - {Code: "app.decision", Name: "Entscheidung", NameEN: "Decision", Party: "court", IsMandatory: true}, - }, - } -} - -func deInfringement() ProceedingType { - return ProceedingType{ - Code: "DE_INF", Name: "Verletzungsklage (LG)", NameEN: "Infringement (Regional Court)", Group: "DE", - Rules: []DeadlineRule{ - {Code: "de_inf.klage", Name: "Klageerhebung", NameEN: "Filing of Action", Party: "claimant", IsMandatory: true}, - {Code: "de_inf.erwidg", Name: "Klageerwiderung", NameEN: "Statement of Defence", Party: "defendant", Duration: 6, Unit: "weeks", IsMandatory: true, RuleRef: "\u00a7 276 ZPO", RelativeTo: "de_inf.klage", Notes: "Regelfrist, kann verl\u00e4ngert werden"}, - {Code: "de_inf.replik", Name: "Replik", NameEN: "Reply", Party: "claimant", Duration: 4, Unit: "weeks", IsMandatory: false, Notes: "Frist vom Gericht bestimmt"}, - {Code: "de_inf.duplik", Name: "Duplik", NameEN: "Rejoinder", Party: "defendant", Duration: 4, Unit: "weeks", IsMandatory: false, Notes: "Frist vom Gericht bestimmt"}, - {Code: "de_inf.termin", Name: "Haupttermin", NameEN: "Main Hearing", Party: "court", IsMandatory: true}, - {Code: "de_inf.urteil", Name: "Urteil", NameEN: "Judgment", Party: "court", IsMandatory: true}, - {Code: "de_inf.berufung", Name: "Berufungsfrist", NameEN: "Appeal Period", Party: "both", Duration: 1, Unit: "months", IsMandatory: true, RuleRef: "\u00a7 517 ZPO", Notes: "Ab Zustellung des Urteils"}, - {Code: "de_inf.beruf_begr", Name: "Berufungsbegr\u00fcndung", NameEN: "Appeal Statement", Party: "both", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "\u00a7 520 ZPO", RelativeTo: "de_inf.berufung"}, - }, - } -} - -func deNullity() ProceedingType { - return ProceedingType{ - Code: "DE_NULL", Name: "Nichtigkeitsverfahren (BPatG)", NameEN: "Nullity (Federal Patent Court)", Group: "DE", - Rules: []DeadlineRule{ - {Code: "de_null.klage", Name: "Nichtigkeitsklage", NameEN: "Nullity Action", Party: "claimant", IsMandatory: true}, - {Code: "de_null.erwidg", Name: "Klageerwiderung", NameEN: "Defence", Party: "defendant", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "\u00a7 82 PatG", RelativeTo: "de_null.klage"}, - {Code: "de_null.termin", Name: "M\u00fcndliche Verhandlung", NameEN: "Oral Hearing", Party: "court", IsMandatory: true}, - {Code: "de_null.urteil", Name: "Urteil", NameEN: "Judgment", Party: "court", IsMandatory: true}, - {Code: "de_null.berufung", Name: "Berufungsfrist", NameEN: "Appeal Period", Party: "both", Duration: 1, Unit: "months", IsMandatory: true, RuleRef: "\u00a7 110 PatG", Notes: "Ab Zustellung des Urteils"}, - {Code: "de_null.beruf_begr", Name: "Berufungsbegr\u00fcndung", NameEN: "Appeal Statement", Party: "both", Duration: 1, Unit: "months", IsMandatory: true, RuleRef: "\u00a7 111 PatG", RelativeTo: "de_null.berufung"}, - }, - } -} - -func epaOpposition() ProceedingType { - return ProceedingType{ - Code: "EPA_OPP", Name: "Einspruchsverfahren", NameEN: "Opposition Proceedings", Group: "EPA", - Rules: []DeadlineRule{ - {Code: "epa_opp.grant", Name: "Ver\u00f6ffentlichung der Erteilung", NameEN: "Publication of Grant", Party: "court", IsMandatory: true}, - {Code: "epa_opp.frist", Name: "Einspruchsfrist", NameEN: "Opposition Period", Party: "both", Duration: 9, Unit: "months", IsMandatory: true, RuleRef: "Art. 99 EP\u00dc", RelativeTo: "epa_opp.grant"}, - {Code: "epa_opp.erwidg", Name: "Erwiderung des Patentinhabers", NameEN: "Proprietor's Response", Party: "defendant", Duration: 4, Unit: "months", IsMandatory: true, RuleRef: "R. 79(1) EP\u00dc", RelativeTo: "epa_opp.frist"}, - {Code: "epa_opp.entsch", Name: "Entscheidung", NameEN: "Decision", Party: "court", IsMandatory: true}, - {Code: "epa_opp.beschwerde", Name: "Beschwerdefrist", NameEN: "Appeal Period", Party: "both", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "Art. 108 EP\u00dc", Notes: "Ab Zustellung der Entscheidung"}, - {Code: "epa_opp.beschwerde_begr", Name: "Beschwerdebegr\u00fcndung", NameEN: "Statement of Grounds", Party: "both", Duration: 4, Unit: "months", IsMandatory: true, RuleRef: "Art. 108 EP\u00dc", RelativeTo: "epa_opp.beschwerde"}, - }, - } -} - -func epaAppeal() ProceedingType { - return ProceedingType{ - Code: "EPA_APP", Name: "Beschwerdeverfahren", NameEN: "Appeal Proceedings", Group: "EPA", - Rules: []DeadlineRule{ - {Code: "epa_app.entsch", Name: "Zustellung der Entscheidung", NameEN: "Notification of Decision", Party: "court", IsMandatory: true}, - {Code: "epa_app.beschwerde", Name: "Beschwerdeeinlegung", NameEN: "Filing of Appeal", Party: "both", Duration: 2, Unit: "months", IsMandatory: true, RuleRef: "Art. 108 EP\u00dc", RelativeTo: "epa_app.entsch"}, - {Code: "epa_app.begr", Name: "Beschwerdebegr\u00fcndung", NameEN: "Statement of Grounds", Party: "both", Duration: 4, Unit: "months", IsMandatory: true, RuleRef: "Art. 108 EP\u00dc", RelativeTo: "epa_app.entsch", Notes: "Ab Zustellung, nicht ab Beschwerdeeinlegung"}, - {Code: "epa_app.erwidg", Name: "Erwiderung", NameEN: "Response", Party: "both", IsMandatory: false, Notes: "Frist von der Beschwerdekammer bestimmt"}, - {Code: "epa_app.oral", Name: "M\u00fcndliche Verhandlung", NameEN: "Oral Proceedings", Party: "court", IsMandatory: false}, - {Code: "epa_app.entsch2", Name: "Entscheidung", NameEN: "Decision", Party: "court", IsMandatory: true}, - }, - } -} - -func epGrant() ProceedingType { - return ProceedingType{ - Code: "EP_GRANT", Name: "EP-Erteilungsverfahren", NameEN: "EP Grant Procedure", Group: "EPA", - Rules: []DeadlineRule{ - {Code: "ep_grant.filing", Name: "Anmeldung", NameEN: "Filing", Party: "claimant", IsMandatory: true}, - {Code: "ep_grant.search", Name: "Recherchenbericht", NameEN: "Search Report", Party: "court", Duration: 6, Unit: "months", IsMandatory: true, RelativeTo: "ep_grant.filing", Notes: "Richtwert, kann l\u00e4nger dauern"}, - {Code: "ep_grant.publish", Name: "Ver\u00f6ffentlichung (A1)", NameEN: "Publication (A1)", Party: "court", Duration: 18, Unit: "months", IsMandatory: true, RuleRef: "Art. 93 EP\u00dc", RelativeTo: "ep_grant.filing", Notes: "Ab Priorit\u00e4tstag"}, - {Code: "ep_grant.exam_req", Name: "Pr\u00fcfungsantrag", NameEN: "Request for Examination", Party: "claimant", Duration: 6, Unit: "months", IsMandatory: true, RuleRef: "R. 70(1) EP\u00dc", RelativeTo: "ep_grant.publish", Notes: "Ab Hinweis auf M\u00f6glichkeit"}, - {Code: "ep_grant.r71_3", Name: "Mitteilung nach R. 71(3)", NameEN: "Communication under R. 71(3)", Party: "court", IsMandatory: true, RuleRef: "R. 71(3) EP\u00dc"}, - {Code: "ep_grant.approval", Name: "Zustimmung + \u00dcbersetzung", NameEN: "Approval + Translation", Party: "claimant", Duration: 4, Unit: "months", IsMandatory: true, RuleRef: "R. 71(3) EP\u00dc", Notes: "Erteilungs- und Druckgeb\u00fchr"}, - {Code: "ep_grant.grant", Name: "Erteilung (B1)", NameEN: "Grant (B1)", Party: "court", IsMandatory: true}, - }, - } -} diff --git a/internal/calc/deadlines.go b/internal/calc/deadlines.go deleted file mode 100644 index 5d326d7..0000000 --- a/internal/calc/deadlines.go +++ /dev/null @@ -1,122 +0,0 @@ -package calc - -import ( - "fmt" - "time" -) - -// DeadlineRequest is the JSON request body for the Fristenrechner API. -type DeadlineRequest struct { - ProceedingType string `json:"proceedingType"` - TriggerDate string `json:"triggerDate"` // YYYY-MM-DD -} - -// CalculatedDeadline is one computed deadline in the response. -type CalculatedDeadline struct { - 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"` - DueDate string `json:"dueDate"` // YYYY-MM-DD (adjusted) - OriginalDate string `json:"originalDate"` // YYYY-MM-DD (before adjustment) - WasAdjusted bool `json:"wasAdjusted"` - IsRootEvent bool `json:"isRootEvent"` - IsCourtSet bool `json:"isCourtSet"` // No calculable date -} - -// DeadlineResponse is the JSON response for the Fristenrechner API. -type DeadlineResponse struct { - ProceedingType string `json:"proceedingType"` - ProceedingName string `json:"proceedingName"` - TriggerDate string `json:"triggerDate"` - Deadlines []CalculatedDeadline `json:"deadlines"` -} - -// CalculateDeadlines computes all deadlines for a proceeding from a trigger date. -func CalculateDeadlines(req DeadlineRequest) (*DeadlineResponse, error) { - pt := ProceedingTypeByCode(req.ProceedingType) - if pt == nil { - return nil, fmt.Errorf("unbekannter Verfahrenstyp: %s", req.ProceedingType) - } - - triggerDate, err := time.Parse("2006-01-02", req.TriggerDate) - if err != nil { - return nil, fmt.Errorf("ungültiges Datum: %s", req.TriggerDate) - } - - // Map code → computed date for RelativeTo references - computed := map[string]time.Time{} - var results []CalculatedDeadline - - for _, rule := range pt.Rules { - cd := CalculatedDeadline{ - Code: rule.Code, - Name: rule.Name, - NameEN: rule.NameEN, - Party: rule.Party, - IsMandatory: rule.IsMandatory, - RuleRef: rule.RuleRef, - Notes: rule.Notes, - } - - // Root event or court-set (no duration) - if rule.Duration == 0 && rule.Unit == "" { - if rule.RelativeTo == "" { - // Root event: use trigger date - cd.IsRootEvent = true - cd.DueDate = req.TriggerDate - cd.OriginalDate = req.TriggerDate - computed[rule.Code] = triggerDate - } else { - // Court-set: no calculable date - cd.IsCourtSet = true - cd.DueDate = "" - cd.OriginalDate = "" - } - results = append(results, cd) - continue - } - - // Calculate from reference point - baseDate := triggerDate - if rule.RelativeTo != "" { - if ref, ok := computed[rule.RelativeTo]; ok { - baseDate = ref - } - } - - endDate := addDuration(baseDate, rule.Duration, rule.Unit) - originalDate := endDate - adjustedDate, wasAdjusted := AdjustForNonWorkingDays(endDate) - - cd.OriginalDate = originalDate.Format("2006-01-02") - cd.DueDate = adjustedDate.Format("2006-01-02") - cd.WasAdjusted = wasAdjusted - - computed[rule.Code] = adjustedDate - results = append(results, cd) - } - - return &DeadlineResponse{ - ProceedingType: pt.Code, - ProceedingName: pt.Name, - TriggerDate: req.TriggerDate, - Deadlines: results, - }, nil -} - -func addDuration(base time.Time, duration int, unit string) time.Time { - switch unit { - case "days": - return base.AddDate(0, 0, duration) - case "weeks": - return base.AddDate(0, 0, duration*7) - case "months": - return base.AddDate(0, duration, 0) - default: - return base - } -} diff --git a/internal/calc/deadlines_test.go b/internal/calc/deadlines_test.go deleted file mode 100644 index bdb2a29..0000000 --- a/internal/calc/deadlines_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package calc - -import ( - "testing" - "time" -) - -func TestEasterSunday(t *testing.T) { - tests := []struct { - year int - want string - }{ - {2024, "2024-03-31"}, - {2025, "2025-04-20"}, - {2026, "2026-04-05"}, - {2027, "2027-03-28"}, - } - for _, tt := range tests { - got := EasterSunday(tt.year) - if got.Format("2006-01-02") != tt.want { - t.Errorf("EasterSunday(%d) = %s, want %s", tt.year, got.Format("2006-01-02"), tt.want) - } - } -} - -func TestIsHoliday(t *testing.T) { - // 2026-04-03 = Karfreitag (Easter Sunday 2026 = April 5) - karfreitag := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC) - if !IsHoliday(karfreitag) { - t.Error("2026-04-03 should be Karfreitag") - } - - // 2026-04-06 = Ostermontag - ostermontag := time.Date(2026, 4, 6, 0, 0, 0, 0, time.UTC) - if !IsHoliday(ostermontag) { - t.Error("2026-04-06 should be Ostermontag") - } - - // Normal Tuesday - normal := time.Date(2026, 4, 14, 0, 0, 0, 0, time.UTC) - if IsHoliday(normal) { - t.Error("2026-04-14 should not be a holiday") - } -} - -func TestAdjustForNonWorkingDays(t *testing.T) { - // Saturday → Monday - sat := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC) - adjusted, wasAdjusted := AdjustForNonWorkingDays(sat) - if !wasAdjusted { - t.Error("Saturday should be adjusted") - } - if adjusted.Weekday() != time.Monday { - t.Errorf("Saturday should adjust to Monday, got %s", adjusted.Weekday()) - } - - // Karfreitag 2026 (April 3, Friday) → April 7 (Tuesday, after Easter) - kf := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC) - adjusted, wasAdjusted = AdjustForNonWorkingDays(kf) - if !wasAdjusted { - t.Error("Karfreitag should be adjusted") - } - want := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC) - if !adjusted.Equal(want) { - t.Errorf("Karfreitag should adjust to 2026-04-07, got %s", adjusted.Format("2006-01-02")) - } - - // Working day → no adjustment - tue := time.Date(2026, 4, 14, 0, 0, 0, 0, time.UTC) - adjusted, wasAdjusted = AdjustForNonWorkingDays(tue) - if wasAdjusted { - t.Error("Tuesday should not be adjusted") - } -} - -func TestCalculateDeadlines_UPCInfringement(t *testing.T) { - req := DeadlineRequest{ - ProceedingType: "UPC_INF", - TriggerDate: "2026-04-14", - } - - resp, err := CalculateDeadlines(req) - if err != nil { - t.Fatal(err) - } - - if resp.ProceedingType != "UPC_INF" { - t.Errorf("expected UPC_INF, got %s", resp.ProceedingType) - } - - if len(resp.Deadlines) != 7 { - t.Fatalf("expected 7 deadlines, got %d", len(resp.Deadlines)) - } - - // First rule = root event - if !resp.Deadlines[0].IsRootEvent { - t.Error("first deadline should be root event") - } - if resp.Deadlines[0].DueDate != "2026-04-14" { - t.Errorf("root event date = %s, want 2026-04-14", resp.Deadlines[0].DueDate) - } - - // SOD = 3 months after SOC (2026-04-14 + 3 months = 2026-07-14, Tuesday) - sod := resp.Deadlines[1] - if sod.Code != "inf.sod" { - t.Errorf("expected inf.sod, got %s", sod.Code) - } - if sod.DueDate != "2026-07-14" { - t.Errorf("SOD date = %s, want 2026-07-14", sod.DueDate) - } - - // Reply = 2 months after SOD (2026-07-14 + 2 months = 2026-09-14, Monday) - reply := resp.Deadlines[2] - if reply.DueDate != "2026-09-14" { - t.Errorf("Reply date = %s, want 2026-09-14", reply.DueDate) - } -} - -func TestCalculateDeadlines_UnknownType(t *testing.T) { - req := DeadlineRequest{ - ProceedingType: "INVALID", - TriggerDate: "2026-04-14", - } - _, err := CalculateDeadlines(req) - if err == nil { - t.Error("expected error for unknown type") - } -} - -func TestCalculateDeadlines_InvalidDate(t *testing.T) { - req := DeadlineRequest{ - ProceedingType: "UPC_INF", - TriggerDate: "not-a-date", - } - _, err := CalculateDeadlines(req) - if err == nil { - t.Error("expected error for invalid date") - } -} - -func TestCalculateDeadlines_EPAOpposition(t *testing.T) { - req := DeadlineRequest{ - ProceedingType: "EPA_OPP", - TriggerDate: "2026-01-15", - } - - resp, err := CalculateDeadlines(req) - if err != nil { - t.Fatal(err) - } - - // Opposition period: 9 months from grant publication - // 2026-01-15 + 9 months = 2026-10-15 (Thursday, working day) - oppFrist := resp.Deadlines[1] - if oppFrist.Code != "epa_opp.frist" { - t.Errorf("expected epa_opp.frist, got %s", oppFrist.Code) - } - if oppFrist.DueDate != "2026-10-15" { - t.Errorf("Opposition period = %s, want 2026-10-15", oppFrist.DueDate) - } -} - -func TestAllProceedingTypes(t *testing.T) { - types := AllProceedingTypes() - if len(types) != 9 { - t.Errorf("expected 9 proceeding types, got %d", len(types)) - } - - // Check unique codes - seen := map[string]bool{} - for _, pt := range types { - if seen[pt.Code] { - t.Errorf("duplicate code: %s", pt.Code) - } - seen[pt.Code] = true - if pt.Name == "" || pt.NameEN == "" { - t.Errorf("empty name for %s", pt.Code) - } - if len(pt.Rules) == 0 { - t.Errorf("no rules for %s", pt.Code) - } - } -} diff --git a/internal/calc/holidays.go b/internal/calc/holidays.go deleted file mode 100644 index d3301fc..0000000 --- a/internal/calc/holidays.go +++ /dev/null @@ -1,76 +0,0 @@ -package calc - -import "time" - -// EasterSunday computes Easter Sunday for a given year using the -// Anonymous Gregorian algorithm (Meeus/Jones/Butcher). -func EasterSunday(year int) time.Time { - a := year % 19 - b := year / 100 - c := year % 100 - d := b / 4 - e := b % 4 - f := (b + 8) / 25 - g := (b - f + 1) / 3 - h := (19*a + b - d - g + 15) % 30 - i := c / 4 - k := c % 4 - l := (32 + 2*e + 2*i - h - k) % 7 - m := (a + 11*h + 22*l) / 451 - month := (h + l - 7*m + 114) / 31 - day := ((h + l - 7*m + 114) % 31) + 1 - return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) -} - -// GermanHolidays returns all German federal holidays for a given year. -func GermanHolidays(year int) []time.Time { - easter := EasterSunday(year) - return []time.Time{ - time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), // Neujahr - easter.AddDate(0, 0, -2), // Karfreitag - easter.AddDate(0, 0, 1), // Ostermontag - time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), // Tag der Arbeit - easter.AddDate(0, 0, 39), // Christi Himmelfahrt - easter.AddDate(0, 0, 50), // Pfingstmontag - time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), // Tag der Deutschen Einheit - time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), // 1. Weihnachtstag - time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), // 2. Weihnachtstag - } -} - -// IsHoliday checks if a date is a German federal holiday. -func IsHoliday(t time.Time) bool { - holidays := GermanHolidays(t.Year()) - y, m, d := t.Date() - for _, h := range holidays { - hy, hm, hd := h.Date() - if y == hy && m == hm && d == hd { - return true - } - } - return false -} - -// IsWorkingDay returns true if the date is neither a weekend nor a holiday. -func IsWorkingDay(t time.Time) bool { - wd := t.Weekday() - if wd == time.Saturday || wd == time.Sunday { - return false - } - return !IsHoliday(t) -} - -// AdjustForNonWorkingDays moves a deadline forward to the next working day -// if it falls on a weekend or holiday. Returns adjusted date and whether -// adjustment was needed. -func AdjustForNonWorkingDays(t time.Time) (time.Time, bool) { - original := t - for i := 0; i < 30; i++ { - if IsWorkingDay(t) { - return t, !t.Equal(original) - } - t = t.AddDate(0, 0, 1) - } - // Fallback: return original + 30 if we somehow couldn't find a working day - return t, true -} diff --git a/internal/db/migrations/012_fristenrechner_rules.down.sql b/internal/db/migrations/012_fristenrechner_rules.down.sql new file mode 100644 index 0000000..709ad5f --- /dev/null +++ b/internal/db/migrations/012_fristenrechner_rules.down.sql @@ -0,0 +1,16 @@ +-- Remove Fristenrechner UI-facing proceeding types + rules. +-- Reverses 012. Schema columns (name_en) stay — they're harmless to keep +-- and removing them would require re-applying the DEFAULT '' backfill. + +DELETE FROM paliad.deadline_rules + WHERE proceeding_type_id IN ( + SELECT id FROM paliad.proceeding_types + WHERE code IN ('UPC_INF','UPC_REV','UPC_PI','UPC_APP', + 'DE_INF','DE_NULL', + 'EPA_OPP','EPA_APP','EP_GRANT') + ); + +DELETE FROM paliad.proceeding_types + WHERE code IN ('UPC_INF','UPC_REV','UPC_PI','UPC_APP', + 'DE_INF','DE_NULL', + 'EPA_OPP','EPA_APP','EP_GRANT'); diff --git a/internal/db/migrations/012_fristenrechner_rules.up.sql b/internal/db/migrations/012_fristenrechner_rules.up.sql new file mode 100644 index 0000000..536b8e7 --- /dev/null +++ b/internal/db/migrations/012_fristenrechner_rules.up.sql @@ -0,0 +1,230 @@ +-- Phase C: migrate Fristenrechner's in-memory rule tree into paliad.deadline_rules. +-- +-- The Paliad public Fristenrechner UI exposes 9 simple proceeding types +-- (UPC_INF/UPC_REV/UPC_PI/UPC_APP/DE_INF/DE_NULL/EPA_OPP/EPA_APP/EP_GRANT) +-- with bilingual names. These sit alongside the richer KanzlAI-ported types +-- (INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL) used for matter-attached Fristen. +-- +-- Rules ported verbatim from the pre-Phase-C internal/calc/deadline_rules.go +-- so /tools/fristenrechner results remain byte-identical after the swap. +-- Codes intentionally reuse the old string values (e.g. "inf.soc") even +-- though some collide with KanzlAI rule codes — proceeding_type_id +-- disambiguates, and the client renders by name/nameEN, not code. + +ALTER TABLE paliad.proceeding_types + ADD COLUMN IF NOT EXISTS name_en text NOT NULL DEFAULT ''; +ALTER TABLE paliad.deadline_rules + ADD COLUMN IF NOT EXISTS name_en text NOT NULL DEFAULT ''; + +-- Backfill name_en for existing KanzlAI-ported entries. +UPDATE paliad.proceeding_types SET name_en = name WHERE name_en = ''; +UPDATE paliad.deadline_rules SET name_en = name WHERE name_en = ''; + +-- ============================================================================ +-- UI-facing proceeding types. Name/NameEN copied verbatim from the old +-- in-memory ProceedingType structs. +-- ============================================================================ +INSERT INTO paliad.proceeding_types (code, name, name_en, description, jurisdiction, category, default_color, sort_order, is_active) VALUES + ('UPC_INF', 'Verletzungsverfahren', 'Infringement Action', 'UPC-Verletzungsklage', 'UPC', 'fristenrechner', '#3b82f6', 101, true), + ('UPC_REV', 'Nichtigkeitsklage', 'Revocation Action', 'UPC-Nichtigkeitsklage', 'UPC', 'fristenrechner', '#ef4444', 102, true), + ('UPC_PI', 'Einstweilige Maßnahmen', 'Provisional Measures', 'UPC-Einstweilige Maßnahmen', 'UPC', 'fristenrechner', '#f59e0b', 103, true), + ('UPC_APP', 'Berufung', 'Appeal', 'UPC-Berufung', 'UPC', 'fristenrechner', '#8b5cf6', 104, true), + ('DE_INF', 'Verletzungsklage (LG)', 'Infringement (Regional Court)', 'Deutsche Verletzungsklage', 'DE', 'fristenrechner', '#6366f1', 201, true), + ('DE_NULL', 'Nichtigkeitsverfahren (BPatG)', 'Nullity (Federal Patent Court)', 'Deutsche Nichtigkeitsklage', 'DE', 'fristenrechner', '#6366f1', 202, true), + ('EPA_OPP', 'Einspruchsverfahren', 'Opposition Proceedings', 'EPA-Einspruchsverfahren', 'EPA', 'fristenrechner', '#059669', 301, true), + ('EPA_APP', 'Beschwerdeverfahren', 'Appeal Proceedings', 'EPA-Beschwerdeverfahren', 'EPA', 'fristenrechner', '#059669', 302, true), + ('EP_GRANT', 'EP-Erteilungsverfahren', 'EP Grant Procedure', 'EP-Erteilungsverfahren', 'EPA', 'fristenrechner', '#059669', 303, true) +ON CONFLICT (code) DO NOTHING; + +-- ============================================================================ +-- Rules — every field ported 1:1 from deadline_rules.go. Parent codes follow +-- the original RelativeTo chains. Zero-duration rules with NULL parent_id +-- render as IsRootEvent (due = trigger date); zero-duration rules with a +-- parent render as IsCourtSet (empty due date). This matches the old +-- in-memory classification. +-- ============================================================================ +DO $$ +DECLARE + v_upc_inf int; v_upc_rev int; v_upc_pi int; v_upc_app int; + v_de_inf int; v_de_null int; + v_epa_opp int; v_epa_app int; v_ep_grant int; + + -- UPC_INF + r_inf_soc uuid; r_inf_sod uuid; + r_inf_reply uuid; r_inf_rejoin uuid; + + -- UPC_REV + r_rev_app uuid; r_rev_defence uuid; + r_rev_reply uuid; r_rev_rejoin uuid; + + -- UPC_PI + r_pi_app uuid; + + -- UPC_APP + r_app_notice uuid; r_app_grounds uuid; + + -- DE_INF + r_de_inf_klage uuid; r_de_inf_berufung uuid; + + -- DE_NULL + r_de_null_klage uuid; r_de_null_berufung uuid; + + -- EPA_OPP + r_epa_opp_grant uuid; r_epa_opp_frist uuid; + r_epa_opp_beschwerde uuid; + + -- EPA_APP + r_epa_app_entsch uuid; + + -- EP_GRANT + r_ep_grant_filing uuid; r_ep_grant_publish uuid; +BEGIN + SELECT id INTO v_upc_inf FROM paliad.proceeding_types WHERE code = 'UPC_INF'; + SELECT id INTO v_upc_rev FROM paliad.proceeding_types WHERE code = 'UPC_REV'; + SELECT id INTO v_upc_pi FROM paliad.proceeding_types WHERE code = 'UPC_PI'; + SELECT id INTO v_upc_app FROM paliad.proceeding_types WHERE code = 'UPC_APP'; + SELECT id INTO v_de_inf FROM paliad.proceeding_types WHERE code = 'DE_INF'; + SELECT id INTO v_de_null FROM paliad.proceeding_types WHERE code = 'DE_NULL'; + SELECT id INTO v_epa_opp FROM paliad.proceeding_types WHERE code = 'EPA_OPP'; + SELECT id INTO v_epa_app FROM paliad.proceeding_types WHERE code = 'EPA_APP'; + SELECT id INTO v_ep_grant FROM paliad.proceeding_types WHERE code = 'EP_GRANT'; + + -- Idempotent: skip if we've already seeded. + IF EXISTS ( + SELECT 1 FROM paliad.deadline_rules + WHERE proceeding_type_id = v_upc_inf AND code = 'inf.soc' + ) THEN + RETURN; + END IF; + + -- ======================================================================== + -- UPC_INF — 7 rules + -- ======================================================================== + r_inf_soc := gen_random_uuid(); + r_inf_sod := gen_random_uuid(); + r_inf_reply := gen_random_uuid(); + r_inf_rejoin := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_inf_soc, v_upc_inf, NULL, 'inf.soc', 'Klageerhebung', 'Statement of Claim', 'claimant', 'filing', true, 0, 'months', NULL, NULL, 0, true), + (r_inf_sod, v_upc_inf, r_inf_soc, 'inf.sod', 'Klageerwiderung', 'Statement of Defence','defendant', 'filing', true, 3, 'months', 'RoP 23', NULL, 1, true), + (r_inf_reply, v_upc_inf, r_inf_sod, 'inf.reply', 'Replik', 'Reply to Defence', 'claimant', 'filing', true, 2, 'months', 'RoP 29b', NULL, 2, true), + (r_inf_rejoin, v_upc_inf, r_inf_reply, 'inf.rejoin', 'Duplik', 'Rejoinder', 'defendant', 'filing', true, 1, 'months', 'RoP 29c', NULL, 3, true), + (gen_random_uuid(), v_upc_inf, NULL, 'inf.interim', 'Zwischenverfahren', 'Interim Conference', 'court', 'hearing', true, 0, 'months', NULL, 'Termin vom Gericht bestimmt',4, true), + (gen_random_uuid(), v_upc_inf, NULL, 'inf.oral', 'Mündliche Verhandlung', 'Oral Hearing', 'court', 'hearing', true, 0, 'months', NULL, NULL, 5, true), + (gen_random_uuid(), v_upc_inf, NULL, 'inf.decision', 'Entscheidung', 'Decision', 'court', 'decision',true, 0, 'months', NULL, NULL, 6, true); + + -- ======================================================================== + -- UPC_REV — 7 rules + -- ======================================================================== + r_rev_app := gen_random_uuid(); + r_rev_defence := gen_random_uuid(); + r_rev_reply := gen_random_uuid(); + r_rev_rejoin := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_rev_app, v_upc_rev, NULL, 'rev.app', 'Nichtigkeitsklage', 'Application for Revocation', 'claimant', 'filing', true, 0, 'months', NULL, NULL, 0, true), + (r_rev_defence, v_upc_rev, r_rev_app, 'rev.defence', 'Klageerwiderung', 'Defence to Revocation', 'defendant', 'filing', true, 3, 'months', NULL, NULL, 1, true), + (r_rev_reply, v_upc_rev, r_rev_defence, 'rev.reply', 'Replik', 'Reply', 'claimant', 'filing', true, 2, 'months', NULL, NULL, 2, true), + (r_rev_rejoin, v_upc_rev, r_rev_reply, 'rev.rejoin', 'Duplik', 'Rejoinder', 'defendant', 'filing', true, 2, 'months', NULL, NULL, 3, true), + (gen_random_uuid(), v_upc_rev, NULL, 'rev.interim', 'Zwischenverfahren', 'Interim Conference', 'court', 'hearing', true, 0, 'months', NULL, NULL, 4, true), + (gen_random_uuid(), v_upc_rev, NULL, 'rev.oral', 'Mündliche Verhandlung', 'Oral Hearing', 'court', 'hearing', true, 0, 'months', NULL, NULL, 5, true), + (gen_random_uuid(), v_upc_rev, NULL, 'rev.decision', 'Entscheidung', 'Decision', 'court', 'decision',true, 0, 'months', NULL, NULL, 6, true); + + -- ======================================================================== + -- UPC_PI — 4 rules + -- ======================================================================== + r_pi_app := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_pi_app, v_upc_pi, NULL, 'pi.app', 'Antrag', 'Application', 'claimant', 'filing', true, 0, 'months', NULL, NULL, 0, true), + (gen_random_uuid(), v_upc_pi, NULL, 'pi.response', 'Erwiderung', 'Response', 'defendant','filing', true, 0, 'months', NULL, 'Frist vom Gericht bestimmt', 1, true), + (gen_random_uuid(), v_upc_pi, NULL, 'pi.oral', 'Mündliche Verhandlung', 'Oral Hearing', 'court', 'hearing', true, 0, 'months', NULL, NULL, 2, true), + (gen_random_uuid(), v_upc_pi, NULL, 'pi.order', 'Beschluss', 'Order', 'court', 'decision',true, 0, 'months', NULL, NULL, 3, true); + + -- ======================================================================== + -- UPC_APP — 5 rules + -- ======================================================================== + r_app_notice := gen_random_uuid(); + r_app_grounds := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_app_notice, v_upc_app, NULL, 'app.notice', 'Berufungseinlegung', 'Notice of Appeal', 'both', 'filing', true, 2, 'months', 'RoP 220.1', NULL, 0, true), + (r_app_grounds, v_upc_app, r_app_notice, 'app.grounds', 'Berufungsbegründung', 'Statement of Grounds', 'both', 'filing', true, 2, 'months', 'RoP 220.1', NULL, 1, true), + (gen_random_uuid(), v_upc_app, r_app_grounds, 'app.response', 'Berufungserwiderung', 'Response to Appeal', 'both', 'filing', true, 2, 'months', NULL, NULL, 2, true), + (gen_random_uuid(), v_upc_app, NULL, 'app.oral', 'Mündliche Verhandlung','Oral Hearing', 'court', 'hearing', true, 0, 'months', NULL, NULL, 3, true), + (gen_random_uuid(), v_upc_app, NULL, 'app.decision', 'Entscheidung', 'Decision', 'court', 'decision',true, 0, 'months', NULL, NULL, 4, true); + + -- ======================================================================== + -- DE_INF — 8 rules + -- ======================================================================== + r_de_inf_klage := gen_random_uuid(); + r_de_inf_berufung := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_de_inf_klage, v_de_inf, NULL, 'de_inf.klage', 'Klageerhebung', 'Filing of Action', 'claimant', 'filing', true, 0, 'months', NULL, NULL, 0, true), + (gen_random_uuid(), v_de_inf, r_de_inf_klage, 'de_inf.erwidg', 'Klageerwiderung', 'Statement of Defence','defendant', 'filing', true, 6, 'weeks', '§ 276 ZPO', 'Regelfrist, kann verlängert werden', 1, true), + (gen_random_uuid(), v_de_inf, NULL, 'de_inf.replik', 'Replik', 'Reply', 'claimant', 'filing', false, 4, 'weeks', NULL, 'Frist vom Gericht bestimmt', 2, true), + (gen_random_uuid(), v_de_inf, NULL, 'de_inf.duplik', 'Duplik', 'Rejoinder', 'defendant', 'filing', false, 4, 'weeks', NULL, 'Frist vom Gericht bestimmt', 3, true), + (gen_random_uuid(), v_de_inf, NULL, 'de_inf.termin', 'Haupttermin', 'Main Hearing', 'court', 'hearing', true, 0, 'months', NULL, NULL, 4, true), + (gen_random_uuid(), v_de_inf, NULL, 'de_inf.urteil', 'Urteil', 'Judgment', 'court', 'decision',true, 0, 'months', NULL, NULL, 5, true), + (r_de_inf_berufung, v_de_inf, NULL, 'de_inf.berufung', 'Berufungsfrist', 'Appeal Period', 'both', 'filing', true, 1, 'months', '§ 517 ZPO', 'Ab Zustellung des Urteils', 6, true), + (gen_random_uuid(), v_de_inf, r_de_inf_berufung, 'de_inf.beruf_begr', 'Berufungsbegründung', 'Appeal Statement', 'both', 'filing', true, 2, 'months', '§ 520 ZPO', NULL, 7, true); + + -- ======================================================================== + -- DE_NULL — 6 rules + -- ======================================================================== + r_de_null_klage := gen_random_uuid(); + r_de_null_berufung := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_de_null_klage, v_de_null, NULL, 'de_null.klage', 'Nichtigkeitsklage', 'Nullity Action', 'claimant', 'filing', true, 0, 'months', NULL, NULL, 0, true), + (gen_random_uuid(), v_de_null, r_de_null_klage, 'de_null.erwidg', 'Klageerwiderung', 'Defence', 'defendant', 'filing', true, 2, 'months', '§ 82 PatG', NULL, 1, true), + (gen_random_uuid(), v_de_null, NULL, 'de_null.termin', 'Mündliche Verhandlung', 'Oral Hearing', 'court', 'hearing', true, 0, 'months', NULL, NULL, 2, true), + (gen_random_uuid(), v_de_null, NULL, 'de_null.urteil', 'Urteil', 'Judgment', 'court', 'decision',true, 0, 'months', NULL, NULL, 3, true), + (r_de_null_berufung, v_de_null, NULL, 'de_null.berufung', 'Berufungsfrist', 'Appeal Period', 'both', 'filing', true, 1, 'months', '§ 110 PatG','Ab Zustellung des Urteils', 4, true), + (gen_random_uuid(), v_de_null, r_de_null_berufung, 'de_null.beruf_begr', 'Berufungsbegründung', 'Appeal Statement', 'both', 'filing', true, 1, 'months', '§ 111 PatG',NULL, 5, true); + + -- ======================================================================== + -- EPA_OPP — 6 rules + -- ======================================================================== + r_epa_opp_grant := gen_random_uuid(); + r_epa_opp_frist := gen_random_uuid(); + r_epa_opp_beschwerde := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_epa_opp_grant, v_epa_opp, NULL, 'epa_opp.grant', 'Veröffentlichung der Erteilung','Publication of Grant', 'court', 'decision',true, 0, 'months', NULL, NULL, 0, true), + (r_epa_opp_frist, v_epa_opp, r_epa_opp_grant, 'epa_opp.frist', 'Einspruchsfrist', 'Opposition Period', 'both', 'filing', true, 9, 'months', 'Art. 99 EPÜ', NULL, 1, true), + (gen_random_uuid(), v_epa_opp, r_epa_opp_frist, 'epa_opp.erwidg', 'Erwiderung des Patentinhabers', 'Proprietor''s Response','defendant', 'filing', true, 4, 'months', 'R. 79(1) EPÜ',NULL, 2, true), + (gen_random_uuid(), v_epa_opp, NULL, 'epa_opp.entsch', 'Entscheidung', 'Decision', 'court', 'decision',true, 0, 'months', NULL, NULL, 3, true), + (r_epa_opp_beschwerde, v_epa_opp, NULL, 'epa_opp.beschwerde', 'Beschwerdefrist', 'Appeal Period', 'both', 'filing', true, 2, 'months', 'Art. 108 EPÜ','Ab Zustellung der Entscheidung', 4, true), + (gen_random_uuid(), v_epa_opp, r_epa_opp_beschwerde, 'epa_opp.beschwerde_begr', 'Beschwerdebegründung', 'Statement of Grounds', 'both', 'filing', true, 4, 'months', 'Art. 108 EPÜ',NULL, 5, true); + + -- ======================================================================== + -- EPA_APP — 6 rules + -- ======================================================================== + r_epa_app_entsch := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_epa_app_entsch, v_epa_app, NULL, 'epa_app.entsch', 'Zustellung der Entscheidung', 'Notification of Decision','court', 'decision',true, 0, 'months', NULL, NULL, 0, true), + (gen_random_uuid(), v_epa_app, r_epa_app_entsch, 'epa_app.beschwerde', 'Beschwerdeeinlegung', 'Filing of Appeal', 'both', 'filing', true, 2, 'months', 'Art. 108 EPÜ', NULL, 1, true), + (gen_random_uuid(), v_epa_app, r_epa_app_entsch, 'epa_app.begr', 'Beschwerdebegründung', 'Statement of Grounds', 'both', 'filing', true, 4, 'months', 'Art. 108 EPÜ', 'Ab Zustellung, nicht ab Beschwerdeeinlegung',2, true), + (gen_random_uuid(), v_epa_app, NULL, 'epa_app.erwidg', 'Erwiderung', 'Response', 'both', 'filing', false, 0, 'months', NULL, 'Frist von der Beschwerdekammer bestimmt', 3, true), + (gen_random_uuid(), v_epa_app, NULL, 'epa_app.oral', 'Mündliche Verhandlung', 'Oral Proceedings', 'court', 'hearing', false, 0, 'months', NULL, NULL, 4, true), + (gen_random_uuid(), v_epa_app, NULL, 'epa_app.entsch2', 'Entscheidung', 'Decision', 'court', 'decision',true, 0, 'months', NULL, NULL, 5, true); + + -- ======================================================================== + -- EP_GRANT — 7 rules + -- ======================================================================== + r_ep_grant_filing := gen_random_uuid(); + r_ep_grant_publish := gen_random_uuid(); + + INSERT INTO paliad.deadline_rules (id, proceeding_type_id, parent_id, code, name, name_en, primary_party, event_type, is_mandatory, duration_value, duration_unit, rule_code, deadline_notes, sequence_order, is_active) VALUES + (r_ep_grant_filing, v_ep_grant, NULL, 'ep_grant.filing', 'Anmeldung', 'Filing', 'claimant', 'filing', true, 0, 'months', NULL, NULL, 0, true), + (gen_random_uuid(), v_ep_grant, r_ep_grant_filing, 'ep_grant.search', 'Recherchenbericht', 'Search Report', 'court', 'decision',true, 6, 'months', NULL, 'Richtwert, kann länger dauern', 1, true), + (r_ep_grant_publish, v_ep_grant, r_ep_grant_filing, 'ep_grant.publish', 'Veröffentlichung (A1)', 'Publication (A1)', 'court', 'decision',true, 18, 'months', 'Art. 93 EPÜ', 'Ab Prioritätstag', 2, true), + (gen_random_uuid(), v_ep_grant, r_ep_grant_publish, 'ep_grant.exam_req', 'Prüfungsantrag', 'Request for Examination', 'claimant', 'filing', true, 6, 'months', 'R. 70(1) EPÜ', 'Ab Hinweis auf Möglichkeit', 3, true), + (gen_random_uuid(), v_ep_grant, NULL, 'ep_grant.r71_3', 'Mitteilung nach R. 71(3)', 'Communication under R. 71(3)', 'court', 'decision',true, 0, 'months', 'R. 71(3) EPÜ', NULL, 4, true), + (gen_random_uuid(), v_ep_grant, NULL, 'ep_grant.approval', 'Zustimmung + Übersetzung', 'Approval + Translation', 'claimant', 'filing', true, 4, 'months', 'R. 71(3) EPÜ', 'Erteilungs- und Druckgebühr', 5, true), + (gen_random_uuid(), v_ep_grant, NULL, 'ep_grant.grant', 'Erteilung (B1)', 'Grant (B1)', 'court', 'decision',true, 0, 'months', NULL, NULL, 6, true); +END $$; diff --git a/internal/handlers/akten.go b/internal/handlers/akten.go index ad11213..7794679 100644 --- a/internal/handlers/akten.go +++ b/internal/handlers/akten.go @@ -14,11 +14,12 @@ import ( // dbServices bundles the Phase B services so handlers can stay thin. // Nil if DATABASE_URL was unset at startup. type dbServices struct { - akte *services.AkteService - parteien *services.ParteienService - rules *services.DeadlineRuleService - calc *services.DeadlineCalculator - users *services.UserService + akte *services.AkteService + parteien *services.ParteienService + rules *services.DeadlineRuleService + calc *services.DeadlineCalculator + users *services.UserService + fristenrechner *services.FristenrechnerService } var dbSvc *dbServices diff --git a/internal/handlers/fristenrechner.go b/internal/handlers/fristenrechner.go index f891aa6..cb14920 100644 --- a/internal/handlers/fristenrechner.go +++ b/internal/handlers/fristenrechner.go @@ -2,43 +2,69 @@ package handlers import ( "encoding/json" + "errors" "net/http" - "mgit.msbls.de/m/patholo/internal/calc" + "mgit.msbls.de/m/patholo/internal/services" ) +// Fristenrechner page handler: serves the static HTML. No DB dependency. func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "dist/fristenrechner.html") } +// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding. +// +// Phase C: routes through FristenrechnerService which pulls rules from +// paliad.deadline_rules. When DATABASE_URL is unset, returns 503; the page +// itself still renders because it's static HTML. func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) { - var req calc.DeadlineRequest + if dbSvc == nil || dbSvc.fristenrechner == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).", + }) + return + } + var req struct { + ProceedingType string `json:"proceedingType"` + TriggerDate string `json:"triggerDate"` + } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"}) return } - - resp, err := calc.CalculateDeadlines(req) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if req.ProceedingType == "" || req.TriggerDate == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "proceedingType und triggerDate sind erforderlich"}) return } + resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate) + if err != nil { + if errors.Is(err, services.ErrUnknownProceedingType) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingType}) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } writeJSON(w, http.StatusOK, resp) } +// GET /api/tools/proceeding-types — metadata list for the wizard buttons. +// Returns 503 with an empty array when DATABASE_URL is unset so the page +// still renders (buttons are server-rendered from tsx and don't depend on +// this endpoint for existence, only for dynamic list updates). func handleProceedingTypes(w http.ResponseWriter, r *http.Request) { - types := calc.AllProceedingTypes() - // Return only metadata, not the full rules - type ptMeta struct { - Code string `json:"code"` - Name string `json:"name"` - NameEN string `json:"nameEN"` - Group string `json:"group"` + if dbSvc == nil || dbSvc.fristenrechner == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "Verfahrenstypen vorübergehend nicht verfügbar (keine Datenbank).", + }) + return } - metas := make([]ptMeta, len(types)) - for i, pt := range types { - metas[i] = ptMeta{Code: pt.Code, Name: pt.Name, NameEN: pt.NameEN, Group: pt.Group} + types, err := dbSvc.fristenrechner.ListFristenrechnerTypes(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Verfahrenstypen nicht laden"}) + return } - writeJSON(w, http.StatusOK, metas) + writeJSON(w, http.StatusOK, types) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index efd5676..8fec017 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,14 +10,15 @@ import ( var authClient *auth.Client -// Services bundles the Phase B database-backed services. Pass nil if +// Services bundles the Phase B + C database-backed services. Pass nil if // DATABASE_URL was unset; the Akten/deadline endpoints will return 503. type Services struct { - Akte *services.AkteService - Parteien *services.ParteienService - Rules *services.DeadlineRuleService - Calculator *services.DeadlineCalculator - Users *services.UserService + Akte *services.AkteService + Parteien *services.ParteienService + Rules *services.DeadlineRuleService + Calculator *services.DeadlineCalculator + Users *services.UserService + Fristenrechner *services.FristenrechnerService } func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) { @@ -26,11 +27,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc if svc != nil { dbSvc = &dbServices{ - akte: svc.Akte, - parteien: svc.Parteien, - rules: svc.Rules, - calc: svc.Calculator, - users: svc.Users, + akte: svc.Akte, + parteien: svc.Parteien, + rules: svc.Rules, + calc: svc.Calculator, + users: svc.Users, + fristenrechner: svc.Fristenrechner, } } diff --git a/internal/models/models.go b/internal/models/models.go index 835a20b..7d33b62 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -76,6 +76,7 @@ type DeadlineRule struct { ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` Code *string `db:"code" json:"code,omitempty"` Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` Description *string `db:"description" json:"description,omitempty"` PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` EventType *string `db:"event_type" json:"event_type,omitempty"` @@ -97,11 +98,13 @@ type DeadlineRule struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL. +// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter +// management) or UPC_*/DE_*/EPA_*/EP_GRANT (Fristenrechner UI). type ProceedingType struct { ID int `db:"id" json:"id"` Code string `db:"code" json:"code"` Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` Description *string `db:"description" json:"description,omitempty"` Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"` Category *string `db:"category" json:"category,omitempty"` diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 604d0eb..17ca788 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -21,14 +21,14 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService { return &DeadlineRuleService{db: db} } -const ruleColumns = `id, proceeding_type_id, parent_id, code, name, description, - primary_party, event_type, is_mandatory, duration_value, duration_unit, - timing, rule_code, deadline_notes, sequence_order, condition_rule_id, - alt_duration_value, alt_duration_unit, alt_rule_code, +const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en, + description, primary_party, event_type, is_mandatory, duration_value, + duration_unit, timing, rule_code, deadline_notes, sequence_order, + condition_rule_id, alt_duration_value, alt_duration_unit, alt_rule_code, is_spawn, spawn_label, is_active, created_at, updated_at` -const proceedingTypeColumns = `id, code, name, description, jurisdiction, category, - default_color, sort_order, is_active` +const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction, + category, default_color, sort_order, is_active` // List returns active rules, optionally filtered by proceeding type. func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ([]models.DeadlineRule, error) { diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go new file mode 100644 index 0000000..a0bbceb --- /dev/null +++ b/internal/services/fristenrechner.go @@ -0,0 +1,228 @@ +package services + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// 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 { + 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"` + 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") + +// 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. +func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string) (*UIResponse, error) { + triggerDate, err := time.Parse("2006-01-02", triggerDateStr) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) + } + + // 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)) + deadlines := make([]UIDeadline, 0, len(rules)) + + for _, r := range rules { + d := UIDeadline{ + 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 + } + + // Zero-duration rules either anchor the timeline (trigger date) or + // represent court-set waypoints with no calculable date. + if r.DurationValue == 0 { + if r.ParentID == nil { + 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 = "" + } + deadlines = append(deadlines, d) + continue + } + + // Calculated duration — anchor to parent's adjusted date if we + // have it, else fall back to the trigger date. + baseDate := triggerDate + if r.ParentID != nil { + // Resolve parent's code from the rules slice so we can look up + // its already-computed date. Linear scan is fine: rule trees + // are small (< 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 + } + } + } + + endDate := addDuration(baseDate, r.DurationValue, r.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"` +} + +// 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 + } +}