From f691e3db3306ffa10f4e24c183bb6a9cae495b94 Mon Sep 17 00:00:00 2001 From: flfeders Date: Thu, 6 Aug 2026 12:24:43 +0200 Subject: [PATCH] Add tenant merge dry-run planner --- backend/src/utils/tenantMergePlan.ts | 149 ++++++++++++++++++++++++++ backend/tests/tenantMergePlan.test.ts | 56 ++++++++++ 2 files changed, 205 insertions(+) create mode 100644 backend/src/utils/tenantMergePlan.ts create mode 100644 backend/tests/tenantMergePlan.test.ts diff --git a/backend/src/utils/tenantMergePlan.ts b/backend/src/utils/tenantMergePlan.ts new file mode 100644 index 0000000..4a40c8a --- /dev/null +++ b/backend/src/utils/tenantMergePlan.ts @@ -0,0 +1,149 @@ +import { createHash } from "crypto" + +export type TenantMergeKind = "import" | "existing" | "conflict" | "id_collision" +export type TenantMergeDecision = "source" | "target" + +export type TenantMergeTableMetadata = { + primaryKey: string[] +} + +export type TenantMergeItem = { + id: string + table: string + kind: TenantMergeKind + sourceKey: string + targetKey: string | null + label: string + defaultDecision: TenantMergeDecision + allowedDecisions: TenantMergeDecision[] + differences: string[] + sourceRow: Record + targetRow: Record | null +} + +export type TenantMergePlan = { + items: TenantMergeItem[] + summary: Record +} + +const ignoredComparisonColumns = new Set([ + "created_at", + "createdAt", + "updated_at", + "updatedAt", + "updated_by", + "updatedBy", +]) + +const normalizeText = (value: unknown) => String(value ?? "").trim().toLocaleLowerCase("de") + +const naturalKeyColumns: Record = { + accounts: ["accountChart", "number"], + units: ["name"], + countrys: ["name"], + citys: ["zip", "short", "districtCode"], +} + +const comparableRow = (row: Record) => Object.fromEntries( + Object.entries(row) + .filter(([column]) => !ignoredComparisonColumns.has(column)) + .sort(([left], [right]) => left.localeCompare(right)) +) + +const rowDifferences = (source: Record, target: Record) => { + const sourceComparable = comparableRow(source) + const targetComparable = comparableRow(target) + const columns = Array.from(new Set([ + ...Object.keys(sourceComparable), + ...Object.keys(targetComparable), + ])).sort() + + return columns.filter((column) => + JSON.stringify(sourceComparable[column]) !== JSON.stringify(targetComparable[column]) + ) +} + +const rowKey = (row: Record, columns: string[]) => + columns.map((column) => `${column}=${JSON.stringify(row[column] ?? null)}`).join("|") + +const naturalKey = (table: string, row: Record) => { + const columns = naturalKeyColumns[table] + if (!columns) return null + + const values = columns.map((column) => row[column]) + if (values.every((value) => value === null || typeof value === "undefined" || value === "")) return null + + return columns.map((column, index) => `${column}=${normalizeText(values[index])}`).join("|") +} + +const itemId = (table: string, sourceKey: string) => + createHash("sha256").update(`${table}\0${sourceKey}`).digest("hex").slice(0, 24) + +const itemLabel = (table: string, row: Record, fallback: string) => { + const descriptive = row.name ?? row.label ?? row.title ?? row.number ?? row.email ?? row.filename + return descriptive ? `${descriptive}` : `${table}: ${fallback}` +} + +export const buildTenantMergePlan = ( + sourceTables: Record[]>, + targetTables: Record[]>, + metadata: Record +): TenantMergePlan => { + const items: TenantMergeItem[] = [] + + for (const table of Object.keys(sourceTables).sort()) { + const primaryKey = metadata[table]?.primaryKey?.length ? metadata[table].primaryKey : ["id"] + const targetRows = targetTables[table] || [] + const targetByPrimaryKey = new Map(targetRows.map((row) => [rowKey(row, primaryKey), row])) + const targetByNaturalKey = new Map>() + + for (const targetRow of targetRows) { + const key = naturalKey(table, targetRow) + if (key) targetByNaturalKey.set(key, targetRow) + } + + for (const sourceRow of sourceTables[table] || []) { + const sourceKey = rowKey(sourceRow, primaryKey) + const sourceNaturalKey = naturalKey(table, sourceRow) + const naturalTarget = sourceNaturalKey ? targetByNaturalKey.get(sourceNaturalKey) : undefined + const primaryTarget = targetByPrimaryKey.get(sourceKey) + const targetRow = naturalTarget || primaryTarget || null + const differences = targetRow ? rowDifferences(sourceRow, targetRow) : [] + let kind: TenantMergeKind + + if (!targetRow) { + kind = "import" + } else if (!differences.length) { + kind = "existing" + } else if (primaryTarget && sourceNaturalKey && naturalKey(table, primaryTarget) !== sourceNaturalKey) { + kind = "id_collision" + } else { + kind = "conflict" + } + + items.push({ + id: itemId(table, sourceKey), + table, + kind, + sourceKey, + targetKey: targetRow ? rowKey(targetRow, primaryKey) : null, + label: itemLabel(table, sourceRow, sourceKey), + defaultDecision: kind === "import" || kind === "id_collision" ? "source" : "target", + allowedDecisions: kind === "conflict" ? ["target", "source"] : [kind === "existing" ? "target" : "source"], + differences, + sourceRow, + targetRow, + }) + } + } + + return { + items, + summary: { + import: items.filter((item) => item.kind === "import").length, + existing: items.filter((item) => item.kind === "existing").length, + conflict: items.filter((item) => item.kind === "conflict").length, + id_collision: items.filter((item) => item.kind === "id_collision").length, + }, + } +} diff --git a/backend/tests/tenantMergePlan.test.ts b/backend/tests/tenantMergePlan.test.ts new file mode 100644 index 0000000..61b35cb --- /dev/null +++ b/backend/tests/tenantMergePlan.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { buildTenantMergePlan } from "../src/utils/tenantMergePlan" + +test("classifies imports, existing rows, conflicts and global id collisions", () => { + const plan = buildTenantMergePlan({ + units: [ + { id: 1, name: "Stück", short: "Stk." }, + { id: 2, name: "Monat", short: "Mon." }, + { id: 3, name: "Stunde", short: "Std." }, + ], + customers: [ + { id: 10, tenant: 42, name: "Neu" }, + { id: 11, tenant: 42, name: "Geändert" }, + ], + }, { + units: [ + { id: 1, name: "Stück", short: "Stk.", updated_at: "later" }, + { id: 2, name: "Kilometer", short: "km" }, + { id: 9, name: "Stunde", short: "h" }, + ], + customers: [ + { id: 11, tenant: 42, name: "Zieländerung" }, + ], + }, { + units: { primaryKey: ["id"] }, + customers: { primaryKey: ["id"] }, + }) + + assert.deepEqual(plan.summary, { + import: 1, + existing: 1, + conflict: 2, + id_collision: 1, + }) + + assert.equal(plan.items.find((item) => item.label === "Monat")?.kind, "id_collision") + assert.equal(plan.items.find((item) => item.label === "Stunde")?.kind, "conflict") + assert.equal(plan.items.find((item) => item.label === "Geändert")?.defaultDecision, "target") +}) + +test("uses target as the safe default for two-way conflicts", () => { + const plan = buildTenantMergePlan({ + accounts: [{ id: 5, accountChart: "skr03", number: "8400", label: "Quelle" }], + }, { + accounts: [{ id: 99, accountChart: "skr03", number: "8400", label: "Ziel" }], + }, { + accounts: { primaryKey: ["id"] }, + }) + + assert.equal(plan.items[0].kind, "conflict") + assert.equal(plan.items[0].targetKey, "id=99") + assert.equal(plan.items[0].defaultDecision, "target") + assert.deepEqual(plan.items[0].differences.sort(), ["id", "label"]) +})