Add tenant merge dry-run planner
This commit is contained in:
149
backend/src/utils/tenantMergePlan.ts
Normal file
149
backend/src/utils/tenantMergePlan.ts
Normal file
@@ -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<string, any>
|
||||
targetRow: Record<string, any> | null
|
||||
}
|
||||
|
||||
export type TenantMergePlan = {
|
||||
items: TenantMergeItem[]
|
||||
summary: Record<TenantMergeKind, number>
|
||||
}
|
||||
|
||||
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<string, string[]> = {
|
||||
accounts: ["accountChart", "number"],
|
||||
units: ["name"],
|
||||
countrys: ["name"],
|
||||
citys: ["zip", "short", "districtCode"],
|
||||
}
|
||||
|
||||
const comparableRow = (row: Record<string, any>) => Object.fromEntries(
|
||||
Object.entries(row)
|
||||
.filter(([column]) => !ignoredComparisonColumns.has(column))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
)
|
||||
|
||||
const rowDifferences = (source: Record<string, any>, target: Record<string, any>) => {
|
||||
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<string, any>, columns: string[]) =>
|
||||
columns.map((column) => `${column}=${JSON.stringify(row[column] ?? null)}`).join("|")
|
||||
|
||||
const naturalKey = (table: string, row: Record<string, any>) => {
|
||||
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<string, any>, 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<string, Record<string, any>[]>,
|
||||
targetTables: Record<string, Record<string, any>[]>,
|
||||
metadata: Record<string, TenantMergeTableMetadata>
|
||||
): 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<string, Record<string, any>>()
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
56
backend/tests/tenantMergePlan.test.ts
Normal file
56
backend/tests/tenantMergePlan.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
Reference in New Issue
Block a user