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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user