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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user