Merge: t-paliad-111 — bug bundle correctness (UPC GESAMTKOSTEN, court-set dates, REGEL save)
This commit is contained in:
@@ -18,6 +18,7 @@ interface Deadline {
|
||||
status: string;
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
@@ -170,6 +171,10 @@ function render() {
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
} else if (deadline.rule_code) {
|
||||
// Fristenrechner-saved deadlines carry rule_code directly without
|
||||
// a rule_id (no rule UUID round-trips through the public API).
|
||||
ruleEl.textContent = deadline.rule_code;
|
||||
} else {
|
||||
ruleEl.textContent = "—";
|
||||
}
|
||||
|
||||
@@ -203,6 +203,10 @@ function urgencyClass(item: EventListItem): string {
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||||
// REGEL is meant for the legal reference, not the rule's display
|
||||
// name (which is the title column's job).
|
||||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
@@ -407,7 +411,7 @@ function renderTable() {
|
||||
const anyEventType = allItems.some((x) => (x.event_type_ids ?? []).length > 0);
|
||||
const anyLocation = allItems.some((x) => !!x.location);
|
||||
const anyAppointmentType = allItems.some((x) => !!x.appointment_type);
|
||||
const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_name || x.rule_name_en));
|
||||
const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_code || x.rule_name || x.rule_name_en));
|
||||
const anyStatus = anyDeadline; // appointments don't carry a status column
|
||||
|
||||
table?.classList.toggle("events-table--hide-row-type", !anyDeadline || !anyAppointment);
|
||||
|
||||
@@ -268,7 +268,8 @@ async function submitSave() {
|
||||
const dlName = isEN ? dl.nameEN : dl.name;
|
||||
const dlNotes = isEN ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
deadlinesPayload.push({
|
||||
title: dl.ruleRef ? `${dl.ruleRef} \u2014 ${dlName}` : dlName,
|
||||
title: dlName,
|
||||
rule_code: dl.ruleRef || undefined,
|
||||
due_date: dl.dueDate,
|
||||
original_due_date: dl.originalDate || undefined,
|
||||
source: "fristenrechner",
|
||||
|
||||
@@ -80,6 +80,7 @@ interface Deadline {
|
||||
due_date: string;
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
@@ -443,7 +444,7 @@ function renderDeadlines() {
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
|
||||
<td class="frist-col-rule">—</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
|
||||
@@ -325,6 +325,11 @@ func ComputeUPCInstance(streitwert float64, input InstanceInput, instanceKey str
|
||||
effectiveCourtFee = courtSME
|
||||
}
|
||||
|
||||
// InstanceTotal is the user's own outlay for this instance — court fee
|
||||
// only. RecoverableCeiling is the OPPOSING side's R.152 cost cap (a
|
||||
// worst-case loss-of-suit liability) and is exposed as its own line
|
||||
// item so the user sees worst-case exposure separately, not folded
|
||||
// into the GESAMTKOSTEN they themselves owe.
|
||||
return &UPCInstanceResult{
|
||||
Instance: instanceKey,
|
||||
Label: label,
|
||||
@@ -335,7 +340,7 @@ func ComputeUPCInstance(streitwert float64, input InstanceInput, instanceKey str
|
||||
CourtFeesTotal: courtTotal,
|
||||
CourtFeesSME: courtSME,
|
||||
RecoverableCeiling: recoverableCeiling,
|
||||
InstanceTotal: effectiveCourtFee + recoverableCeiling,
|
||||
InstanceTotal: effectiveCourtFee,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -150,11 +150,15 @@ func TestComputeUPCInstance_SME(t *testing.T) {
|
||||
if result.CourtFeesSME != 9000 {
|
||||
t.Errorf("CourtFeesSME = %v, want 9000", result.CourtFeesSME)
|
||||
}
|
||||
// Total = SME court fee + recoverable
|
||||
expectedTotal := 9000.0 + 112000.0
|
||||
// InstanceTotal is the user's own outlay — court fee only, never the
|
||||
// opposing side's R.152 recoverable cap (which stays on RecoverableCeiling).
|
||||
expectedTotal := 9000.0
|
||||
if math.Abs(result.InstanceTotal-expectedTotal) > 0.01 {
|
||||
t.Errorf("InstanceTotal = %v, want %v", result.InstanceTotal, expectedTotal)
|
||||
}
|
||||
if result.RecoverableCeiling != 112000 {
|
||||
t.Errorf("RecoverableCeiling = %v, want 112000 (separate line item)", result.RecoverableCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEPAInstance(t *testing.T) {
|
||||
|
||||
1
internal/db/migrations/032_deadlines_rule_code.down.sql
Normal file
1
internal/db/migrations/032_deadlines_rule_code.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE paliad.deadlines DROP COLUMN IF EXISTS rule_code;
|
||||
21
internal/db/migrations/032_deadlines_rule_code.up.sql
Normal file
21
internal/db/migrations/032_deadlines_rule_code.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- t-paliad-111 B6: store the legal rule citation (RoP.023, R.151, …) on
|
||||
-- the Deadline row directly so the /deadlines list and project-detail
|
||||
-- /deadlines tab can render REGEL without having to JOIN paliad.deadline_rules.
|
||||
--
|
||||
-- Before: the Fristenrechner save flow concatenated the rule code into
|
||||
-- the title ("RoP.023 — Klageerwiderung") because deadlines had nowhere
|
||||
-- else to put it; the REGEL column ended up showing "—" because rule_id
|
||||
-- on the bulk-save payload is always NULL (no rule UUID round-trips
|
||||
-- through the public Fristenrechner API).
|
||||
--
|
||||
-- After: the save flow sends rule_code as a plain string field, the
|
||||
-- title stays clean ("Klageerwiderung"), and the list join becomes
|
||||
-- redundant — the deadline owns its citation.
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN IF NOT EXISTS rule_code text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.rule_code IS
|
||||
'Legal rule citation (e.g. "RoP.023") as it should appear in REGEL. '
|
||||
'Free text — not FK-constrained — so the value survives rule-table '
|
||||
'rewrites and accepts citations from sources outside paliad.deadline_rules.';
|
||||
@@ -173,6 +173,11 @@ type Deadline struct {
|
||||
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
|
||||
Source string `db:"source" json:"source"`
|
||||
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
||||
// RuleCode is the legal citation ("RoP.023", "R.151") attached at
|
||||
// save time — see migration 032. Free text by design; survives
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
// outside that table.
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
@@ -191,15 +196,15 @@ type Deadline struct {
|
||||
// DeadlineWithProject enriches a Deadline with parent-Project display fields
|
||||
// (reference + title) for list views. RuleName/RuleNameEN are the
|
||||
// human-readable label of the linked deadline-rule (e.g. "Replik" / "Reply"),
|
||||
// while RuleCode is the machine-readable slug ("inf.rejoin") — list views
|
||||
// should prefer the localized name and fall back to the code only when no
|
||||
// rule is attached.
|
||||
// pulled from the LEFT JOIN on paliad.deadline_rules.rule_id. The
|
||||
// RuleCode field is inherited from the embedded Deadline (the row's own
|
||||
// stored citation, see migration 032) — list views render it directly as
|
||||
// REGEL.
|
||||
type DeadlineWithProject struct {
|
||||
Deadline
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
ProjectType string `db:"project_type" json:"project_type"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
RuleName *string `db:"rule_name" json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `db:"rule_name_en" json:"rule_name_en,omitempty"`
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *Event
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at`
|
||||
|
||||
// CreateDeadlineInput is the payload for Create / bulk create entries.
|
||||
@@ -49,6 +49,10 @@ type CreateDeadlineInput struct {
|
||||
DueDate string `json:"due_date"` // YYYY-MM-DD
|
||||
OriginalDueDate *string `json:"original_due_date,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
// RuleCode is the legal citation ("RoP.023") to display in REGEL.
|
||||
// Sent by the Fristenrechner save flow so the title can stay clean
|
||||
// instead of carrying the citation as a prefix.
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
@@ -169,13 +173,12 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
p.reference AS project_reference,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
r.code AS rule_code,
|
||||
r.name AS rule_name,
|
||||
r.name_en AS rule_name_en
|
||||
FROM paliad.deadlines f
|
||||
@@ -750,15 +753,23 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
|
||||
source = "manual"
|
||||
}
|
||||
|
||||
var ruleCode *string
|
||||
if input.RuleCode != nil {
|
||||
trimmed := strings.TrimSpace(*input.RuleCode)
|
||||
if trimmed != "" {
|
||||
ruleCode = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, description, due_date, original_due_date,
|
||||
source, rule_id, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10, $11, $11)`,
|
||||
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
|
||||
id, projectID, title, input.Description, due, orig,
|
||||
source, input.RuleID, input.Notes, userID, now,
|
||||
source, input.RuleID, ruleCode, input.Notes, userID, now,
|
||||
); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
|
||||
}
|
||||
|
||||
@@ -33,12 +33,12 @@ func TestProjectDeadline_ShapeStable(t *testing.T) {
|
||||
Description: &descr,
|
||||
DueDate: due,
|
||||
Source: src,
|
||||
RuleCode: &rcode,
|
||||
Status: "pending",
|
||||
EventTypeIDs: []uuid.UUID{uuid.New()},
|
||||
},
|
||||
ProjectTitle: "Acme v. Foo",
|
||||
ProjectType: "case",
|
||||
RuleCode: &rcode,
|
||||
RuleName: &rname,
|
||||
RuleNameEN: &rnameEN,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// FristenrechnerService renders the Paliad public Fristenrechner's response
|
||||
@@ -30,6 +34,7 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi
|
||||
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
||||
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||
type UIDeadline struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
@@ -133,10 +138,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// compute each entry, keeping a code→date map so RelativeTo / parent_id
|
||||
// references resolve to the adjusted predecessor date.
|
||||
computed := make(map[string]time.Time, len(rules))
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: r.IsMandatory,
|
||||
@@ -157,10 +164,24 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
|
||||
// Propagate court-set status from a parent rule whose date the
|
||||
// court determines: if the anchor itself has no real date,
|
||||
// nothing downstream can be computed either.
|
||||
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID]
|
||||
|
||||
// Zero-duration rules either anchor the timeline (trigger date) or
|
||||
// represent court-set waypoints with no calculable date.
|
||||
// represent court-set waypoints with no calculable date. The court
|
||||
// path covers two flavours:
|
||||
// 1. zero-duration with a parent_id (waypoint chained off another
|
||||
// rule, original behaviour).
|
||||
// 2. zero-duration with no parent but flagged as a court-driven
|
||||
// event (Zwischenverfahren / Mündliche Verhandlung /
|
||||
// Entscheidung etc.) — without this, those rendered as
|
||||
// IsRootEvent and emitted the trigger date as their own date,
|
||||
// which then leaked into any downstream rule that chained off
|
||||
// them (e.g. RoP.151 Antrag auf Kostenentscheidung).
|
||||
if r.DurationValue == 0 {
|
||||
if r.ParentID == nil {
|
||||
if r.ParentID == nil && !isCourtDeterminedRule(r) {
|
||||
d.IsRootEvent = true
|
||||
d.DueDate = triggerDateStr
|
||||
d.OriginalDate = triggerDateStr
|
||||
@@ -171,11 +192,25 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the parent is court-determined we have no real anchor date;
|
||||
// surface this rule as court-set too rather than fabricating one
|
||||
// off the trigger date. The user can re-run with the actual
|
||||
// decision date once the court issues it.
|
||||
if parentIsCourtSet {
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for EP_GRANT publish)
|
||||
// when supplied, then parent's computed date, then trigger date.
|
||||
baseDate := triggerDate
|
||||
@@ -270,6 +305,28 @@ type FristenrechnerType struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// isCourtDeterminedRule returns true when a deadline rule represents an
|
||||
// event the court (not a party) sets the date for — Zwischenverfahren,
|
||||
// Mündliche Verhandlung, Entscheidung, Beschluss, etc. These have no
|
||||
// statutory deadline that can be calculated; the date depends on the
|
||||
// court's docket and is only known once the court communicates it.
|
||||
//
|
||||
// Discriminator: primary_party = 'court' OR event_type ∈ {hearing,
|
||||
// decision, order}. Both signals are populated by migration 012; we
|
||||
// accept either so future rules don't have to set both to be detected.
|
||||
func isCourtDeterminedRule(r models.DeadlineRule) bool {
|
||||
if r.PrimaryParty != nil && *r.PrimaryParty == "court" {
|
||||
return true
|
||||
}
|
||||
if r.EventType != nil {
|
||||
switch *r.EventType {
|
||||
case "hearing", "decision", "order":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addDuration adds a signed duration value/unit to a base date.
|
||||
func addDuration(base time.Time, value int, unit string) time.Time {
|
||||
switch unit {
|
||||
|
||||
65
internal/services/fristenrechner_test.go
Normal file
65
internal/services/fristenrechner_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// TestIsCourtDeterminedRule covers the discriminator used by Calculate to
|
||||
// classify zero-duration rules as court-set waypoints rather than
|
||||
// trigger-anchored root events. t-paliad-111 B3 — without this gate the
|
||||
// Fristenrechner emitted the trigger date as the placeholder date for
|
||||
// Zwischenverfahren / Mündliche Verhandlung / Entscheidung and any
|
||||
// downstream rule (e.g. RoP.151 Antrag auf Kostenentscheidung) that
|
||||
// chained off them.
|
||||
func TestIsCourtDeterminedRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule models.DeadlineRule
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "primary_party=court → court-set",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("court"), EventType: ptr("hearing")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "event_type=hearing → court-set even when party is defendant (PI response)",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("defendant"), EventType: ptr("hearing")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "event_type=decision → court-set",
|
||||
rule: models.DeadlineRule{EventType: ptr("decision")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "event_type=order → court-set",
|
||||
rule: models.DeadlineRule{EventType: ptr("order")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "claimant filing (e.g. inf.soc Klageerhebung) → NOT court-set, anchors trigger",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("claimant"), EventType: ptr("filing")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "defendant filing with no court signals → NOT court-set",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("defendant"), EventType: ptr("filing")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil party + nil event_type → NOT court-set",
|
||||
rule: models.DeadlineRule{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isCourtDeterminedRule(tc.rule); got != tc.want {
|
||||
t.Errorf("isCourtDeterminedRule = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user