From 07acf7b4a216e9e64c89fdb93e08dbf0fb6e8983 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 13:49:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(litigationplanner):=20Berufung=20unificati?= =?UTF-8?q?on=20=E2=80=94=20one=20upc.apl=20+=205=20appeal=5Ftarget=20chip?= =?UTF-8?q?s=20(Slice=20B1,=20m/paliad#124=20=C2=A718.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the 3 UPC appeal proceeding_types (upc.apl.merits 7 rules, upc.apl.cost 2, upc.apl.order 7 = 16 total across 3 codes) into ONE unified upc.apl proceeding type + a per-rule applies_to_target[] discriminator. The verfahrensablauf picker now shows one "Berufung" tile; after picking it, the user selects which decision the appeal is directed AT via a 5-chip group (Endentscheidung / Kostenentscheidung / Anordnung / Schadensbemessung / Bucheinsicht) and the engine filters rules whose applies_to_target contains the picked slug. m's 2026-05-26 decision: Schadensbemessung-as-appeal is a NEW first- class target with its OWN rule set (no shared inheritance from merits). The 5 enum values are all defined + addressable; for now schadensbemessung and bucheinsicht return empty timelines until rules are seeded in a follow-up slice (likely via /admin/rules or pairing with t-paliad-193 orphan-concept-seed). Migration 134 (additive only): - ADD proceeding_types.appeal_target text (CHECK on 5 slugs OR NULL) - ADD deadline_rules.applies_to_target text[] (CHECK each element in the 5 slugs) - INSERT the unified upc.apl row (inherits sort/color from upc.apl.merits) - Audit-first RAISE NOTICE pass listing every row about to be touched + a post-migration sanity check - Reassign rule rows: merits → applies_to_target={endentscheidung}, cost → {kostenentscheidung}, order → {anordnung} - Archive (is_active=false, NOT DELETE) the 3 old proceeding_types so historical FKs stay intact - Down migration restores is_active=true on the 3 old types, points rules back by their applies_to_target stamp, drops the unified row, drops both columns. Safe. Package additions (pkg/litigationplanner): - AppealTarget* constants + AppealTargets[] ordered list + IsValidAppealTarget(s) predicate (silent no-op on unknown slugs so a stale frontend chip doesn't break the render) - ProceedingType.AppealTarget *string field (top-level marker; NULL on non-appeal proceedings) - Rule.AppliesToTarget pq.StringArray field (per-row applies-to set) - CalcOptions.AppealTarget string (engine filter — when set, keeps only rules whose AppliesToTarget contains the slug) Engine filter runs after ApplyRuleOverrides but before the rule walk so the existing condition_expr / spawn / appellant-context machinery operates on the filtered subset transparently. paliad-side wiring: - deadline_rule_service.go: ruleColumns + proceedingTypeColumns extended to scan the new columns - handlers/fristenrechner.go: AppealTarget JSON field on the request payload, threaded into CalcOptions Frontend (verfahrensablauf surface only): - Single "Berufung" tile replaces the 3 separate Berufung tiles - New 5-chip appeal-target row, shown only when upc.apl is picked - URL state ?target=; default endentscheidung when none set - APPELLANT_AXIS_PROCEEDINGS updated: upc.apl.* (3 entries) → upc.apl (1 entry) - i18n keys (DE + EN) for the new tile + the 5 chip labels + the "Worauf richtet sich die Berufung?" / "Appeal against:" prompt - calculateDeadlines threads appealTarget through to the API Acceptance: - go build clean, go test all green (existing test suite — no new tests on the engine filter as a follow-up; the migration's sanity-check DO block guards the rule-reassignment count) - Live audit before drafting confirmed: 3 active UPC appeal proceeding_types, 16 rules total, primary_party already conforms to 4-value vocab on all proceeding-bound rules --- docs/design-litigation-planner-2026-05-26.md | 24 +- frontend/src/client/i18n.ts | 14 + frontend/src/client/verfahrensablauf.ts | 98 ++++++- .../src/client/views/verfahrensablauf-core.ts | 7 + frontend/src/i18n-keys.ts | 7 + frontend/src/verfahrensablauf.tsx | 40 ++- .../134_berufung_unification.down.sql | 63 +++++ .../134_berufung_unification.up.sql | 263 ++++++++++++++++++ internal/handlers/fristenrechner.go | 9 + internal/services/deadline_rule_service.go | 5 +- pkg/litigationplanner/engine.go | 19 ++ pkg/litigationplanner/types.go | 71 +++++ 12 files changed, 605 insertions(+), 15 deletions(-) create mode 100644 internal/db/migrations/134_berufung_unification.down.sql create mode 100644 internal/db/migrations/134_berufung_unification.up.sql diff --git a/docs/design-litigation-planner-2026-05-26.md b/docs/design-litigation-planner-2026-05-26.md index 92fde0e..a7d6ec8 100644 --- a/docs/design-litigation-planner-2026-05-26.md +++ b/docs/design-litigation-planner-2026-05-26.md @@ -1245,16 +1245,26 @@ Frontend logic: 5. Existing project rows that referenced the old `upc.apl.merits` / `upc.apl.cost` / `upc.apl.order` codes still load (the FK integrity is preserved via the archived old rows). 6. The `paliad.proceeding_type_history` follow-up (not in scope here) can later migrate those project FKs to the new `upc.apl` + `appeal_target` field — that's a follow-up. -#### Open question for m (escalate via `mai instruct head`) +#### m's answer on Q18.1.1 (2026-05-26 13:40) -**Q18.1.1 — Is Schadensbemessung-as-appeal a duplicate of Endentscheidung, or a distinct set?** +> Schadensbemessung-as-appeal is a NEW appeal target. Model it as a first-class entry in the appeal_target enum with its own rule set (no shared inheritance from upc.apl.merits). The rules don't exist yet in the catalog; for now the appeal_target value is defined and `CalcOptions.AppealTarget="schadensbemessung"` returns an empty sequence until rules are seeded. -Three interpretations: -- A. **Shared rule set**: appeal of a Schadensbemessung uses the SAME rules as appeal of an Endentscheidung (both run the 2/4-month merits track). `applies_to_target=['endentscheidung','schadensbemessung']` on each rule. (R) — simplest, matches what the live `upc.apl.merits` corpus does today (no explicit target distinction). -- B. **Distinct rule set**: Schadensbemessung-appeal has its own anchor + sequence (different trigger event, different timing). Would need 7 new rule rows specifically for `applies_to_target=['schadensbemessung']`. No live evidence for this today. -- C. **Defer**: ship Berufung unification with only 3 targets (endentscheidung / kostenentscheidung / anordnung) for v1; add Schadensbemessung + Bucheinsicht as a follow-up. +→ **Option B wins (m overrode inventor's R=A).** Migration 134 still ships the schema + the 5 enum values + the chip group + the engine filter. Existing rules: +- 7 `upc.apl.merits` rules → `applies_to_target=['endentscheidung']` (Endentscheidung only — NOT also Schadensbemessung). +- 2 `upc.apl.cost` rules → `applies_to_target=['kostenentscheidung']`. +- 7 `upc.apl.order` rules → `applies_to_target=['anordnung']`. +- **Schadensbemessung + Bucheinsicht get NO rules in this migration.** `applies_to_target='schadensbemessung'` and `'bucheinsicht'` are valid enum values but no rule row carries them yet. -Recommendation: **A** — share rules, multi-valued `applies_to_target` array. Frontend renders all 5 chips from day 1; the merits 7 rules show under endentscheidung + schadensbemessung; the order 7 rules show under anordnung + bucheinsicht (the 15-day track DOES apply to Bucheinsicht under R.142+R.220.2). No new rule rows needed. +**Frontend behaviour with empty rule sets:** +- All 5 target chips still render (the picker promises the user a complete vocabulary). +- Picking Schadensbemessung or Bucheinsicht returns an empty timeline with a banner: "Frist-Sequenz für diesen Berufungstyp ist noch nicht hinterlegt — bitte über /admin/rules einpflegen oder Migrations-Follow-up abwarten." +- Picking the 3 populated targets renders normally. + +**Rule-seeding follow-up (TODO, separate slice):** +- Schadensbemessung-appeal rules: anchor on R.118.4 (Folgeentscheidung Schadensbemessung) decision; conjecture 2/4-month track but distinct legal basis. +- Bucheinsicht-appeal rules: anchor on R.142 (Lay-open-books decision); conjecture 15-day track per R.220.2 + R.224.2.b. +- Can pair with `t-paliad-193` orphan-concept-seed if m wants a combined seeding pass. +- Either path: editorial via `/admin/rules` (rule-editor service, Slice 11a) so the lawyer team can author + audit. **Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?** diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 6775aeb..d303031 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -237,6 +237,13 @@ const translations: Record> = { "deadlines.upc.disc.cfi": "Bucheinsicht", "deadlines.upc.apl.cost": "Berufung Kosten", "deadlines.upc.apl.order": "Berufung Anordnungen", + "deadlines.upc.apl": "Berufung", + "deadlines.appeal_target.label": "Worauf richtet sich die Berufung?", + "deadlines.appeal_target.endentscheidung": "Endentscheidung", + "deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung", + "deadlines.appeal_target.anordnung": "Anordnung", + "deadlines.appeal_target.schadensbemessung": "Schadensbemessung", + "deadlines.appeal_target.bucheinsicht": "Bucheinsicht", "deadlines.de.group.inf": "Verletzungsverfahren", "deadlines.de.group.null": "Nichtigkeitsverfahren", "deadlines.de.inf.lg": "LG (1. Instanz)", @@ -3327,6 +3334,13 @@ const translations: Record> = { "deadlines.upc.dmgs.cfi": "Damages Determination", "deadlines.upc.disc.cfi": "Lay-open Books", "deadlines.upc.apl.cost": "Cost-Decision Appeal", + "deadlines.upc.apl": "Appeal", + "deadlines.appeal_target.label": "Appeal against:", + "deadlines.appeal_target.endentscheidung": "Final Decision", + "deadlines.appeal_target.kostenentscheidung": "Cost Decision", + "deadlines.appeal_target.anordnung": "Order", + "deadlines.appeal_target.schadensbemessung": "Damages Determination", + "deadlines.appeal_target.bucheinsicht": "Lay-open Books", "deadlines.upc.apl.order": "Order Appeal (15-day)", "deadlines.de.group.inf": "Infringement proceedings", "deadlines.de.group.null": "Nullity proceedings", diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 4a2e95d..2054307 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -64,9 +64,7 @@ let sidePrefilledFromProject = false; // Conservative — false negatives just hide a control; false positives // would show an irrelevant control. const APPELLANT_AXIS_PROCEEDINGS = new Set([ - "upc.apl.merits", - "upc.apl.cost", - "upc.apl.order", + "upc.apl", "de.inf.olg", "de.inf.bgh", "de.null.bgh", @@ -75,6 +73,29 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([ "epa.opp.boa", ]); +// Slice B1 (m/paliad#124 §18.1) — Berufung unification. +// Proceedings that surface the appeal-target chip group. Currently +// only the unified upc.apl proceeding; future variants (e.g. de.apl) +// can opt in by adding the code here. +const APPEAL_TARGET_PROCEEDINGS = new Set([ + "upc.apl", +]); + +// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered +// in sync with pkg/litigationplanner/types.go AppealTargets). +const APPEAL_TARGETS = [ + "endentscheidung", + "kostenentscheidung", + "anordnung", + "schadensbemessung", + "bucheinsicht", +] as const; +type AppealTarget = (typeof APPEAL_TARGETS)[number] | ""; + +function hasAppealTarget(proceedingType: string): boolean { + return APPEAL_TARGET_PROCEEDINGS.has(proceedingType); +} + function hasAppellantAxis(proceedingType: string): boolean { return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType); } @@ -103,6 +124,32 @@ function writeAppellantToURL(a: Side) { window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); } +// Slice B1 — appeal-target URL state. Empty string = no target picked +// (the row is hidden because the proceeding isn't an appeal). Any +// other value must be one of APPEAL_TARGETS; unknown values are +// rejected by readAppealTargetFromURL so a stale link can't break +// the engine filter. +function readAppealTargetFromURL(): AppealTarget { + const raw = new URLSearchParams(window.location.search).get("target") || ""; + if ((APPEAL_TARGETS as readonly string[]).includes(raw)) { + return raw as AppealTarget; + } + return ""; +} + +function writeAppealTargetToURL(t: AppealTarget) { + const url = new URL(window.location.href); + if (t === "") url.searchParams.delete("target"); + else url.searchParams.set("target", t); + window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); +} + +// Default target on first picker entry into upc.apl. m: Endentscheidung +// is the most-common appeal target; the chip group also defaults +// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in +// sync so the URL-less default render hits the same code path. +let currentAppealTarget: AppealTarget = ""; + // Per-rule anchor overrides set by the click-to-edit affordance on // timeline / column date cells. Posted as `anchorOverrides` to the // /api/tools/fristenrechner calc so downstream rules re-anchor off the @@ -268,6 +315,13 @@ async function doCalc() { const overrides: Record = {}; for (const [code, date] of anchorOverrides) overrides[code] = date; + // Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung, + // default to "endentscheidung" when no chip pick is stored in URL. + // For non-appeal proceedings the engine ignores opts.AppealTarget. + const appealTarget = hasAppealTarget(selectedType) + ? (currentAppealTarget || "endentscheidung") + : ""; + const data = await calculateDeadlines({ proceedingType: selectedType, triggerDate, @@ -276,6 +330,7 @@ async function doCalc() { courtId, perCardChoices, includeHidden: showHidden, + appealTarget, }); if (seq !== calcSeq) return; if (!data) return; @@ -447,6 +502,7 @@ function selectProceeding(btn: HTMLButtonElement) { void populateCourtPicker("court-picker-row", "court-picker", selectedType); syncFlagRows(); syncAppellantRowVisibility(); + syncAppealTargetRowVisibility(); setProceedingPickerCollapsed(true, proceedingDisplayName(btn)); @@ -471,6 +527,23 @@ function syncAppellantRowVisibility() { } } +// Slice B1 (m/paliad#124 §18.1) — Berufung unification. +// syncAppealTargetRowVisibility shows the appeal-target chip group +// when the unified upc.apl Berufung tile is selected, hides it +// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears +// state + URL when hiding so a stale ?target= can't leak. +function syncAppealTargetRowVisibility() { + const row = document.getElementById("appeal-target-row"); + if (!row) return; + const visible = hasAppealTarget(selectedType); + row.style.display = visible ? "" : "none"; + if (!visible && currentAppealTarget !== "") { + currentAppealTarget = ""; + writeAppealTargetToURL(""); + syncRadioGroup("appeal-target", "endentscheidung"); + } +} + function syncRadioGroup(name: string, value: string) { document.querySelectorAll(`input[type=radio][name=${name}]`).forEach((input) => { input.checked = input.value === value; @@ -655,8 +728,10 @@ function initViewToggle() { function initPerspectiveControls() { currentSide = readSideFromURL(); currentAppellant = readAppellantFromURL(); + currentAppealTarget = readAppealTargetFromURL(); syncRadioGroup("side", currentSide ?? ""); syncRadioGroup("appellant", currentAppellant ?? ""); + syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung"); syncSideHintVisibility(); document.querySelectorAll("input[type=radio][name=side]").forEach((input) => { @@ -679,6 +754,23 @@ function initPerspectiveControls() { if (lastResponse) renderResults(lastResponse); }); }); + + // Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler. + // Each chip change re-fetches with the new target slug so the + // timeline re-renders against the matching rule subset. + document.querySelectorAll("input[type=radio][name=appeal-target]").forEach((input) => { + input.addEventListener("change", () => { + if (!input.checked) return; + const v = input.value; + if ((APPEAL_TARGETS as readonly string[]).includes(v)) { + currentAppealTarget = v as AppealTarget; + } else { + currentAppealTarget = ""; + } + writeAppealTargetToURL(currentAppealTarget); + scheduleCalc(0); + }); + }); } document.addEventListener("DOMContentLoaded", () => { diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index fe1afa4..ff26182 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -195,6 +195,12 @@ export interface CalcParams { // Sent only when the page-level "Ausgeblendete anzeigen" toggle is // ON. includeHidden?: boolean; + // Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung + // (upc.apl) timeline to the rule subset whose applies_to_target + // contains the requested slug. Empty = no filter. Valid values: + // endentscheidung | kostenentscheidung | anordnung | + // schadensbemessung | bucheinsicht. + appealTarget?: string; } const PARTY_CLASS: Record = { @@ -811,6 +817,7 @@ export async function calculateDeadlines(params: CalcParams): Promise + {/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1). + Shown only when the unified upc.apl Berufung tile is + selected; lets the user narrow the timeline to the + rules whose applies_to_target contains the picked + decision kind. URL state ?target=. */} +