feat(archive): RET-02 Aufbewahrungsfristen-Engine
internal/retentionengine: Frist je Aufbewahrungsklasse als natives Postgres-INTERVAL, Stichtagsberechnung an Postgres delegiert statt eigener Kalenderrechnung (Schaltjahr/Monatsende-Referenzwerte real verifiziert: 2024-02-29+1y=2025-02-28, 2026-01-31+1mo=2026-02-28). Periodischer Job (ListExpiringObjects) beschraenkt sich per DISTINCT ON auf die juengste Klassenzuordnung je Objekt - sonst wuerden Objekte mit mehrfach geaenderter Klasse (RET-01-Historisierung) doppelt auftauchen, real mit einem Zwei-Zuordnungen-Testobjekt bewiesen. Scope bewusst eng gehalten: keine RET-05-Anbindung, keine Vernichtungslogik - das ist Ticket-Scope, dependsOn ist nur RET-01.
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
# RET-02 – Prüfprotokoll: Aufbewahrungsfristen-Engine
|
||||||
|
|
||||||
|
Voraussetzung RET-01 – erledigt, siehe eigenes Protokoll.
|
||||||
|
|
||||||
|
**Scope-Klarstellung:** Dieses Ticket ist die Fristen-BERECHNUNGS-Engine
|
||||||
|
(Frist je Klasse, Stichtagsberechnung, periodischer Ablauf-Job) —
|
||||||
|
`dependsOn: ["RET-01"]`, KEINE Abhängigkeit auf RET-05. Die DMS-/Mail-
|
||||||
|
seitige Registrierung als RET-05-Adapter-Konsument sowie
|
||||||
|
Vernichtungs-Job-Fehlerbehandlung (2xx/Requeue) sind NICHT Teil dieser
|
||||||
|
Kachel — das ist ein späteres, eigenes Ticket (vermutlich im
|
||||||
|
ARC-*/DOC-*-Umfeld). Bewusst nicht mitgebaut, um nicht über den
|
||||||
|
Ticket-Umfang hinaus zu implementieren.
|
||||||
|
|
||||||
|
## Grundsatzentscheidung: Postgres-INTERVAL statt eigener Kalenderrechnung
|
||||||
|
|
||||||
|
`retention_class_rules.duration` ist ein natives Postgres-`INTERVAL`
|
||||||
|
(z. B. `'10 years'`, `'6 months'`) — `ComputeDueDate` delegiert die
|
||||||
|
gesamte Stichtagsberechnung an Postgres selbst (`start + duration`),
|
||||||
|
statt eine eigene Schaltjahr-/Monatsende-Logik in Go nachzubauen, die
|
||||||
|
von der WHERE-Klausel des periodischen Jobs (dieselbe Arithmetik)
|
||||||
|
abweichen könnte. Referenzwerte für Akzeptanzkriterium 2 real gegen
|
||||||
|
Postgres verifiziert, nicht angenommen:
|
||||||
|
`2024-02-29 + 1 year = 2025-02-28`, `2026-01-31 + 1 month = 2026-02-28`.
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `migrations/0004_retention_class_rules.up.sql`/`.down.sql`.
|
||||||
|
- `internal/retentionengine.ConfigureClassRule` — eine Regel je Klasse
|
||||||
|
(`UPSERT`).
|
||||||
|
- `internal/retentionengine.ComputeDueDate` — delegiert an Postgres.
|
||||||
|
- `internal/retentionengine.ListExpiringObjects` — periodischer Job:
|
||||||
|
`DISTINCT ON (retention_object_id)` auf die JÜNGSTE Klassenzuordnung
|
||||||
|
beschränkt, sonst würde ein Objekt mit mehrfach geänderter Klasse
|
||||||
|
(RET-01s Historisierung) mehrfach im Ergebnis auftauchen.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Fristberechnung an Referenzdaten mit bekannten Ablaufdaten geprüft | **bestanden** — `TestComputeDueDate_KnownReferenceDates`: Schaltjahr (29.02.2024 + 1 Jahr → 28.02.2025) und Monatsende (31.01.2026 + 1 Monat → 28.02.2026), beide Werte vorab real gegen Postgres verifiziert |
|
||||||
|
| 2 | Job liefert bei leerem Bestand ein leeres, nicht fehlerhaftes Ergebnis | **bestanden** — `TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError` |
|
||||||
|
| 3 | Mehrfachausführung des Jobs erzeugt keine doppelten Einträge | **bestanden** — `TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges`: Objekt mit ZWEI historischen Klassenzuordnungen (beide abgelaufen), zwei Job-Läufe liefern je genau 1 Eintrag — ohne die `DISTINCT ON`-Einschränkung wäre es 2 gewesen |
|
||||||
|
|
||||||
|
## Echte Verdrahtung auf 192.168.1.131
|
||||||
|
|
||||||
|
- Migration real gegen `dms_tenant_test` angewendet — `retention_class_rules`
|
||||||
|
bestätigt vorhanden neben `retention_objects`/`retention_class_assignments`
|
||||||
|
- Kein systemd-Timer in diesem Ticket — "periodischer Job" ist die
|
||||||
|
Bibliotheksfunktion `ListExpiringObjects`; ihr tatsächlicher
|
||||||
|
Aufrufer/Zeitplan (systemd-Timer + Meldeweg für abgelaufene Objekte)
|
||||||
|
ist Aufgabe eines Folgetickets, das auch die Vernichtungslogik selbst
|
||||||
|
bringt (dieses Ticket berechnet nur, wer fällig ist — vernichtet
|
||||||
|
nichts)
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131, `make check`)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
go test ./... -p 1 -count=1 -> 10/10 Pakete mit Tests ok, 0 Fehlschläge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||||
|
Pflichtprüfungen real erfüllt — Stichtagsberechnung gegen real
|
||||||
|
verifizierte Postgres-Referenzwerte, Job-Idempotenz gegen ein Objekt mit
|
||||||
|
echter Mehrfach-Historie bewiesen (nicht nur behauptet).
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package retentionengine implementiert RET-02: Fristenmodell je
|
||||||
|
// Aufbewahrungsklasse mit Stichtagsberechnung und ein periodischer Job,
|
||||||
|
// der ablaufende Objekte ermittelt. Baut auf RET-01 (retention_objects,
|
||||||
|
// retention_class_assignments) auf — kennt weiter keine Modul-Interna
|
||||||
|
// (dieselbe Adapter-Disziplin).
|
||||||
|
package retentionengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigureClassRule legt die Frist (Postgres-INTERVAL, z. B. "10 years",
|
||||||
|
// "6 months") für eine Aufbewahrungsklasse fest oder ändert sie
|
||||||
|
// (Akzeptanzkriterium 1) — je Klasse GENAU eine aktive Regel.
|
||||||
|
func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, duration string) error {
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO retention_class_rules (retention_class, duration)
|
||||||
|
VALUES ($1, $2::interval)
|
||||||
|
ON CONFLICT (retention_class) DO UPDATE SET duration = EXCLUDED.duration
|
||||||
|
`, retentionClass, duration)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retentionengine: fristregel konfigurieren: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeDueDate berechnet den Stichtag aus Beginn (start) und der
|
||||||
|
// konfigurierten Frist der Klasse — DELEGIERT an Postgres' eigene
|
||||||
|
// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive
|
||||||
|
// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von
|
||||||
|
// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte.
|
||||||
|
func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) {
|
||||||
|
var due time.Time
|
||||||
|
err := pool.QueryRow(ctx, `
|
||||||
|
SELECT $1::timestamptz + r.duration
|
||||||
|
FROM retention_class_rules r WHERE r.retention_class = $2
|
||||||
|
`, start, retentionClass).Scan(&due)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err)
|
||||||
|
}
|
||||||
|
return due, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist.
|
||||||
|
type ExpiringObject struct {
|
||||||
|
RetentionObjectID string
|
||||||
|
ObjectType string
|
||||||
|
ObjectReference string
|
||||||
|
RetentionClass string
|
||||||
|
DueDate time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListExpiringObjects ist der periodische Job (Akzeptanzkriterium 3):
|
||||||
|
// liefert alle aktiven Retention-Objekte, deren Stichtag (aktuelle
|
||||||
|
// Klassenzuordnung + deren Frist) bis asOf erreicht ist. Betrachtet je
|
||||||
|
// Objekt AUSSCHLIESSLICH die JÜNGSTE Klassenzuordnung (`DISTINCT ON`) -
|
||||||
|
// ohne diese Einschränkung würde ein Objekt mit mehrfach geänderter
|
||||||
|
// Klasse (RET-01s Historisierung) mehrfach im Ergebnis auftauchen,
|
||||||
|
// genau der Doppelte-Einträge-Fehler, den Pflichtprüfung 3 ausschließt.
|
||||||
|
// Ein leerer Bestand liefert eine leere Liste, keinen Fehler
|
||||||
|
// (Akzeptanzkriterium/Pflichtprüfung 2).
|
||||||
|
func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]ExpiringObject, error) {
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
WITH latest_assignment AS (
|
||||||
|
SELECT DISTINCT ON (retention_object_id)
|
||||||
|
retention_object_id, retention_class, assigned_at
|
||||||
|
FROM retention_class_assignments
|
||||||
|
ORDER BY retention_object_id, assigned_at DESC
|
||||||
|
)
|
||||||
|
SELECT o.id, o.object_type, o.object_reference, a.retention_class,
|
||||||
|
a.assigned_at + r.duration AS due_date
|
||||||
|
FROM retention_objects o
|
||||||
|
JOIN latest_assignment a ON a.retention_object_id = o.id
|
||||||
|
JOIN retention_class_rules r ON r.retention_class = a.retention_class
|
||||||
|
WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1
|
||||||
|
ORDER BY due_date ASC
|
||||||
|
`, asOf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("retentionengine: ablaufende objekte ermitteln: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []ExpiringObject
|
||||||
|
for rows.Next() {
|
||||||
|
var e ExpiringObject
|
||||||
|
if err := rows.Scan(&e.RetentionObjectID, &e.ObjectType, &e.ObjectReference, &e.RetentionClass, &e.DueDate); err != nil {
|
||||||
|
return nil, fmt.Errorf("retentionengine: zeile lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package retentionengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { pool.Close() })
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||||
|
object_reference TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (object_type, object_reference)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||||
|
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||||
|
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||||
|
})
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustTime(t *testing.T, layout, value string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
tm, err := time.Parse(layout, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zeitangabe parsen: %v", err)
|
||||||
|
}
|
||||||
|
return tm
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDueDate_KnownReferenceDates ist Pruefung 1: Fristberechnung
|
||||||
|
// an Referenzdaten mit bekannten Ablaufdaten geprueft - inklusive
|
||||||
|
// Schaltjahr und Monatsende (Akzeptanzkriterium 2). Erwartete Werte real
|
||||||
|
// gegen Postgres verifiziert (dessen eigene INTERVAL-Arithmetik ist die
|
||||||
|
// Quelle der Wahrheit, keine eigene Nachbildung in Go).
|
||||||
|
func TestComputeDueDate_KnownReferenceDates(t *testing.T) {
|
||||||
|
pool := requireTestPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := ConfigureClassRule(ctx, pool, "klasse-1-jahr", "1 year"); err != nil {
|
||||||
|
t.Fatalf("regel konfigurieren: %v", err)
|
||||||
|
}
|
||||||
|
if err := ConfigureClassRule(ctx, pool, "klasse-1-monat", "1 month"); err != nil {
|
||||||
|
t.Fatalf("regel konfigurieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
start time.Time
|
||||||
|
retentionClass string
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "schaltjahr 29. februar plus 1 jahr",
|
||||||
|
start: mustTime(t, "2006-01-02", "2024-02-29"),
|
||||||
|
retentionClass: "klasse-1-jahr",
|
||||||
|
want: mustTime(t, "2006-01-02", "2025-02-28"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "monatsende 31. januar plus 1 monat",
|
||||||
|
start: mustTime(t, "2006-01-02", "2026-01-31"),
|
||||||
|
retentionClass: "klasse-1-monat",
|
||||||
|
want: mustTime(t, "2006-01-02", "2026-02-28"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := ComputeDueDate(ctx, pool, c.start, c.retentionClass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("computeduedate: %v", err)
|
||||||
|
}
|
||||||
|
if !got.UTC().Equal(c.want.UTC()) {
|
||||||
|
t.Fatalf("stichtag = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2.
|
||||||
|
func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) {
|
||||||
|
pool := requireTestPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := ListExpiringObjects(ctx, pool, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("erwartet keinen fehler bei leerem bestand, habe: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("erwartet leere liste, habe %d eintraege", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges ist
|
||||||
|
// Pruefung 3: Mehrfachausfuehrung des Jobs erzeugt keine doppelten
|
||||||
|
// Eintraege - real geprueft an einem Objekt mit MEHREREN historischen
|
||||||
|
// Klassenzuordnungen (RET-01s Historisierung), das ohne die
|
||||||
|
// DISTINCT-ON-Einschraenkung mehrfach im Ergebnis auftauchen wuerde.
|
||||||
|
func TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges(t *testing.T) {
|
||||||
|
pool := requireTestPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var objID string
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'doc-mehrfach') RETURNING id
|
||||||
|
`).Scan(&objID); err != nil {
|
||||||
|
t.Fatalf("objekt anlegen: %v", err)
|
||||||
|
}
|
||||||
|
past := time.Now().UTC().Add(-72 * time.Hour)
|
||||||
|
// zwei historische Zuordnungen fuer DASSELBE Objekt, beide in der
|
||||||
|
// Vergangenheit (also beide laengst abgelaufen, wenn nicht auf die
|
||||||
|
// juengste beschraenkt wuerde).
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for run := 1; run <= 2; run++ {
|
||||||
|
got, err := ListExpiringObjects(ctx, pool, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lauf %d: %v", run, err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("lauf %d: erwartet genau 1 eintrag (kein duplikat trotz 2 historischer zuordnungen), habe %d: %+v", run, len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS retention_class_rules;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- RET-02: Fristenmodell je Aufbewahrungsklasse. duration ist ein
|
||||||
|
-- natives Postgres-INTERVAL statt eigener Tage-/Monatszaehlung, damit
|
||||||
|
-- Kalenderfaelle (Schaltjahr, Monatsende) exakt Postgres' eigene,
|
||||||
|
-- bewaehrte Intervall-Arithmetik nutzen statt eine eigene, potenziell
|
||||||
|
-- fehlerhafte Nachbildung.
|
||||||
|
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||||
|
retention_class TEXT PRIMARY KEY,
|
||||||
|
duration INTERVAL NOT NULL
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user