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:
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
16
internal/db/migrations/012_fristenrechner_rules.down.sql
Normal file
16
internal/db/migrations/012_fristenrechner_rules.down.sql
Normal 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');
|
||||
230
internal/db/migrations/012_fristenrechner_rules.up.sql
Normal file
230
internal/db/migrations/012_fristenrechner_rules.up.sql
Normal 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 $$;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
228
internal/services/fristenrechner.go
Normal file
228
internal/services/fristenrechner.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user