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>
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
package reconciliation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// DayPoint is one day's figures for a source in the dashboard response.
|
|
// ArchivedCount is nil (and Missing true) when no report row exists for that
|
|
// date — i.e. the cron job did not run — which is distinct from an archived
|
|
// count of 0 on a genuine zero-activity day.
|
|
type DayPoint struct {
|
|
Date string `json:"date"`
|
|
ArchivedCount *int64 `json:"archived_count"`
|
|
ExpectedCount *int64 `json:"expected_count"`
|
|
Delta *int64 `json:"delta"`
|
|
Missing bool `json:"missing"`
|
|
}
|
|
|
|
// SourceSummary aggregates the trailing days plus alert state for one source.
|
|
type SourceSummary struct {
|
|
SourceType string `json:"source_type"`
|
|
SourceID *int64 `json:"source_id"`
|
|
SourceKey string `json:"source_key"`
|
|
TenantID *int64 `json:"tenant_id"`
|
|
Points []DayPoint `json:"points"`
|
|
Avg7d float64 `json:"avg_7d"`
|
|
EnoughData bool `json:"enough_data"`
|
|
Alert bool `json:"alert"`
|
|
}
|
|
|
|
// DashboardData returns the last `days` calendar days of reconciliation figures
|
|
// per source, tenant-scoped. When tenantID is nil (superadmin) all tenants are
|
|
// included; otherwise only rows for that tenant are returned. thresholdPct is
|
|
// used to compute the per-source Alert flag consistently with the cron job.
|
|
func (s *Store) DashboardData(ctx context.Context, tenantID *int64, days, thresholdPct int) ([]SourceSummary, error) {
|
|
if days <= 0 {
|
|
days = 7
|
|
}
|
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
|
start := today.AddDate(0, 0, -(days - 1))
|
|
|
|
rows, err := s.queryRange(ctx, tenantID, start, today.Add(24*time.Hour))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Ordered date labels for the window.
|
|
dateLabels := make([]string, days)
|
|
for i := 0; i < days; i++ {
|
|
dateLabels[i] = start.AddDate(0, 0, i).Format("2006-01-02")
|
|
}
|
|
|
|
type srcAgg struct {
|
|
meta Report
|
|
byDate map[string]Report
|
|
}
|
|
agg := map[string]*srcAgg{}
|
|
for _, r := range rows {
|
|
key := SourceKey(r.SourceType, r.SourceID)
|
|
// Distinguish sources of different tenants sharing a key.
|
|
if r.TenantID != nil {
|
|
key = fmt.Sprintf("t%d/%s", *r.TenantID, key)
|
|
}
|
|
a, ok := agg[key]
|
|
if !ok {
|
|
a = &srcAgg{meta: r, byDate: map[string]Report{}}
|
|
agg[key] = a
|
|
}
|
|
a.byDate[r.Date.UTC().Format("2006-01-02")] = r
|
|
}
|
|
|
|
summaries := make([]SourceSummary, 0, len(agg))
|
|
for _, a := range agg {
|
|
sum := SourceSummary{
|
|
SourceType: a.meta.SourceType,
|
|
SourceID: a.meta.SourceID,
|
|
SourceKey: SourceKey(a.meta.SourceType, a.meta.SourceID),
|
|
TenantID: a.meta.TenantID,
|
|
Points: make([]DayPoint, 0, days),
|
|
}
|
|
for _, d := range dateLabels {
|
|
if r, ok := a.byDate[d]; ok {
|
|
c := r.ArchivedCount
|
|
sum.Points = append(sum.Points, DayPoint{
|
|
Date: d,
|
|
ArchivedCount: &c,
|
|
ExpectedCount: r.ExpectedCount,
|
|
Delta: r.Delta,
|
|
Missing: false,
|
|
})
|
|
} else {
|
|
sum.Points = append(sum.Points, DayPoint{Date: d, Missing: true})
|
|
}
|
|
}
|
|
|
|
// Alert against the trailing 7-day average of the most recent day that
|
|
// actually has a report row (mirrors the cron job's evaluation).
|
|
latest, latestDate, hasLatest := latestPresent(a.byDate, dateLabels)
|
|
if hasLatest {
|
|
avg, n, err := s.trailingAverage(ctx, a.meta, latestDate)
|
|
if err == nil && n >= 7 {
|
|
sum.Avg7d = avg
|
|
sum.EnoughData = true
|
|
limit := avg * (1 - float64(thresholdPct)/100.0)
|
|
if float64(latest.ArchivedCount) < limit {
|
|
sum.Alert = true
|
|
}
|
|
}
|
|
}
|
|
summaries = append(summaries, sum)
|
|
}
|
|
return summaries, nil
|
|
}
|
|
|
|
// latestPresent returns the most recent report row within the window that has a
|
|
// stored row, along with its date.
|
|
func latestPresent(byDate map[string]Report, dateLabels []string) (Report, time.Time, bool) {
|
|
for i := len(dateLabels) - 1; i >= 0; i-- {
|
|
if r, ok := byDate[dateLabels[i]]; ok {
|
|
d, _ := time.Parse("2006-01-02", dateLabels[i])
|
|
return r, d, true
|
|
}
|
|
}
|
|
return Report{}, time.Time{}, false
|
|
}
|
|
|
|
// ExportRows returns raw report rows for CSV export, tenant-scoped, for the
|
|
// last `days` days, ordered by date descending then source.
|
|
func (s *Store) ExportRows(ctx context.Context, tenantID *int64, days int) ([]Report, error) {
|
|
if days <= 0 {
|
|
days = 30
|
|
}
|
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
|
start := today.AddDate(0, 0, -(days - 1))
|
|
return s.queryRange(ctx, tenantID, start, today.Add(24*time.Hour))
|
|
}
|
|
|
|
// queryRange loads report rows in [start, end) filtered by tenant. tenantID nil
|
|
// returns all tenants (superadmin scope).
|
|
func (s *Store) queryRange(ctx context.Context, tenantID *int64, start, end time.Time) ([]Report, error) {
|
|
var (
|
|
sql string
|
|
args []interface{}
|
|
)
|
|
if tenantID == nil {
|
|
sql = `SELECT date, tenant_id, source_type, source_id, expected_count, archived_count, delta
|
|
FROM reconciliation_reports
|
|
WHERE date >= $1 AND date < $2
|
|
ORDER BY date DESC, source_type, source_id`
|
|
args = []interface{}{start, end}
|
|
} else {
|
|
sql = `SELECT date, tenant_id, source_type, source_id, expected_count, archived_count, delta
|
|
FROM reconciliation_reports
|
|
WHERE date >= $1 AND date < $2 AND tenant_id = $3
|
|
ORDER BY date DESC, source_type, source_id`
|
|
args = []interface{}{start, end, *tenantID}
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reconciliation: query range: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Report
|
|
for rows.Next() {
|
|
var r Report
|
|
if err := rows.Scan(&r.Date, &r.TenantID, &r.SourceType, &r.SourceID,
|
|
&r.ExpectedCount, &r.ArchivedCount, &r.Delta); err != nil {
|
|
return nil, fmt.Errorf("reconciliation: scan range: %w", err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|