feat(PROJ-52): Vollständigkeits-Reconciliation (Zähl-Report Mailserver vs. Archiv)
Täglicher Cron-Job (archivmail reconcile) berechnet pro Tenant/Quelle (SMTP-Journal, IMAP-Konto, POP3-Konto, Datei-Import) archivierte Mail-Zahlen, für IMAP zusätzlich einen Soll/Ist-Vergleich via UID-Tracking. Abweichungen über Schwellenwert erzeugen Audit-Log-Warnung. Neue Admin-Dashboard-Kachel "Vollständigkeits-Check" (letzte 7 Tage, Warn-Badge, CSV-Export). Schließt die "teilweise erfüllt"-Lücke bei Vollständigkeit im GoBD/DSGVO-Compliance-Check (VOI-Grundsatz 2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b286352d07
commit
be93614c9f
@@ -0,0 +1,120 @@
|
||||
// Package reconciliation implements the daily completeness reconciliation
|
||||
// report (PROJ-52). It counts newly archived mails per source (SMTP journal,
|
||||
// IMAP account, POP3 account, bulk import) and per day, persists the counts in
|
||||
// the reconciliation_reports table, and flags significant drops against the
|
||||
// trailing 7-day average via the audit log.
|
||||
//
|
||||
// The reconciliation is deliberately a read-only observer of the emails table
|
||||
// plus the IMAP UID-tracking state (imap_folder_state, PROJ-45). It never
|
||||
// mutates archived mail content and issues no additional IMAP logins — the
|
||||
// IMAP soll/ist comparison reuses the UID high-water marks already persisted by
|
||||
// the sync scheduler.
|
||||
package reconciliation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"archivmail/internal/audit"
|
||||
)
|
||||
|
||||
// Store owns the reconciliation_reports table and the reconciliation logic.
|
||||
// It uses its own connection pool so the CLI cron command and the daemon can
|
||||
// both operate it independently.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
audlog *audit.Logger // optional; when nil, anomalies are only logged
|
||||
}
|
||||
|
||||
// Report is a single persisted reconciliation row for one day and one source.
|
||||
type Report struct {
|
||||
Date time.Time `json:"date"`
|
||||
TenantID *int64 `json:"tenant_id"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID *int64 `json:"source_id"`
|
||||
ExpectedCount *int64 `json:"expected_count"`
|
||||
ArchivedCount int64 `json:"archived_count"`
|
||||
Delta *int64 `json:"delta"`
|
||||
}
|
||||
|
||||
// New connects to PostgreSQL and initialises the reconciliation schema.
|
||||
func New(dsn string, logger *slog.Logger) (*Store, error) {
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reconciliation: connect: %w", err)
|
||||
}
|
||||
s := &Store{pool: pool, logger: logger}
|
||||
if err := s.initSchema(context.Background()); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("reconciliation: init schema: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetAuditLogger wires an audit.Logger so anomalies are persisted as
|
||||
// tenant-visible `reconciliation_anomaly` audit entries. Optional.
|
||||
func (s *Store) SetAuditLogger(a *audit.Logger) { s.audlog = a }
|
||||
|
||||
// Close releases the connection pool.
|
||||
func (s *Store) Close() {
|
||||
if s.pool != nil {
|
||||
s.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// initSchema creates the reconciliation_reports table and its indexes.
|
||||
// Idempotent and safe on existing databases (CREATE ... IF NOT EXISTS).
|
||||
func (s *Store) initSchema(ctx context.Context) error {
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS reconciliation_reports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
tenant_id BIGINT,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id BIGINT,
|
||||
expected_count BIGINT,
|
||||
archived_count BIGINT NOT NULL DEFAULT 0,
|
||||
delta BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Upsert key. tenant_id and source_id are nullable and PostgreSQL treats
|
||||
// NULLs as distinct in a plain UNIQUE index, which would allow duplicate
|
||||
// rows for the (tenant-less / smtp) buckets. A COALESCE-based expression
|
||||
// index gives a single deterministic key per (date, tenant, source_type,
|
||||
// source_id). -1 is a safe sentinel because real tenant/account IDs are
|
||||
// positive BIGSERIALs.
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_recon_reports_key
|
||||
ON reconciliation_reports (date, COALESCE(tenant_id, -1), source_type, COALESCE(source_id, -1));
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Lookup index for the dashboard / CSV queries (per AC).
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_recon_reports_lookup
|
||||
ON reconciliation_reports (tenant_id, date, source_type);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceKey returns the canonical source identifier used in the API / CSV:
|
||||
// "smtp", "import", "imap:<account_id>", "pop3:<account_id>".
|
||||
func SourceKey(sourceType string, sourceID *int64) string {
|
||||
if sourceID != nil && (sourceType == "imap" || sourceType == "pop3") {
|
||||
return fmt.Sprintf("%s:%d", sourceType, *sourceID)
|
||||
}
|
||||
return sourceType
|
||||
}
|
||||
Reference in New Issue
Block a user