feat: Phase C — Fristenrechner → DB-backed via FristenrechnerService

- 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
This commit is contained in:
m
2026-04-16 17:11:02 +02:00
parent 533f5764b2
commit d1909c766e
13 changed files with 553 additions and 603 deletions

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -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 $$;

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

@@ -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) {

View File

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