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