Compare commits
11 Commits
mai/cronus
...
mai/curie/
| Author | SHA1 | Date | |
|---|---|---|---|
| 6acb1167dd | |||
| 4cd28bc896 | |||
| 568eac0aff | |||
| 733d21c930 | |||
| b05bcf7eeb | |||
| 71e8023784 | |||
| d190fbe0a4 | |||
| e0a82d9f9e | |||
| d326f9aa4a | |||
| 026ad2d5ee | |||
| 13a65a6d6e |
342
cmd/seed-orphan-concept-drafts/main.go
Normal file
342
cmd/seed-orphan-concept-drafts/main.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
|
||||
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
|
||||
// same services.RuleEditorService.Create that POST
|
||||
// /admin/api/procedural-events runs internally, so the audit trigger
|
||||
// + INSTEAD-OF view trigger fan-out into procedural_events +
|
||||
// sequencing_rules + legal_sources fire identically. No HTTP/auth
|
||||
// shell, no direct SQL writes by this command.
|
||||
//
|
||||
// All rules are created with lifecycle_state='draft' (forced by the
|
||||
// service). The admin still reviews + publishes via
|
||||
// /admin/procedural-events.
|
||||
//
|
||||
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
|
||||
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
|
||||
// einspruch, schriftsatznachreichung, weiterbehandlung. The
|
||||
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
|
||||
// versus DPatG § 123a) since the two regimes have different durations
|
||||
// and jurisdictions.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
|
||||
// [-dry-run] [-reason "free-text audit reason"]
|
||||
//
|
||||
// Idempotency: the command refuses to insert if any rule for a given
|
||||
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
|
||||
// after a partial failure.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// draftSpec captures one CreateRuleInput plus the metadata the command
|
||||
// needs to resolve concept_id + proceeding_type_id from human-readable
|
||||
// slugs/codes. ProceedingCode == "" means event-rooted
|
||||
// (proceeding_type_id = NULL), used for cross-cutting rules whose
|
||||
// jurisdiction has no matching proceeding_type yet.
|
||||
type draftSpec struct {
|
||||
Label string // human label for log output
|
||||
ConceptSlug string
|
||||
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
|
||||
SubmissionCode string
|
||||
Name string
|
||||
NameEN string
|
||||
EventKind string
|
||||
PrimaryParty string // "" → omit (NULL)
|
||||
DurationValue int
|
||||
DurationUnit string
|
||||
Timing string
|
||||
Priority string
|
||||
IsCourtSet bool
|
||||
RuleCode string
|
||||
LegalSource string
|
||||
DeadlineNotes string
|
||||
DeadlineNotesEn string
|
||||
}
|
||||
|
||||
func drafts() []draftSpec {
|
||||
return []draftSpec{
|
||||
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
|
||||
{
|
||||
Label: "counterclaim-for-revocation → upc.ccr.cfi",
|
||||
ConceptSlug: "counterclaim-for-revocation",
|
||||
ProceedingCode: "upc.ccr.cfi",
|
||||
SubmissionCode: "upc.ccr.cfi.lodge",
|
||||
Name: "Widerklage auf Nichtigkeit (CCR)",
|
||||
NameEN: "Counterclaim for Revocation (CCR)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "RoP.025",
|
||||
LegalSource: "UPC.RoP.25.1",
|
||||
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
|
||||
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
|
||||
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
|
||||
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
|
||||
},
|
||||
|
||||
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
|
||||
{
|
||||
Label: "versaeumnisurteil-einspruch → de.inf.lg",
|
||||
ConceptSlug: "versaeumnisurteil-einspruch",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.einspruch_vu",
|
||||
Name: "Einspruch gegen Versäumnisurteil",
|
||||
NameEN: "Objection to default judgment",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 339 ZPO",
|
||||
LegalSource: "DE.ZPO.339.1",
|
||||
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
|
||||
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
|
||||
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
|
||||
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
|
||||
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
|
||||
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
|
||||
},
|
||||
|
||||
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
|
||||
{
|
||||
Label: "schriftsatznachreichung → de.inf.lg",
|
||||
ConceptSlug: "schriftsatznachreichung",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.nachreichung",
|
||||
Name: "Schriftsatznachreichung",
|
||||
NameEN: "Subsequent written submission",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "", // concept.party = "both" → no default
|
||||
DurationValue: 3,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "optional",
|
||||
IsCourtSet: true,
|
||||
RuleCode: "§ 283 ZPO",
|
||||
LegalSource: "DE.ZPO.283",
|
||||
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
|
||||
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
|
||||
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
|
||||
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
|
||||
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
|
||||
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
|
||||
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
|
||||
},
|
||||
|
||||
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
|
||||
{
|
||||
Label: "weiterbehandlung (EPC) → epa.grant.exa",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "epa.grant.exa",
|
||||
SubmissionCode: "epa.grant.exa.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung",
|
||||
NameEN: "Request for further processing",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "Art. 121 EPÜ",
|
||||
LegalSource: "EU.EPC-R.135.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
|
||||
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
|
||||
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
|
||||
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
|
||||
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
|
||||
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
|
||||
},
|
||||
|
||||
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
|
||||
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
|
||||
// event-rooted (proceeding_type_id NULL) — same pattern as 78
|
||||
// other cross-cutting rules. Editorial follow-up: create a
|
||||
// `dpma.grant.dpma` proceeding_type and reassign.
|
||||
{
|
||||
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "", // event-rooted
|
||||
SubmissionCode: "dpma.grant.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung (DPMA)",
|
||||
NameEN: "Request for further processing (DPMA, § 123a PatG)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 123a PatG",
|
||||
LegalSource: "DE.PatG.123a.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
|
||||
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
|
||||
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
|
||||
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
|
||||
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
|
||||
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
|
||||
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
|
||||
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
|
||||
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
|
||||
flag.Parse()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := sqlx.Connect("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rules := services.NewDeadlineRuleService(conn)
|
||||
editor := services.NewRuleEditorService(conn, rules)
|
||||
|
||||
conceptIDs := map[string]uuid.UUID{}
|
||||
proceedingIDs := map[string]int{}
|
||||
specs := drafts()
|
||||
|
||||
for _, s := range specs {
|
||||
if _, ok := conceptIDs[s.ConceptSlug]; ok {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
|
||||
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
|
||||
}
|
||||
conceptIDs[s.ConceptSlug] = id
|
||||
}
|
||||
for _, s := range specs {
|
||||
if s.ProceedingCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
|
||||
continue
|
||||
}
|
||||
var id int
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
|
||||
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
|
||||
}
|
||||
proceedingIDs[s.ProceedingCode] = id
|
||||
}
|
||||
|
||||
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
|
||||
|
||||
for i, s := range specs {
|
||||
conceptID := conceptIDs[s.ConceptSlug]
|
||||
var procID *int
|
||||
if s.ProceedingCode != "" {
|
||||
p := proceedingIDs[s.ProceedingCode]
|
||||
procID = &p
|
||||
}
|
||||
|
||||
// Idempotency: refuse if a rule with the same (concept, proceeding,
|
||||
// rule_code) already exists in any lifecycle state.
|
||||
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
|
||||
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
|
||||
} else if existing != uuid.Nil {
|
||||
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
|
||||
continue
|
||||
}
|
||||
|
||||
input := services.CreateRuleInput{
|
||||
Name: s.Name,
|
||||
NameEN: s.NameEN,
|
||||
ProceedingTypeID: procID,
|
||||
DurationValue: s.DurationValue,
|
||||
DurationUnit: s.DurationUnit,
|
||||
Priority: s.Priority,
|
||||
IsCourtSet: s.IsCourtSet,
|
||||
}
|
||||
input.ConceptID = &conceptID
|
||||
code := s.SubmissionCode
|
||||
input.SubmissionCode = &code
|
||||
ek := s.EventKind
|
||||
input.EventType = &ek
|
||||
t := s.Timing
|
||||
input.Timing = &t
|
||||
rc := s.RuleCode
|
||||
input.RuleCode = &rc
|
||||
ls := s.LegalSource
|
||||
input.LegalSource = &ls
|
||||
dn := s.DeadlineNotes
|
||||
input.DeadlineNotes = &dn
|
||||
dne := s.DeadlineNotesEn
|
||||
input.DeadlineNotesEn = &dne
|
||||
if s.PrimaryParty != "" {
|
||||
pp := s.PrimaryParty
|
||||
input.PrimaryParty = &pp
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
|
||||
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
|
||||
continue
|
||||
}
|
||||
|
||||
row, err := editor.Create(ctx, input, *reason)
|
||||
if err != nil {
|
||||
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
|
||||
}
|
||||
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
|
||||
i+1, s.Label, row.ID, row.LifecycleState)
|
||||
}
|
||||
|
||||
fmt.Println("Done.")
|
||||
}
|
||||
|
||||
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
q := `
|
||||
SELECT sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.concept_id = $1
|
||||
AND sr.rule_code IS NOT DISTINCT FROM $2
|
||||
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
|
||||
LIMIT 1`
|
||||
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func codeOrNil(p *int) string {
|
||||
if p == nil {
|
||||
return "<NULL>"
|
||||
}
|
||||
return fmt.Sprintf("%d", *p)
|
||||
}
|
||||
@@ -102,9 +102,9 @@ export function renderAdminRulesList(): string {
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
|
||||
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
|
||||
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||
<th data-i18n="admin.rules.col.priority">Priorität</th>
|
||||
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
|
||||
<th data-i18n="admin.rules.col.modified">Zuletzt geändert</th>
|
||||
|
||||
@@ -11,6 +11,13 @@ import { initSidebar } from "./sidebar";
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
// proceeding_type_code is the joined paliad.proceeding_types.code
|
||||
// for proceeding_type_id, populated server-side by the
|
||||
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
|
||||
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
|
||||
// a glance without depending on the FILTER-dropdown's limited
|
||||
// proceeding list. NULL on event-rooted rules.
|
||||
proceeding_type_code?: string | null;
|
||||
// submission_code is the proceeding-prefixed identifier of this rule
|
||||
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
|
||||
// rule_code (the legal citation, e.g. `RoP.013.1`).
|
||||
@@ -138,6 +145,19 @@ function proceedingLabel(id: number | null | undefined): string {
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
|
||||
// the server-side joined proceeding_type_code when available
|
||||
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
|
||||
// for older API responses or for rules whose proceeding_type_id
|
||||
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
|
||||
// proceeding_type_id renders as the em-dash placeholder used
|
||||
// elsewhere in the admin table.
|
||||
function proceedingCodeCell(r: Rule): string {
|
||||
if (r.proceeding_type_code) return r.proceeding_type_code;
|
||||
if (r.proceeding_type_id == null) return "—";
|
||||
return proceedingLabel(r.proceeding_type_id);
|
||||
}
|
||||
|
||||
function buildFilterURL(): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
|
||||
@@ -233,9 +253,9 @@ function renderRulesTable() {
|
||||
tbody.innerHTML = rules.map((r) => `
|
||||
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
|
||||
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
|
||||
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
|
||||
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
|
||||
<td>${esc(name(r))}</td>
|
||||
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
|
||||
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
|
||||
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
|
||||
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
|
||||
|
||||
@@ -3120,6 +3120,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
|
||||
// t-paliad-321: 3-segment proceeding-type code column (joined
|
||||
// server-side); disambiguates same-named rules across proceedings.
|
||||
"admin.procedural_events.col.proceeding": "Verfahren",
|
||||
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
|
||||
@@ -6188,6 +6191,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.procedural_events.list.heading": "Manage procedural events",
|
||||
"admin.procedural_events.list.new": "+ New procedural event",
|
||||
"admin.procedural_events.col.code": "Code (procedural event)",
|
||||
// t-paliad-321: 3-segment proceeding-type code column.
|
||||
"admin.procedural_events.col.proceeding": "Proceeding",
|
||||
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
|
||||
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
|
||||
|
||||
@@ -297,6 +297,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.procedural_events.col.code"
|
||||
| "admin.procedural_events.col.proceeding"
|
||||
| "admin.procedural_events.edit.breadcrumb"
|
||||
| "admin.procedural_events.edit.field.code"
|
||||
| "admin.procedural_events.edit.field.event_kind"
|
||||
|
||||
@@ -314,15 +314,28 @@ CREATE TRIGGER deadline_rules_unified_update
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snapshot_count int;
|
||||
v_sr_count int;
|
||||
v_view_count int;
|
||||
v_dr_table_exists int;
|
||||
v_rule_id_col int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_snapshot_count FROM paliad.deadline_rules_pre_140;
|
||||
-- B.2 dual-write was implemented only for the active+published lifecycle
|
||||
-- (the scope of the read paths and B.4's pre-flip drift check). Archived
|
||||
-- + draft rows in deadline_rules were never replicated to sequencing_rules
|
||||
-- (they had no production read path). Snapshot includes them all (CREATE
|
||||
-- TABLE AS is unfiltered), so we compare on the same filter B.2 actually
|
||||
-- maintained. Drafts/archived rows are preserved in paliad.deadline_rules_pre_140
|
||||
-- for forensic + future-backfill use.
|
||||
SELECT COUNT(*) INTO v_snapshot_count
|
||||
FROM paliad.deadline_rules_pre_140
|
||||
WHERE is_active = true AND lifecycle_state = 'published';
|
||||
SELECT COUNT(*) INTO v_sr_count
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true AND lifecycle_state = 'published';
|
||||
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
||||
IF v_snapshot_count <> v_view_count THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot has % rows, view has % rows — drift between final state and snapshot',
|
||||
v_snapshot_count, v_view_count;
|
||||
IF v_snapshot_count <> v_sr_count THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot active+published has % rows, sequencing_rules active+published has % rows — dual-write drift',
|
||||
v_snapshot_count, v_sr_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_dr_table_exists
|
||||
@@ -339,8 +352,8 @@ BEGIN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, view=% rows, INSTEAD OF triggers active',
|
||||
v_snapshot_count, v_view_count;
|
||||
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
|
||||
v_snapshot_count, v_sr_count, v_view_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144
|
||||
--
|
||||
-- Best-effort restore from paliad.procedural_events_pre_151 and
|
||||
-- paliad.sequencing_rules_pre_151. Re-points the reparented
|
||||
-- sequencing_rules back at their original procedural_event_id and
|
||||
-- reactivates the archived duplicates with the lifecycle_state +
|
||||
-- is_active they had before the up migration.
|
||||
--
|
||||
-- Catastrophic-recovery path only; the normal revert is to leave the
|
||||
-- dedupe in place (it is purely cosmetic).
|
||||
|
||||
-- 1. Re-point sequencing_rules.procedural_event_id back to its
|
||||
-- pre-mig-151 value. The snapshot row is keyed by sr.id so the
|
||||
-- join is 1:1 and idempotent.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET procedural_event_id = s.original_procedural_event_id,
|
||||
updated_at = now()
|
||||
FROM paliad.sequencing_rules_pre_151 s
|
||||
WHERE sr.id = s.id;
|
||||
|
||||
-- 2. Reactivate the archived duplicates with their snapshot lifecycle.
|
||||
UPDATE paliad.procedural_events pe
|
||||
SET is_active = s.is_active,
|
||||
lifecycle_state = s.lifecycle_state,
|
||||
updated_at = now()
|
||||
FROM paliad.procedural_events_pre_151 s
|
||||
WHERE pe.id = s.id;
|
||||
|
||||
-- 3. Drop the snapshot tables — the data is back in place.
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events_pre_151;
|
||||
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
@@ -0,0 +1,229 @@
|
||||
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
|
||||
--
|
||||
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
|
||||
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
|
||||
-- whose submission_code was NULL) share user-visible names. The
|
||||
-- /admin/procedural-events list shows multiple entries for the same legal
|
||||
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
|
||||
-- migration consolidates every name-group onto a single canonical row,
|
||||
-- reparents the sequencing_rules pointing at the duplicates, and archives
|
||||
-- the duplicates without deleting them.
|
||||
--
|
||||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 1–5 dups per
|
||||
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
|
||||
-- * 0 paliad.deadlines reference any duplicate.
|
||||
-- * 0 procedural_events.draft_of references any duplicate.
|
||||
-- * No audit trigger on procedural_events or sequencing_rules — only
|
||||
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
|
||||
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
|
||||
-- needed.
|
||||
--
|
||||
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
|
||||
-- created_at, id::text). Every duplicate in current data shares the same
|
||||
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
|
||||
-- the UUID's lexicographic order.
|
||||
--
|
||||
-- Hard constraints honoured:
|
||||
-- * No deletions. Duplicates flip to is_active=false +
|
||||
-- lifecycle_state='archived'. The rows stay in the table for audit.
|
||||
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
|
||||
-- BEFORE archiving, so no FK ever points at an archived PE.
|
||||
-- * Snapshot the affected procedural_events + sequencing_rules into
|
||||
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
|
||||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
|
||||
--
|
||||
-- Down: best-effort restore from the snapshots. See .down.sql.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
|
||||
-- TEMP table used by every subsequent step.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
|
||||
WITH dupe_names AS (
|
||||
SELECT name
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY name
|
||||
HAVING COUNT(*) > 1
|
||||
),
|
||||
ranked AS (
|
||||
SELECT pe.id,
|
||||
pe.code,
|
||||
pe.name,
|
||||
pe.created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY pe.name
|
||||
ORDER BY pe.created_at, pe.id::text
|
||||
) AS rn
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.code LIKE 'null.%'
|
||||
AND pe.name IN (SELECT name FROM dupe_names)
|
||||
),
|
||||
canonicals AS (
|
||||
SELECT name,
|
||||
id AS canonical_id,
|
||||
code AS canonical_code
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
)
|
||||
SELECT r.id AS duplicate_id,
|
||||
r.code AS duplicate_code,
|
||||
r.name,
|
||||
c.canonical_id,
|
||||
c.canonical_code
|
||||
FROM ranked r
|
||||
JOIN canonicals c ON c.name = r.name
|
||||
WHERE r.rn > 1;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Snapshot. Captures the rows that change so .down has a clean
|
||||
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events_pre_151 AS
|
||||
SELECT pe.*
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
|
||||
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
|
||||
'duplicates that were archived in favour of their canonical name-mate. '
|
||||
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
|
||||
'095/098/140.';
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_151 AS
|
||||
SELECT sr.id,
|
||||
sr.procedural_event_id AS original_procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
|
||||
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
|
||||
'before reparenting from null.* duplicates onto their canonical PE. '
|
||||
'Read-only forensic + revert source.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Audit log — per-row NOTICE so the migration output captures
|
||||
-- exactly which duplicate folded into which canonical, including
|
||||
-- the sr_count for the duplicate (always 1 in current data, but
|
||||
-- the RAISE keeps the audit honest if the scope grows later).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
v_dup_count int;
|
||||
v_grp_count int;
|
||||
BEGIN
|
||||
SELECT COUNT(*), COUNT(DISTINCT name)
|
||||
INTO v_dup_count, v_grp_count
|
||||
FROM tmp_pe_dedupe;
|
||||
|
||||
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
|
||||
v_dup_count, v_grp_count;
|
||||
|
||||
FOR rec IN
|
||||
SELECT d.duplicate_id,
|
||||
d.duplicate_code,
|
||||
d.name,
|
||||
d.canonical_id,
|
||||
d.canonical_code,
|
||||
(SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
|
||||
FROM tmp_pe_dedupe d
|
||||
ORDER BY d.name, d.duplicate_id
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
|
||||
rec.duplicate_id, rec.duplicate_code,
|
||||
rec.canonical_id, rec.canonical_code,
|
||||
rec.sr_count;
|
||||
RAISE NOTICE '[mig 151] name: %', rec.name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
|
||||
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
|
||||
-- multiple sr onto one PE is by design.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET procedural_event_id = d.canonical_id,
|
||||
updated_at = now()
|
||||
FROM tmp_pe_dedupe d
|
||||
WHERE sr.procedural_event_id = d.duplicate_id;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Archive the duplicates. No deletion — audit trail preserved.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.procedural_events pe
|
||||
SET is_active = false,
|
||||
lifecycle_state = 'archived',
|
||||
updated_at = now()
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. POST assertions. Any failure rolls the migration back.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_surviving_groups int;
|
||||
v_expected_count int;
|
||||
v_archived_count int;
|
||||
v_orphan_sr int;
|
||||
BEGIN
|
||||
-- (a) Acceptance criterion 2: no name-group still has >1 active+
|
||||
-- published null.* row.
|
||||
SELECT COUNT(*) INTO v_surviving_groups
|
||||
FROM (
|
||||
SELECT name
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
GROUP BY name
|
||||
HAVING COUNT(*) > 1
|
||||
) s;
|
||||
|
||||
IF v_surviving_groups > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
|
||||
v_surviving_groups;
|
||||
END IF;
|
||||
|
||||
-- (b) Every targeted duplicate is now archived.
|
||||
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
|
||||
|
||||
SELECT COUNT(*) INTO v_archived_count
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
|
||||
AND pe.is_active = false
|
||||
AND pe.lifecycle_state = 'archived';
|
||||
|
||||
IF v_archived_count <> v_expected_count THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: archived %/% duplicates',
|
||||
v_archived_count, v_expected_count;
|
||||
END IF;
|
||||
|
||||
-- (c) Acceptance criterion 4: no sequencing_rule still points at
|
||||
-- an archived duplicate.
|
||||
SELECT COUNT(*) INTO v_orphan_sr
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
IF v_orphan_sr > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
|
||||
v_orphan_sr;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
|
||||
v_archived_count,
|
||||
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 152_dedupe_identical_sequencing_rule_clones (down) — t-paliad-321
|
||||
--
|
||||
-- Best-effort revert from paliad.sequencing_rules_pre_152. Flips the
|
||||
-- archived rows back to is_active=true / lifecycle_state='published'.
|
||||
-- Does NOT undo the deadlines.sequencing_rule_id reparent — that would
|
||||
-- require remembering the previous pointer per row, which the snapshot
|
||||
-- on sequencing_rules doesn't carry. In live data the reparent was a
|
||||
-- no-op (zero deadlines pointed at duplicates), so this is fine.
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET is_active = true,
|
||||
lifecycle_state = 'published',
|
||||
updated_at = now()
|
||||
FROM paliad.sequencing_rules_pre_152 snap
|
||||
WHERE sr.id = snap.id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_152;
|
||||
@@ -0,0 +1,240 @@
|
||||
-- 152_dedupe_identical_sequencing_rule_clones — t-paliad-321 / m/paliad#144 follow-up
|
||||
--
|
||||
-- Purpose: mig 151 archived 5 of 6 duplicate procedural_events for
|
||||
-- "Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
|
||||
-- onto the canonical PE. The 6 sequencing_rules themselves remained
|
||||
-- active. Because every one of them is a byte-for-byte clone (same
|
||||
-- proceeding_type_id=NULL, rule_code=NULL, duration 14d, primary_party=NULL,
|
||||
-- everything else NULL, lifecycle_state='published') and only sequence_order
|
||||
-- differs, the admin shows six indistinguishable rows for one legal
|
||||
-- concept. This mig archives 5 of the 6 keeping the lexicographically
|
||||
-- lowest UUID as canonical.
|
||||
--
|
||||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||
-- * Exactly 1 clone-group surfaces by the full-signature query
|
||||
-- below: 6 "Mängelbeseitigung / Zahlung" sequencing_rules with
|
||||
-- all-NULL discriminators and (duration_value=14, duration_unit='days').
|
||||
-- * 0 paliad.deadlines reference the 5 to-be-archived rows
|
||||
-- (verified via deadlines.sequencing_rule_id JOIN; the column
|
||||
-- formerly named deadlines.rule_id was dropped in mig 140 / B.4).
|
||||
-- * Other name-groups in the live corpus — "Antrag auf
|
||||
-- Patentänderung"×4, "Beginn des Hauptsacheverfahrens"×2,
|
||||
-- "Berufungsbegründung-R.220.1"×2, "Berufungsschrift-R.220.1"×2 —
|
||||
-- do NOT collapse under this signature because their
|
||||
-- proceeding_type_id / rule_code / duration / primary_party
|
||||
-- differ. They are legitimately distinct rules per proceeding;
|
||||
-- this mig leaves them alone.
|
||||
--
|
||||
-- Hard constraints honoured (mirrors mig 151):
|
||||
-- * No deletions. Archived rows flip to is_active=false +
|
||||
-- lifecycle_state='archived'. Rows stay in the table for audit.
|
||||
-- * Reparent paliad.deadlines.sequencing_rule_id duplicate →
|
||||
-- canonical BEFORE archiving, so no live deadline keeps pointing
|
||||
-- at an archived sequencing_rule. (deadlines.rule_id column
|
||||
-- dropped in mig 140; the back-link lives on sequencing_rule_id
|
||||
-- now — same UUID semantics.)
|
||||
-- * Snapshot the affected rows into paliad.sequencing_rules_pre_152
|
||||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140/151).
|
||||
-- * set_config('paliad.audit_reason') is defensively called even
|
||||
-- though no audit trigger fires on sequencing_rules today (mig 151
|
||||
-- §comments documented this). Future audit trigger would inherit
|
||||
-- the reason automatically.
|
||||
--
|
||||
-- Generic-shape rationale: the audit query below uses the FULL
|
||||
-- signature paliadin specified — procedural_event_id, proceeding_type_id,
|
||||
-- rule_code, duration_value, duration_unit, primary_party, condition_expr,
|
||||
-- trigger_event_id, alt_*, anchor_alt, combine_op, parent_id, is_spawn,
|
||||
-- spawn_*. A NOTICE surfaces every group BEFORE the archive step so an
|
||||
-- operator running the deploy logs sees what's about to be touched.
|
||||
-- If new groups appear after future seeds, this mig is safe to re-run
|
||||
-- conceptually (it would archive any new clones) but only fires once
|
||||
-- via the applied_migrations protocol.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) into a
|
||||
-- TEMP table used by every subsequent step.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TEMP TABLE tmp_sr_dedupe ON COMMIT DROP AS
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id, procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr, trigger_event_id, alt_duration_value,
|
||||
alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
|
||||
parent_id, is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY
|
||||
procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
ORDER BY created_at, id::text
|
||||
) AS rn,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY
|
||||
procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
) AS grp_size
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
)
|
||||
SELECT
|
||||
r.id AS duplicate_id,
|
||||
canon.id AS canonical_id,
|
||||
r.procedural_event_id,
|
||||
(SELECT name FROM paliad.procedural_events WHERE id = r.procedural_event_id) AS pe_name
|
||||
FROM ranked r
|
||||
JOIN ranked canon
|
||||
ON canon.procedural_event_id IS NOT DISTINCT FROM r.procedural_event_id
|
||||
AND canon.proceeding_type_id IS NOT DISTINCT FROM r.proceeding_type_id
|
||||
AND canon.rule_code IS NOT DISTINCT FROM r.rule_code
|
||||
AND canon.duration_value IS NOT DISTINCT FROM r.duration_value
|
||||
AND canon.duration_unit IS NOT DISTINCT FROM r.duration_unit
|
||||
AND canon.primary_party IS NOT DISTINCT FROM r.primary_party
|
||||
AND canon.condition_expr::text IS NOT DISTINCT FROM r.condition_expr::text
|
||||
AND canon.trigger_event_id IS NOT DISTINCT FROM r.trigger_event_id
|
||||
AND canon.alt_duration_value IS NOT DISTINCT FROM r.alt_duration_value
|
||||
AND canon.alt_duration_unit IS NOT DISTINCT FROM r.alt_duration_unit
|
||||
AND canon.alt_rule_code IS NOT DISTINCT FROM r.alt_rule_code
|
||||
AND canon.anchor_alt IS NOT DISTINCT FROM r.anchor_alt
|
||||
AND canon.combine_op IS NOT DISTINCT FROM r.combine_op
|
||||
AND canon.parent_id IS NOT DISTINCT FROM r.parent_id
|
||||
AND canon.is_spawn IS NOT DISTINCT FROM r.is_spawn
|
||||
AND canon.spawn_label IS NOT DISTINCT FROM r.spawn_label
|
||||
AND canon.spawn_proceeding_type_id IS NOT DISTINCT FROM r.spawn_proceeding_type_id
|
||||
AND canon.rn = 1
|
||||
WHERE r.rn > 1 AND r.grp_size > 1;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Surface every clone-group as a NOTICE before archiving.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
total_to_archive int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_to_archive FROM tmp_sr_dedupe;
|
||||
RAISE NOTICE '[mig 152] PRE: % sequencing_rules row(s) will be archived', total_to_archive;
|
||||
FOR rec IN
|
||||
SELECT pe_name, canonical_id, COUNT(*) AS dup_count, array_agg(duplicate_id::text ORDER BY duplicate_id::text) AS dup_ids
|
||||
FROM tmp_sr_dedupe
|
||||
GROUP BY pe_name, canonical_id
|
||||
ORDER BY pe_name
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 152] % canonical=% duplicates=% ids=%',
|
||||
rec.pe_name, rec.canonical_id, rec.dup_count, rec.dup_ids;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Snapshot the rows about to be archived (only the duplicates;
|
||||
-- the canonicals stay in the live table). Matches precedent.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_152 AS
|
||||
SELECT sr.*
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN tmp_sr_dedupe d ON d.duplicate_id = sr.id;
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_152 IS
|
||||
'Snapshot of paliad.sequencing_rules rows archived by mig 152 '
|
||||
'(identical clones — Mängelbeseitigung / Zahlung × 5). Mirrors '
|
||||
'precedent pre_091/093/095/098/140/151. Read-only revert source. '
|
||||
't-paliad-321 / m/paliad#144 follow-up.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
|
||||
-- BEFORE archiving. Today's live data has 0 deadlines pointing at
|
||||
-- any duplicate, but the statement is safe + defensive against a
|
||||
-- race between drift-check and apply.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = m.canonical_id,
|
||||
procedural_event_id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = m.canonical_id),
|
||||
updated_at = now()
|
||||
FROM tmp_sr_dedupe m
|
||||
WHERE d.sequencing_rule_id = m.duplicate_id;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Defensive audit-reason. Sequencing_rules has no audit trigger
|
||||
-- today (mig 151 §scope verified), but set_config is transactional
|
||||
-- and a future audit trigger inherits the reason automatically.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
SELECT set_config('paliad.audit_reason',
|
||||
'mig 152: archive identical sequencing_rule clones (mig 151 follow-up; t-paliad-321)',
|
||||
true);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. Archive the duplicates.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET is_active = false,
|
||||
lifecycle_state = 'archived',
|
||||
updated_at = now()
|
||||
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 7. POST assertions.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_archived int;
|
||||
v_remaining_dupes int;
|
||||
v_orphan_deadlines int;
|
||||
BEGIN
|
||||
-- a. Did the expected number of rows get archived?
|
||||
SELECT COUNT(*) INTO v_archived
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe)
|
||||
AND lifecycle_state = 'archived'
|
||||
AND is_active = false;
|
||||
IF v_archived <> (SELECT COUNT(*) FROM tmp_sr_dedupe) THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: expected % rows archived, got %',
|
||||
(SELECT COUNT(*) FROM tmp_sr_dedupe), v_archived;
|
||||
END IF;
|
||||
|
||||
-- b. No clone group of size > 1 should remain in active+published.
|
||||
SELECT COUNT(*) INTO v_remaining_dupes FROM (
|
||||
SELECT 1
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true AND lifecycle_state = 'published'
|
||||
GROUP BY procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
HAVING COUNT(*) > 1
|
||||
) g;
|
||||
IF v_remaining_dupes > 0 THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: % clone group(s) still active+published after archive', v_remaining_dupes;
|
||||
END IF;
|
||||
|
||||
-- c. No deadline points at an archived sequencing_rule.
|
||||
SELECT COUNT(*) INTO v_orphan_deadlines
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = d.sequencing_rule_id
|
||||
WHERE sr.lifecycle_state = 'archived';
|
||||
IF v_orphan_deadlines > 0 THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: % live deadline(s) still point at an archived sequencing_rule', v_orphan_deadlines;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 152] OK — archived=%, remaining clone groups=0, orphan deadlines=0',
|
||||
v_archived;
|
||||
END $$;
|
||||
@@ -41,14 +41,22 @@ import (
|
||||
// historical `submission_code` + `event_type` already on Rule's tags.
|
||||
// The embedded *models.DeadlineRule carries every existing tag through
|
||||
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
|
||||
//
|
||||
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
|
||||
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
|
||||
// /admin/procedural-events list disambiguate same-named rules at a glance
|
||||
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
|
||||
type adminRuleResponse struct {
|
||||
*models.DeadlineRule
|
||||
Code *string `json:"code,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
|
||||
}
|
||||
|
||||
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
||||
// Same values, two keys per concept — no semantic change.
|
||||
// Same values, two keys per concept — no semantic change. Pass a non-nil
|
||||
// ptCode to populate the proceeding_type_code field; nil leaves it
|
||||
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
|
||||
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
|
||||
if r == nil {
|
||||
return adminRuleResponse{}
|
||||
@@ -61,11 +69,20 @@ func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
|
||||
}
|
||||
|
||||
// wrapRuleListResponse maps a slice of service results into the
|
||||
// dual-emit wrapper. Used by the LIST endpoint.
|
||||
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
|
||||
// dual-emit wrapper. Used by the LIST endpoint. ptCodes is an
|
||||
// optional id → code lookup populated by handleAdminListRules from a
|
||||
// single batch query against paliad.proceeding_types; nil leaves
|
||||
// every row's proceeding_type_code empty (the LIST endpoint always
|
||||
// passes a populated map; other callers don't need it).
|
||||
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
|
||||
out := make([]adminRuleResponse, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = wrapRuleResponse(&rows[i])
|
||||
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
|
||||
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
|
||||
out[i].ProceedingTypeCode = &code
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -128,8 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
// t-paliad-321: batch-fetch proceeding_type.code for every rule
|
||||
// row that carries a non-NULL proceeding_type_id, so the LIST
|
||||
// response can show a Proceeding column without an N+1 join.
|
||||
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
|
||||
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
@@ -677,6 +678,42 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadProceedingTypeCodes returns an id → code map for every distinct
|
||||
// non-NULL proceeding_type_id present in rows. Single SELECT against
|
||||
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
|
||||
// /admin/api/procedural-events to enrich the LIST response with a
|
||||
// proceeding_type_code field so the admin UI can disambiguate
|
||||
// same-named rules at a glance (t-paliad-321).
|
||||
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
|
||||
seen := map[int]bool{}
|
||||
var ids []int
|
||||
for _, r := range rows {
|
||||
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
|
||||
seen[*r.ProceedingTypeID] = true
|
||||
ids = append(ids, *r.ProceedingTypeID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type pair struct {
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
}
|
||||
var pairs []pair
|
||||
if err := s.db.SelectContext(ctx, &pairs,
|
||||
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
|
||||
}
|
||||
out := make(map[int]string, len(pairs))
|
||||
for _, p := range pairs {
|
||||
out[p.ID] = p.Code
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single rule. Exported so the handler can call it
|
||||
// directly without round-tripping through ListRules.
|
||||
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
|
||||
|
||||
Reference in New Issue
Block a user