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.
97 lines
3.8 KiB
Go
97 lines
3.8 KiB
Go
// 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()
|
|
}
|