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

Three correctness bugs from the t-paliad-101 QA sweep, fixed together since
they all change displayed/saved numbers users rely on.

B1 — Kostenrechner UPC GESAMTKOSTEN double-count
  ComputeUPCInstance was setting InstanceTotal = effectiveCourtFee +
  recoverableCeiling. The R.152 recoverable-cost cap is the OPPOSING
  side's worst-case loss-of-suit liability, not the user's own cost —
  folding it into GESAMTKOSTEN inflated the UPC total under a label
  that means "your outlay," and the DE LG/OLG/BGH branches don't add
  any opponent estimate. Drop it from InstanceTotal; the ceiling
  still surfaces as its own RecoverableCeiling line item.

  Live pre-fix on paliad.de (Streitwert 100k, UPC 1. Instanz only):
    instanceTotal = 52600 = 14600 court fee + 38000 R.152 ceiling
  Post-fix:
    instanceTotal = 14600 (court fee only); RecoverableCeiling stays 38000

B3 — Court-determined Termine emit trigger date as a real-looking date
  Zwischenverfahren / Mündliche Verhandlung / Entscheidung all live in
  paliad.deadline_rules with duration_value=0 and parent_id=NULL, so
  Calculate() classified them as IsRootEvent and emitted the trigger
  date as their own DueDate. Worse, RoP.151 "Antrag auf Kostenentscheidung"
  parents off inf.decision and chained 1 month off the placeholder ->
  bogus deadline that the UI rendered as real.

  Fix: classify a zero-duration rule as IsCourtSet (not IsRootEvent)
  when primary_party = 'court' or event_type ∈ {hearing, decision,
  order}. Track court-set rule IDs and propagate IsCourtSet downstream
  to any rule whose parent is court-set, so RoP.151 also surfaces as
  court-set rather than a fabricated date. Save-modal already greys
  out IsCourtSet rows so the "Gerichtsbestimmte Termine ohne Datum
  werden übersprungen" footnote becomes truthful again.

  Live pre-fix on paliad.de (UPC_INF, trigger 2026-04-29):
    Zwischenverfahren / Oral / Entscheidung -> dueDate 2026-04-29
    Antrag auf Kostenentscheidung -> 2026-05-29 (bogus, +1mo from trigger)

B6 — Fristenrechner save flow stored rule code in TITLE
  Frontend was concatenating "RoP.023 — Klageerwiderung" into the
  title because deadlines had nowhere else to put the citation, and
  the /deadlines REGEL column ended up showing "—". Add migration 032
  with a paliad.deadlines.rule_code text column, plumb it through
  CreateDeadlineInput / insertTx, drop the now-redundant r.code AS
  rule_code JOIN alias on the list query (the deadline owns its
  citation), and render f.rule_code on the project-detail deadlines
  table + /deadlines events list + deadline-detail page.

Build, vet, and tests all clean. New unit test
TestIsCourtDeterminedRule pins the B3 discriminator across the
event_type / primary_party combinations seen in migrations 012 + 031.

Repro creds: tester@hlc.de
This commit is contained in:
m
2026-05-04 14:42:29 +02:00
parent 7463831932
commit 0be2dfb5a0
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

@@ -201,6 +201,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);
@@ -391,7 +395,7 @@ function render() {
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

@@ -247,7 +247,8 @@ async function submitSave() {
if (!dl || !dl.dueDate) return;
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
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"`
@@ -132,10 +137,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,
@@ -153,10 +160,24 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.Notes = *r.DeadlineNotes
}
// 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
@@ -167,11 +188,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
@@ -266,6 +301,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)
}
})
}
}