MAP-01: Tenant-Export und Slug/Domain-Abgleich #254
No files matched your search
@@ -0,0 +1,72 @@
|
||||
// map01-tenant-match fuehrt MAP-01 aus: Export der tenants-Tabellen aus
|
||||
// archivmail und archivdms sowie Slug-/Domain-Abgleichsvorschlag,
|
||||
// uebersteuerbar durch eine manuelle Kunden-Zuordnungsliste. Ausgabe ist
|
||||
// eine Kandidatenliste (CSV), die menschlich geprueft in
|
||||
// tenant_migration_map (COR-01) uebernommen wird -- kein automatischer
|
||||
// Merge.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch"
|
||||
)
|
||||
|
||||
func main() {
|
||||
archivmailDSN := os.Getenv("MAP01_ARCHIVMAIL_DSN")
|
||||
if archivmailDSN == "" {
|
||||
log.Fatal("MAP01_ARCHIVMAIL_DSN nicht gesetzt")
|
||||
}
|
||||
archivdmsDSN := os.Getenv("MAP01_ARCHIVDMS_DSN")
|
||||
if archivdmsDSN == "" {
|
||||
log.Fatal("MAP01_ARCHIVDMS_DSN nicht gesetzt")
|
||||
}
|
||||
manualListPath := os.Getenv("MAP01_MANUAL_LIST_CSV")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
amPool, err := db.Connect(ctx, archivmailDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("archivmail-verbindung: %v", err)
|
||||
}
|
||||
defer amPool.Close()
|
||||
|
||||
dmsPool, err := db.Connect(ctx, archivdmsDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("archivdms-verbindung: %v", err)
|
||||
}
|
||||
defer dmsPool.Close()
|
||||
|
||||
archivmailTenants, err := tenantmatch.ExportTenants(ctx, amPool, "archivmail")
|
||||
if err != nil {
|
||||
log.Fatalf("archivmail-export: %v", err)
|
||||
}
|
||||
archivdmsTenants, err := tenantmatch.ExportTenants(ctx, dmsPool, "archivdms")
|
||||
if err != nil {
|
||||
log.Fatalf("archivdms-export: %v", err)
|
||||
}
|
||||
|
||||
var manual []tenantmatch.ManualDecision
|
||||
if manualListPath != "" {
|
||||
f, err := os.Open(manualListPath)
|
||||
if err != nil {
|
||||
log.Fatalf("kunden-zuordnungsliste oeffnen: %v", err)
|
||||
}
|
||||
manual, err = tenantmatch.LoadManualDecisions(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
log.Fatalf("kunden-zuordnungsliste lesen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
candidates := tenantmatch.Match(archivmailTenants, archivdmsTenants, manual)
|
||||
|
||||
if err := tenantmatch.WriteCandidatesCSV(os.Stdout, candidates); err != nil {
|
||||
log.Fatalf("kandidatenliste schreiben: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("MAP-01: archivmail=%d archivdms=%d kandidaten=%d", len(archivmailTenants), len(archivdmsTenants), len(candidates))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# MAP-01 — Prüfprotokoll: Tenant-Export & Slug/Domain-Abgleich
|
||||
|
||||
Datum: 2026-10-13
|
||||
Testhost: root@192.168.1.204 (Wegwerf-Postgres-Cluster, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, eigenes Test-Schema pro Testlauf über `internal/testdb`, kein Bezug zu `dms_dev`/`archivdms`)
|
||||
|
||||
## Synthetische Testdaten — Substitutionsentscheidung
|
||||
|
||||
Der Ticket-Auftrag verlangt einen SQL-Export der tenants-Tabellen aus den echten Altsystemen archivmail und archivdms. Es wurde **zu keinem Zeitpunkt** eine Verbindung zu einer echten archivmail- oder archivdms-Datenbank aufgebaut und kein echter Export/Dump gelesen. Stattdessen:
|
||||
|
||||
- `internal/tenantmatch/export_test.go` legt eine eigene, synthetische `tenants`-Tabelle (Spalten `id`, `slug`, `name`, `domain`) im isolierten Test-Schema an und befüllt sie mit frei erfundenen Zeilen (Slugs `synth-mandant-a`/`b`/`c`, Namen „Synthetische GmbH A/B/C“, Domains `synth-a.example` usw.).
|
||||
- `internal/tenantmatch/match_test.go` verwendet ausschließlich synthetische Tenant-Paare (Slugs `synth-mandant-*`, `synth-conflict`, `synth-false-positive` usw., Namen „Synthetische GmbH …“, „Konflikt GmbH“, „Firma A GmbH“).
|
||||
- Die produktive `ExportTenants`-Funktion ist schemagenerisch: sie liest `SELECT id, slug, name, domain FROM tenants` über einen beliebigen, von außen übergebenen DSN. Gegen welche Datenbank sie läuft, entscheidet ausschließlich der Aufrufer (`MAP01_ARCHIVMAIL_DSN`/`MAP01_ARCHIVDMS_DSN` im CLI-Einstiegspunkt `cmd/map01-tenant-match`) — dieser Agent hat diese Umgebungsvariablen nie auf eine echte archivmail-/archivdms-Instanz gesetzt und auch keinen Zugriff darauf angefordert.
|
||||
|
||||
Diese Substitution folgt demselben Muster wie COR-01/COR-02: Logik bauen und mit synthetischen, aber strukturell identischen Fixtures testen, keine realen Mandantendaten im Repository oder in Prompts.
|
||||
|
||||
## Ausgangslage geprüft
|
||||
|
||||
- Keine internen Vorbedingungen im Code — MAP-01 ist laut Auftrag sofort startbar.
|
||||
- `migrations/0019_tenant_migration_map.up.sql` (von COR-01 als Stub angelegt, eigentlich MAP-02-Scope) wurde gelesen: Zielschema erwartet `archivmail_tenant_id`, `archivdms_tenant_id`, `slug`, `name`, `approved_by`, `approved_at`. Die Kandidatenausgabe von MAP-01 liefert bewusst exakt die Spalten `archivmail_tenant_id`/`archivdms_tenant_id`/`slug`/`name`, ergänzt um `confidence`/`source`/`conflict`/`conflict_reason` für die menschliche Freigabeprüfung — `approved_by`/`approved_at` bleiben, wie in COR-01 dokumentiert, Sache des (noch zu bauenden) MAP-02-Freigabe-Workflows.
|
||||
- Kein vorhandenes Schema für archivmail-/archivdms-`tenants`-Tabellen im Repository gefunden; das Ticket selbst nennt nur `slug`, `name`, `Domain` als Pflichtfelder — dieses Minimalschema wurde für Export/Fixtures übernommen (Spaltenname `domain`, da keine andere Konvention im Code vorgefunden wurde).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- Neues Paket `internal/tenantmatch`:
|
||||
- `export.go`: `ExportTenants` (generischer SQL-Export aus einer `tenants`-Tabelle, parametrisiert nur über Pool und Quellsystem-Namen) und `CountTenants` (Zeilenzahl für die Vollständigkeitsprüfung).
|
||||
- `manual.go`: `LoadManualDecisions` liest die manuelle Kunden-Zuordnungsliste aus CSV (`archivmail_slug,archivdms_slug,match,note`).
|
||||
- `match.go`: `Match` erzeugt die Kandidatenliste. Automatischer Abgleich zuerst per exaktem Slug (Konfidenz 1.0), dann per exakter Domain (Konfidenz 0.7) als Fallback. Die manuelle Liste übersteuert: bestätigt sie den automatischen Vorschlag, bleibt die Konfidenz hoch und es gibt keinen Konflikt; weicht sie ab (anderes Ziel oder explizites „kein Match“ trotz automatischem Treffer), wird der Kandidat mit `Conflict = true` und einer `ConflictReason` ausgegeben — **nie** eine stille automatische Entscheidung. Tenants ohne jeden Treffer (weder automatisch noch manuell) werden als einseitiger Kandidat (nur eine der beiden IDs gesetzt, Konfidenz 0) ausgegeben, damit sie in der Freigabeprüfung sichtbar bleiben statt zu verschwinden.
|
||||
- `csv_out.go`: `WriteCandidatesCSV` schreibt die Kandidatenliste im Format `archivmail_tenant_id,archivdms_tenant_id,slug,name,confidence,source,conflict,conflict_reason`.
|
||||
- Neuer CLI-Einstiegspunkt `cmd/map01-tenant-match` (liest `MAP01_ARCHIVMAIL_DSN`, `MAP01_ARCHIVDMS_DSN`, optional `MAP01_MANUAL_LIST_CSV` aus der Umgebung, keine Zugangsdaten im Code, schreibt Kandidatenliste nach stdout).
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Beide tenants-Tabellen vollständig exportiert, inkl. slug/name/Domain | Erfüllt — `ExportTenants` liest alle Spalten, `TestExportTenants_Vollstaendigkeit` prüft Export-Zeilenzahl gegen `CountTenants` der Quelltabelle |
|
||||
| 2 | Automatischer Abgleichsvorschlag erzeugt Kandidatenliste mit Konfidenzwert | Erfüllt — jeder `Candidate` trägt `Confidence` (1.0 Slug-Match, 0.7 Domain-Fallback, 0.0 unmatched), `TestMatch_ZehnBekannteDuplikateKorrektVorgeschlagen` deckt 10 synthetische Duplikat-Paare ab |
|
||||
| 3 | Manuelle Zuordnungsliste kann Konflikte des automatischen Abgleichs übersteuern | Erfüllt — `TestMatch_KonfliktZwischenAutoUndManuellSichtbarMarkiert` und `TestMatch_ManuelleListeKannAutomatischenVorschlagAlsKeinMatchMarkieren` zeigen, dass die manuelle Entscheidung das Ergebnis bestimmt, der Konflikt aber als solcher markiert bleibt |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
1. **Export-Vollständigkeit gegen Quelltabellen-Zeilenzahl geprüft.**
|
||||
Ergebnis: erreicht — `TestExportTenants_Vollstaendigkeit`, 3 synthetische Zeilen eingefügt, `len(ExportTenants(...)) == CountTenants(...) == 3`, alle Felder (`slug`/`name`/`domain`/`id`) stimmen exakt.
|
||||
|
||||
2. **Stichprobe von 10 bekannten Duplikat-Tenants korrekt vorgeschlagen.**
|
||||
Ergebnis: erreicht — `TestMatch_ZehnBekannteDuplikateKorrektVorgeschlagen`, 10 synthetische Tenant-Paare mit identischem Slug auf beiden Seiten, alle 10 werden mit korrekten IDs, Konfidenz 1.0, `Source = "slug"`, `Conflict = false` vorgeschlagen.
|
||||
|
||||
3. **Konflikt zwischen automatischem Vorschlag und manueller Liste wird sichtbar markiert, nicht stillschweigend aufgelöst.**
|
||||
Ergebnis: erreicht — zwei Szenarien getestet: (a) automatischer Slug-Match vs. abweichende manuelle Zuordnung (`TestMatch_KonfliktZwischenAutoUndManuellSichtbarMarkiert`) — manuelle Zuordnung gewinnt, `Conflict = true`, `ConflictReason` nennt den automatischen Vorschlag; (b) automatischer Slug-Match vs. manuelles „kein Match“ (`TestMatch_ManuelleListeKannAutomatischenVorschlagAlsKeinMatchMarkieren`) — Kandidat bleibt einseitig, `Conflict = true`.
|
||||
|
||||
Zusätzlich (Regression/Build):
|
||||
```
|
||||
go build ./... -> keine Fehler
|
||||
go vet ./internal/tenantmatch/... ./cmd/map01-tenant-match/... -> keine Befunde
|
||||
TEST_ADMIN_DSN=... go test ./internal/tenantmatch/... -v -> alle 7 Tests PASS
|
||||
```
|
||||
|
||||
## Entscheidungen (dokumentiert, keine Rückfrage nötig)
|
||||
|
||||
- Schema-Annahme `tenants(id, slug, name, domain)` für beide Altsysteme, da im Repository kein vorhandenes Schema für archivmail-/archivdms-`tenants` gefunden wurde und das Ticket selbst nur diese drei fachlichen Felder nennt. Falls das reale Schema abweicht (andere Spaltennamen, zusätzliche Felder), muss `ExportTenants` vor dem echten Cutover angepasst werden — das ist hier dokumentiert, keine Produktentscheidung dieser Kachel.
|
||||
- Matching-Reihenfolge Slug (hart, Konfidenz 1.0) vor Domain (weicher Fallback, Konfidenz 0.7): folgt der Produkt-DNA „Freitext-Firmennamen sind zu unreliable“ — Name wird bewusst **nicht** als Matching-Kriterium verwendet, nur zur Anzeige im Kandidaten.
|
||||
- Manuelle Liste ist slug-indiziert auf archivmail-Seite (ein Eintrag pro archivmail-Slug). Mehrdeutige manuelle Zuordnungen (mehrere Entscheidungen für denselben archivmail-Slug) werden nicht unterstützt; das CSV-Format geht von einer gepflegten, eindeutigen Liste aus, wie im Ticket beschrieben.
|
||||
- Kein Schreibzugriff auf `tenant_migration_map` in dieser Kachel: MAP-01 liefert ausschließlich die Kandidatenliste (CSV nach stdout), die Übernahme in die Freigabe-Tabelle inkl. UI/Workflow bleibt MAP-02-Scope (wie in COR-01 bereits für die Freigabe-Oberfläche dokumentiert).
|
||||
- Keine echte Verbindung zu archivmail/archivdms aufgebaut oder versucht — CLI-DSNs blieben während der gesamten Session ungesetzt, alle Tests liefen gegen synthetische Fixtures auf dem Wegwerf-Testhost.
|
||||
|
||||
## Offene Punkte für MAP-02 / die echte Cutover-Vorbereitung
|
||||
|
||||
- MAP-02 muss die Kandidatenliste von MAP-01 einlesen und daraus die Freigabe-Oberfläche/den Workflow bauen, der `approved_by`/`approved_at` in `tenant_migration_map` befüllt — MAP-01 selbst schreibt nichts in diese Tabelle.
|
||||
- Vor dem echten Export muss das tatsächliche Schema der archivmail-/archivdms-`tenants`-Tabellen gegen die hier getroffene Annahme (`id, slug, name, domain`) geprüft werden.
|
||||
- Die Konfidenzschwellen (1.0 Slug, 0.7 Domain) sind ein Startwert; ob MAP-02 z.B. eine Mindestkonfidenz für automatische Vorauswahl in der UI verlangt, ist eine Produktentscheidung für MAP-02, nicht für MAP-01.
|
||||
@@ -0,0 +1,45 @@
|
||||
package tenantmatch
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// WriteCandidatesCSV schreibt die Kandidatenliste im Format
|
||||
// archivmail_tenant_id,archivdms_tenant_id,slug,name,confidence,source,conflict,conflict_reason
|
||||
// -- direkt als Eingabe fuer die menschliche Freigabe in
|
||||
// tenant_migration_map (COR-01/MAP-02) nutzbar.
|
||||
func WriteCandidatesCSV(w io.Writer, candidates []Candidate) error {
|
||||
cw := csv.NewWriter(w)
|
||||
defer cw.Flush()
|
||||
|
||||
header := []string{"archivmail_tenant_id", "archivdms_tenant_id", "slug", "name", "confidence", "source", "conflict", "conflict_reason"}
|
||||
if err := cw.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
record := []string{
|
||||
idOrEmpty(c.ArchivmailTenantID),
|
||||
idOrEmpty(c.ArchivdmsTenantID),
|
||||
c.Slug,
|
||||
c.Name,
|
||||
fmt.Sprintf("%.2f", c.Confidence),
|
||||
c.Source,
|
||||
fmt.Sprintf("%t", c.Conflict),
|
||||
c.ConflictReason,
|
||||
}
|
||||
if err := cw.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
func idOrEmpty(id *int64) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", *id)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package tenantmatch implementiert MAP-01: Export der tenants-Tabellen aus
|
||||
// archivmail und archivdms sowie den Slug-/Domain-Abgleichsvorschlag als
|
||||
// Eingabe fuer die menschliche Freigabe in tenant_migration_map (COR-01).
|
||||
package tenantmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ExportedTenant ist eine Zeile aus der tenants-Tabelle eines Altsystems.
|
||||
type ExportedTenant struct {
|
||||
Source string
|
||||
SourceID int64
|
||||
Slug string
|
||||
Name string
|
||||
Domain string
|
||||
}
|
||||
|
||||
// ExportTenants liest slug/name/domain aus der tenants-Tabelle eines
|
||||
// Altsystems. source identifiziert den Herkunftssystem-Namen ("archivmail"
|
||||
// oder "archivdms") fuer die spaetere Zuordnung der Kandidaten.
|
||||
func ExportTenants(ctx context.Context, pool *pgxpool.Pool, source string) ([]ExportedTenant, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT id, slug, name, domain FROM tenants ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenants-export (%s): %w", source, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ExportedTenant
|
||||
for rows.Next() {
|
||||
var e ExportedTenant
|
||||
if err := rows.Scan(&e.SourceID, &e.Slug, &e.Name, &e.Domain); err != nil {
|
||||
return nil, fmt.Errorf("tenants-zeile lesen (%s): %w", source, err)
|
||||
}
|
||||
e.Source = source
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountTenants liefert die Zeilenzahl der Quelltabelle, um die
|
||||
// Export-Vollstaendigkeit zu pruefen (Pruefung 1 des Tickets).
|
||||
func CountTenants(ctx context.Context, pool *pgxpool.Pool) (int, error) {
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM tenants`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("tenants-zeilenzahl: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package tenantmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
||||
)
|
||||
|
||||
// Integrationstest gegen eine echte Postgres-Instanz mit frei erfundenen
|
||||
// Tenant-Zeilen (Slugs wie "synth-mandant-a", Namen wie "Synthetische
|
||||
// GmbH") -- niemals echte archivmail-/archivdms-Kundendaten. Siehe
|
||||
// docs/MAP-01-PRUEFPROTOKOLL.md fuer die dokumentierte Substitution. Ohne
|
||||
// TEST_ADMIN_DSN uebersprungen.
|
||||
func TestExportTenants_Vollstaendigkeit(t *testing.T) {
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
pool := testdb.Pool(t, adminDSN)
|
||||
defer pool.Close()
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id BIGINT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
domain TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
suffix := testdb.Suffix()
|
||||
rows := []ExportedTenant{
|
||||
{SourceID: 1, Slug: "synth-mandant-a-" + suffix, Name: "Synthetische GmbH A", Domain: "synth-a.example"},
|
||||
{SourceID: 2, Slug: "synth-mandant-b-" + suffix, Name: "Synthetische GmbH B", Domain: "synth-b.example"},
|
||||
{SourceID: 3, Slug: "synth-mandant-c-" + suffix, Name: "Synthetische GmbH C", Domain: ""},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO tenants (id, slug, name, domain) VALUES ($1,$2,$3,$4)`, r.SourceID, r.Slug, r.Name, r.Domain); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, r := range rows {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, r.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
wantCount, err := CountTenants(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("CountTenants: %v", err)
|
||||
}
|
||||
|
||||
got, err := ExportTenants(ctx, pool, "archivmail")
|
||||
if err != nil {
|
||||
t.Fatalf("ExportTenants: %v", err)
|
||||
}
|
||||
|
||||
// Pruefung 1: Export-Vollstaendigkeit gegen Quelltabellen-Zeilenzahl.
|
||||
if len(got) != wantCount {
|
||||
t.Fatalf("export unvollstaendig: habe %d zeilen, quelltabelle hat %d", len(got), wantCount)
|
||||
}
|
||||
|
||||
bySlug := make(map[string]ExportedTenant, len(got))
|
||||
for _, g := range got {
|
||||
if g.Source != "archivmail" {
|
||||
t.Fatalf("source nicht gesetzt: %+v", g)
|
||||
}
|
||||
bySlug[g.Slug] = g
|
||||
}
|
||||
for _, r := range rows {
|
||||
g, ok := bySlug[r.Slug]
|
||||
if !ok {
|
||||
t.Fatalf("zeile %q fehlt im export", r.Slug)
|
||||
}
|
||||
if g.Name != r.Name || g.Domain != r.Domain || g.SourceID != r.SourceID {
|
||||
t.Fatalf("zeile %q nicht korrekt exportiert: habe %+v, erwarte %+v", r.Slug, g, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package tenantmatch
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ManualDecision ist ein Eintrag der manuell gepflegten
|
||||
// Kunden-Zuordnungsliste (Fallback-/Override-Quelle, siehe Produkt-DNA:
|
||||
// kein automatischer Merge ohne Mensch).
|
||||
type ManualDecision struct {
|
||||
ArchivmailSlug string
|
||||
ArchivdmsSlug string
|
||||
Match bool
|
||||
Note string
|
||||
}
|
||||
|
||||
// LoadManualDecisions liest die Kunden-Zuordnungsliste im Format
|
||||
// archivmail_slug,archivdms_slug,match,note (match = "true"/"false").
|
||||
func LoadManualDecisions(r io.Reader) ([]ManualDecision, error) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.FieldsPerRecord = 4
|
||||
records, err := cr.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kunden-zuordnungsliste lesen: %w", err)
|
||||
}
|
||||
|
||||
var out []ManualDecision
|
||||
for i, rec := range records {
|
||||
if i == 0 && rec[0] == "archivmail_slug" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ManualDecision{
|
||||
ArchivmailSlug: rec[0],
|
||||
ArchivdmsSlug: rec[1],
|
||||
Match: rec[2] == "true",
|
||||
Note: rec[3],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package tenantmatch
|
||||
|
||||
import "fmt"
|
||||
|
||||
const (
|
||||
ConfidenceSlugMatch = 1.0
|
||||
ConfidenceDomainMatch = 0.7
|
||||
ConfidenceManualMatch = 1.0
|
||||
ConfidenceUnmatched = 0.0
|
||||
)
|
||||
|
||||
// Candidate ist ein Abgleichsvorschlag im Ausgabeformat von
|
||||
// tenant_migration_map (archivmail_tenant_id, archivdms_tenant_id, slug,
|
||||
// name), ergaenzt um Konfidenz und Konfliktmarkierung fuer die
|
||||
// Freigabe-Pruefung durch einen Menschen (COR-01/MAP-02 konsumieren diese
|
||||
// Kandidaten, approved_at bleibt dort bewusst leer).
|
||||
type Candidate struct {
|
||||
ArchivmailTenantID *int64
|
||||
ArchivdmsTenantID *int64
|
||||
Slug string
|
||||
Name string
|
||||
Confidence float64
|
||||
Source string
|
||||
Conflict bool
|
||||
ConflictReason string
|
||||
}
|
||||
|
||||
// Match erzeugt Abgleichsvorschlaege aus den Exporten beider Altsysteme,
|
||||
// uebersteuert durch die manuelle Kunden-Zuordnungsliste. Es wird nie
|
||||
// automatisch gemerged: Match liefert ausschliesslich Kandidaten, die
|
||||
// Freigabe-Tabelle (approved_at) bleibt menschlicher Entscheidung
|
||||
// vorbehalten.
|
||||
func Match(archivmail, archivdms []ExportedTenant, manual []ManualDecision) []Candidate {
|
||||
dmsBySlug := make(map[string]ExportedTenant, len(archivdms))
|
||||
dmsByDomain := make(map[string]ExportedTenant, len(archivdms))
|
||||
for _, d := range archivdms {
|
||||
dmsBySlug[d.Slug] = d
|
||||
if d.Domain != "" {
|
||||
dmsByDomain[d.Domain] = d
|
||||
}
|
||||
}
|
||||
|
||||
manualBySlug := make(map[string]ManualDecision, len(manual))
|
||||
for _, m := range manual {
|
||||
manualBySlug[m.ArchivmailSlug] = m
|
||||
}
|
||||
|
||||
consumedDMSSlugs := make(map[string]bool)
|
||||
var out []Candidate
|
||||
|
||||
for _, am := range archivmail {
|
||||
md, hasManual := manualBySlug[am.Slug]
|
||||
|
||||
autoTarget, autoConfidence, autoSource, hasAuto := autoMatch(am, dmsBySlug, dmsByDomain)
|
||||
|
||||
switch {
|
||||
case !hasManual && hasAuto:
|
||||
out = append(out, candidateFromMatch(am, autoTarget, autoConfidence, autoSource, false, ""))
|
||||
consumedDMSSlugs[autoTarget.Slug] = true
|
||||
|
||||
case !hasManual && !hasAuto:
|
||||
out = append(out, oneSidedArchivmail(am, "kein automatischer Vorschlag und keine manuelle Zuordnung"))
|
||||
|
||||
case hasManual && md.Match:
|
||||
target, found := dmsBySlug[md.ArchivdmsSlug]
|
||||
if !found {
|
||||
out = append(out, oneSidedArchivmail(am, "manuelle Zuordnung verweist auf unbekannten archivdms-slug "+md.ArchivdmsSlug))
|
||||
continue
|
||||
}
|
||||
conflict := hasAuto && autoTarget.Slug != target.Slug
|
||||
reason := ""
|
||||
if conflict {
|
||||
reason = fmt.Sprintf("automatischer Vorschlag (%s, Konfidenz %.1f) weicht von manueller Zuordnung (%s) ab", autoTarget.Slug, autoConfidence, target.Slug)
|
||||
}
|
||||
out = append(out, candidateFromMatch(am, target, ConfidenceManualMatch, "manual", conflict, reason))
|
||||
consumedDMSSlugs[target.Slug] = true
|
||||
|
||||
case hasManual && !md.Match:
|
||||
reason := ""
|
||||
if hasAuto {
|
||||
reason = fmt.Sprintf("automatischer Vorschlag (%s, Konfidenz %.1f) widerspricht manueller Entscheidung 'kein Match'", autoTarget.Slug, autoConfidence)
|
||||
}
|
||||
out = append(out, oneSidedArchivmailWithConflict(am, "manuelle Liste bestaetigt: kein Match"+ifNote(md.Note), hasAuto, reason))
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range archivdms {
|
||||
if consumedDMSSlugs[d.Slug] {
|
||||
continue
|
||||
}
|
||||
out = append(out, oneSidedArchivdms(d, "kein automatischer Vorschlag und keine manuelle Zuordnung auf archivmail-Seite"))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func autoMatch(am ExportedTenant, bySlug, byDomain map[string]ExportedTenant) (ExportedTenant, float64, string, bool) {
|
||||
if d, ok := bySlug[am.Slug]; ok {
|
||||
return d, ConfidenceSlugMatch, "slug", true
|
||||
}
|
||||
if am.Domain != "" {
|
||||
if d, ok := byDomain[am.Domain]; ok {
|
||||
return d, ConfidenceDomainMatch, "domain", true
|
||||
}
|
||||
}
|
||||
return ExportedTenant{}, 0, "", false
|
||||
}
|
||||
|
||||
func candidateFromMatch(am, dms ExportedTenant, confidence float64, source string, conflict bool, reason string) Candidate {
|
||||
amID := am.SourceID
|
||||
dmsID := dms.SourceID
|
||||
return Candidate{
|
||||
ArchivmailTenantID: &amID,
|
||||
ArchivdmsTenantID: &dmsID,
|
||||
Slug: am.Slug,
|
||||
Name: am.Name,
|
||||
Confidence: confidence,
|
||||
Source: source,
|
||||
Conflict: conflict,
|
||||
ConflictReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func oneSidedArchivmail(am ExportedTenant, reason string) Candidate {
|
||||
amID := am.SourceID
|
||||
return Candidate{
|
||||
ArchivmailTenantID: &amID,
|
||||
Slug: am.Slug,
|
||||
Name: am.Name,
|
||||
Confidence: ConfidenceUnmatched,
|
||||
Source: "unmatched",
|
||||
ConflictReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func oneSidedArchivmailWithConflict(am ExportedTenant, reason string, conflict bool, conflictReason string) Candidate {
|
||||
c := oneSidedArchivmail(am, reason)
|
||||
c.Conflict = conflict
|
||||
if conflictReason != "" {
|
||||
c.ConflictReason = reason + "; " + conflictReason
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func oneSidedArchivdms(d ExportedTenant, reason string) Candidate {
|
||||
dmsID := d.SourceID
|
||||
return Candidate{
|
||||
ArchivdmsTenantID: &dmsID,
|
||||
Slug: d.Slug,
|
||||
Name: d.Name,
|
||||
Confidence: ConfidenceUnmatched,
|
||||
Source: "unmatched",
|
||||
ConflictReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func ifNote(note string) string {
|
||||
if note == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + note + ")"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package tenantmatch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func findCandidate(t *testing.T, candidates []Candidate, slug string) Candidate {
|
||||
t.Helper()
|
||||
for _, c := range candidates {
|
||||
if c.Slug == slug {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("kein kandidat mit slug %q gefunden", slug)
|
||||
return Candidate{}
|
||||
}
|
||||
|
||||
// Pruefung 2: Stichprobe von 10 bekannten Duplikat-Tenants korrekt
|
||||
// vorgeschlagen -- alle rein synthetisch (Slugs "synth-mandant-*", Namen
|
||||
// "Synthetische GmbH *"), keine echten archivmail-/archivdms-Daten.
|
||||
func TestMatch_ZehnBekannteDuplikateKorrektVorgeschlagen(t *testing.T) {
|
||||
var archivmail, archivdms []ExportedTenant
|
||||
for i := 1; i <= 10; i++ {
|
||||
slug := "synth-mandant-" + string(rune('a'-1+i))
|
||||
name := "Synthetische GmbH " + string(rune('A'-1+i))
|
||||
archivmail = append(archivmail, ExportedTenant{SourceID: int64(i), Slug: slug, Name: name, Domain: slug + ".example"})
|
||||
archivdms = append(archivdms, ExportedTenant{SourceID: int64(100 + i), Slug: slug, Name: name, Domain: slug + ".example"})
|
||||
}
|
||||
|
||||
candidates := Match(archivmail, archivdms, nil)
|
||||
if len(candidates) != 10 {
|
||||
t.Fatalf("erwarte 10 kandidaten, habe %d", len(candidates))
|
||||
}
|
||||
for i := 1; i <= 10; i++ {
|
||||
slug := "synth-mandant-" + string(rune('a'-1+i))
|
||||
c := findCandidate(t, candidates, slug)
|
||||
if c.ArchivmailTenantID == nil || c.ArchivdmsTenantID == nil {
|
||||
t.Fatalf("slug %q: beide seiten sollten zugeordnet sein, habe %+v", slug, c)
|
||||
}
|
||||
if *c.ArchivmailTenantID != int64(i) || *c.ArchivdmsTenantID != int64(100+i) {
|
||||
t.Fatalf("slug %q: falsche ids zugeordnet: %+v", slug, c)
|
||||
}
|
||||
if c.Confidence != ConfidenceSlugMatch || c.Source != "slug" {
|
||||
t.Fatalf("slug %q: erwarte slug-match mit konfidenz %.1f, habe %+v", slug, ConfidenceSlugMatch, c)
|
||||
}
|
||||
if c.Conflict {
|
||||
t.Fatalf("slug %q: kein konflikt erwartet, habe %+v", slug, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_DomainFallbackBeiAbweichendemSlug(t *testing.T) {
|
||||
archivmail := []ExportedTenant{{SourceID: 1, Slug: "synth-am-slug", Name: "Synthetische GmbH", Domain: "synth.example"}}
|
||||
archivdms := []ExportedTenant{{SourceID: 101, Slug: "synth-dms-slug", Name: "Synthetische GmbH", Domain: "synth.example"}}
|
||||
|
||||
candidates := Match(archivmail, archivdms, nil)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("erwarte 1 kandidat, habe %d", len(candidates))
|
||||
}
|
||||
c := candidates[0]
|
||||
if c.Source != "domain" || c.Confidence != ConfidenceDomainMatch {
|
||||
t.Fatalf("erwarte domain-match mit niedrigerer konfidenz, habe %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_UnmatchedTenantsBleibenEinseitigSichtbar(t *testing.T) {
|
||||
archivmail := []ExportedTenant{{SourceID: 1, Slug: "synth-nur-mail", Name: "Nur Mail GmbH"}}
|
||||
archivdms := []ExportedTenant{{SourceID: 101, Slug: "synth-nur-dms", Name: "Nur DMS GmbH"}}
|
||||
|
||||
candidates := Match(archivmail, archivdms, nil)
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("erwarte 2 unmatched kandidaten, habe %d", len(candidates))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if c.ArchivmailTenantID != nil && c.ArchivdmsTenantID != nil {
|
||||
t.Fatalf("unmatched kandidat darf nicht beide seiten tragen: %+v", c)
|
||||
}
|
||||
if c.Confidence != ConfidenceUnmatched {
|
||||
t.Fatalf("unmatched kandidat sollte konfidenz 0 haben: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 3: Konflikt zwischen automatischem Vorschlag und manueller Liste
|
||||
// wird sichtbar markiert, nicht still aufgeloest.
|
||||
func TestMatch_KonfliktZwischenAutoUndManuellSichtbarMarkiert(t *testing.T) {
|
||||
archivmail := []ExportedTenant{{SourceID: 1, Slug: "synth-conflict", Name: "Konflikt GmbH"}}
|
||||
archivdms := []ExportedTenant{
|
||||
{SourceID: 101, Slug: "synth-conflict", Name: "Konflikt GmbH"},
|
||||
{SourceID: 102, Slug: "synth-conflict-richtig", Name: "Konflikt GmbH Richtig"},
|
||||
}
|
||||
manual := []ManualDecision{
|
||||
{ArchivmailSlug: "synth-conflict", ArchivdmsSlug: "synth-conflict-richtig", Match: true, Note: "bekannter Dublettenfall, Freitext-Name mehrdeutig"},
|
||||
}
|
||||
|
||||
candidates := Match(archivmail, archivdms, manual)
|
||||
c := findCandidate(t, candidates, "synth-conflict")
|
||||
|
||||
if !c.Conflict {
|
||||
t.Fatalf("konflikt zwischen automatischem slug-match und manueller zuordnung haette markiert werden muessen: %+v", c)
|
||||
}
|
||||
if c.ArchivdmsTenantID == nil || *c.ArchivdmsTenantID != 102 {
|
||||
t.Fatalf("manuelle zuordnung haette gewinnen sollen (102), habe %+v", c)
|
||||
}
|
||||
if !strings.Contains(c.ConflictReason, "automatischer Vorschlag") {
|
||||
t.Fatalf("konfliktbegruendung sollte den automatischen vorschlag nennen: %q", c.ConflictReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_ManuelleListeKannAutomatischenVorschlagAlsKeinMatchMarkieren(t *testing.T) {
|
||||
archivmail := []ExportedTenant{{SourceID: 1, Slug: "synth-false-positive", Name: "Firma A GmbH"}}
|
||||
archivdms := []ExportedTenant{{SourceID: 101, Slug: "synth-false-positive", Name: "Firma A GmbH Tochter"}}
|
||||
manual := []ManualDecision{
|
||||
{ArchivmailSlug: "synth-false-positive", Match: false, Note: "unterschiedliche Rechtstraeger trotz gleichem Slug"},
|
||||
}
|
||||
|
||||
candidates := Match(archivmail, archivdms, manual)
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("erwarte je einen einseitigen kandidaten pro seite, habe %d: %+v", len(candidates), candidates)
|
||||
}
|
||||
c := findCandidate(t, candidates, "synth-false-positive")
|
||||
if c.ArchivmailTenantID == nil || c.ArchivdmsTenantID != nil {
|
||||
t.Fatalf("manuell abgelehntes match darf keine archivdms-id tragen: %+v", c)
|
||||
}
|
||||
if !c.Conflict {
|
||||
t.Fatalf("widerspruch zwischen automatischem slug-match und manuellem 'kein match' haette markiert werden muessen: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadManualDecisions_ParstCSVMitHeader(t *testing.T) {
|
||||
csv := "archivmail_slug,archivdms_slug,match,note\nsynth-a,synth-a-dms,true,test\nsynth-b,,false,kein treffer\n"
|
||||
decisions, err := LoadManualDecisions(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManualDecisions: %v", err)
|
||||
}
|
||||
if len(decisions) != 2 {
|
||||
t.Fatalf("erwarte 2 eintraege, habe %d", len(decisions))
|
||||
}
|
||||
if decisions[0].ArchivmailSlug != "synth-a" || !decisions[0].Match {
|
||||
t.Fatalf("erster eintrag falsch geparst: %+v", decisions[0])
|
||||
}
|
||||
if decisions[1].Match {
|
||||
t.Fatalf("zweiter eintrag sollte match=false haben: %+v", decisions[1])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user