Merge: t-paliad-111 — bug bundle correctness (UPC GESAMTKOSTEN, court-set dates, REGEL save)

This commit is contained in:
m
2026-05-04 14:42:51 +02:00
13 changed files with 199 additions and 19 deletions

View File

@@ -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 = "—";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE paliad.deadlines DROP COLUMN IF EXISTS rule_code;

View 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.';

View File

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

View File

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

View File

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

View File

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

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