Connect tenant merge planner to exports
This commit is contained in:
@@ -1025,41 +1025,14 @@ export const importTenantFullExportArchive = async (
|
|||||||
archiveBuffer: Buffer,
|
archiveBuffer: Buffer,
|
||||||
options: ImportOptions = {}
|
options: ImportOptions = {}
|
||||||
): Promise<ImportResult> => {
|
): Promise<ImportResult> => {
|
||||||
|
const rawExportData = await readTenantFullExportArchive(archiveBuffer)
|
||||||
|
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
|
||||||
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer])))
|
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer])))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await reader.getEntries()
|
const entries = await reader.getEntries()
|
||||||
const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry]))
|
const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry]))
|
||||||
const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest
|
const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest
|
||||||
|
|
||||||
if (manifest?.format !== "fedeo.tenant-archive-export" || manifest.version !== 1) {
|
|
||||||
throw new Error("Ungültiges FEDEO Mandantenarchiv-Format")
|
|
||||||
}
|
|
||||||
|
|
||||||
const tables: TableRows = {}
|
|
||||||
for (const table of manifest.tables || []) {
|
|
||||||
tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawExportData: TenantFullExport = {
|
|
||||||
format: "fedeo.tenant-full-export",
|
|
||||||
version: 1,
|
|
||||||
exportedAt: manifest.exportedAt,
|
|
||||||
tenantId: manifest.tenantId,
|
|
||||||
tables,
|
|
||||||
files: (manifest.files || []).map((file) => ({
|
|
||||||
id: file.id,
|
|
||||||
path: file.path,
|
|
||||||
name: file.name,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
size: file.size,
|
|
||||||
contentBase64: null,
|
|
||||||
missing: file.missing,
|
|
||||||
error: file.error,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
|
|
||||||
const sourceTenantId = rawExportData.tenantId
|
const sourceTenantId = rawExportData.tenantId
|
||||||
const targetTenantId = exportData.tenantId
|
const targetTenantId = exportData.tenantId
|
||||||
const sourcePrefix = `${sourceTenantId}/`
|
const sourcePrefix = `${sourceTenantId}/`
|
||||||
@@ -1086,3 +1059,43 @@ export const importTenantFullExportArchive = async (
|
|||||||
await reader.close()
|
await reader.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promise<TenantFullExport> => {
|
||||||
|
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer])))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await reader.getEntries()
|
||||||
|
const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry]))
|
||||||
|
const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest
|
||||||
|
|
||||||
|
if (manifest?.format !== "fedeo.tenant-archive-export" || manifest.version !== 1) {
|
||||||
|
throw new Error("Ungültiges FEDEO Mandantenarchiv-Format")
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables: TableRows = {}
|
||||||
|
for (const table of manifest.tables || []) {
|
||||||
|
tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: "fedeo.tenant-full-export",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: manifest.exportedAt,
|
||||||
|
tenantId: manifest.tenantId,
|
||||||
|
tables,
|
||||||
|
files: (manifest.files || []).map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
path: file.path,
|
||||||
|
name: file.name,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
contentBase64: null,
|
||||||
|
missing: file.missing,
|
||||||
|
error: file.error,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await reader.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
124
backend/src/utils/tenantMergeService.ts
Normal file
124
backend/src/utils/tenantMergeService.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { pool } from "../../db"
|
||||||
|
import type { TenantFullExport } from "./tenantFullExport"
|
||||||
|
import { buildTenantMergePlan, type TenantMergePlan, type TenantMergeTableMetadata } from "./tenantMergePlan"
|
||||||
|
|
||||||
|
type MergeDatabaseMetadata = TenantMergeTableMetadata & {
|
||||||
|
columns: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||||
|
const globalNaturalKeyTables = new Set(["accounts", "units", "countrys", "citys"])
|
||||||
|
|
||||||
|
const loadMergeMetadata = async (client: any) => {
|
||||||
|
const columnsResult = await client.query(`
|
||||||
|
select table_name, column_name
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
order by table_name, ordinal_position
|
||||||
|
`)
|
||||||
|
const primaryKeyResult = await client.query(`
|
||||||
|
select tc.table_name, kcu.column_name
|
||||||
|
from information_schema.table_constraints tc
|
||||||
|
join information_schema.key_column_usage kcu
|
||||||
|
on tc.constraint_name = kcu.constraint_name
|
||||||
|
and tc.constraint_schema = kcu.constraint_schema
|
||||||
|
where tc.table_schema = 'public' and tc.constraint_type = 'PRIMARY KEY'
|
||||||
|
order by tc.table_name, kcu.ordinal_position
|
||||||
|
`)
|
||||||
|
const metadata: Record<string, MergeDatabaseMetadata> = {}
|
||||||
|
|
||||||
|
for (const row of columnsResult.rows) {
|
||||||
|
metadata[row.table_name] ||= { columns: [], primaryKey: [] }
|
||||||
|
metadata[row.table_name].columns.push(row.column_name)
|
||||||
|
}
|
||||||
|
for (const row of primaryKeyResult.rows) {
|
||||||
|
metadata[row.table_name] ||= { columns: [], primaryKey: [] }
|
||||||
|
metadata[row.table_name].primaryKey.push(row.column_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const remapSourceTenant = (exportData: TenantFullExport, targetTenantId: number) => {
|
||||||
|
const tables: Record<string, Record<string, any>[]> = {}
|
||||||
|
|
||||||
|
for (const [table, rows] of Object.entries(exportData.tables || {})) {
|
||||||
|
tables[table] = rows.map((row) => {
|
||||||
|
const next = { ...row }
|
||||||
|
if (table === "tenants" && Number(next.id) === Number(exportData.tenantId)) next.id = targetTenantId
|
||||||
|
if (Number(next.tenant) === Number(exportData.tenantId)) next.tenant = targetTenantId
|
||||||
|
if (Number(next.tenant_id) === Number(exportData.tenantId)) next.tenant_id = targetTenantId
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTargetRows = async (
|
||||||
|
client: any,
|
||||||
|
table: string,
|
||||||
|
sourceRows: Record<string, any>[],
|
||||||
|
metadata: MergeDatabaseMetadata,
|
||||||
|
targetTenantId: number
|
||||||
|
) => {
|
||||||
|
if (!sourceRows.length) return []
|
||||||
|
if (table === "tenants") {
|
||||||
|
return (await client.query(`select * from ${quoteIdent(table)} where ${quoteIdent("id")} = $1`, [targetTenantId])).rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantColumn = metadata.columns.includes("tenant")
|
||||||
|
? "tenant"
|
||||||
|
: metadata.columns.includes("tenant_id") ? "tenant_id" : null
|
||||||
|
if (tenantColumn) {
|
||||||
|
return (await client.query(
|
||||||
|
`select * from ${quoteIdent(table)} where ${quoteIdent(tenantColumn)} = $1`,
|
||||||
|
[targetTenantId]
|
||||||
|
)).rows
|
||||||
|
}
|
||||||
|
if (globalNaturalKeyTables.has(table)) {
|
||||||
|
return (await client.query(`select * from ${quoteIdent(table)}`)).rows
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.primaryKey.length === 1) {
|
||||||
|
const key = metadata.primaryKey[0]
|
||||||
|
const values = Array.from(new Set(sourceRows.map((row) => row[key]).filter((value) => value !== null && typeof value !== "undefined").map(String)))
|
||||||
|
if (!values.length) return []
|
||||||
|
return (await client.query(
|
||||||
|
`select * from ${quoteIdent(table)} where ${quoteIdent(key)}::text = any($1::text[])`,
|
||||||
|
[values]
|
||||||
|
)).rows
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await client.query(`select * from ${quoteIdent(table)}`)).rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTenantMergeDryRunWithClient = async (
|
||||||
|
client: any,
|
||||||
|
exportData: TenantFullExport,
|
||||||
|
targetTenantId: number
|
||||||
|
): Promise<TenantMergePlan> => {
|
||||||
|
const metadata = await loadMergeMetadata(client)
|
||||||
|
const sourceTables = remapSourceTenant(exportData, targetTenantId)
|
||||||
|
const targetTables: Record<string, Record<string, any>[]> = {}
|
||||||
|
|
||||||
|
for (const [table, sourceRows] of Object.entries(sourceTables)) {
|
||||||
|
const tableMetadata = metadata[table]
|
||||||
|
if (!tableMetadata) continue
|
||||||
|
targetTables[table] = await loadTargetRows(client, table, sourceRows, tableMetadata, targetTenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTenantMergePlan(sourceTables, targetTables, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTenantMergeDryRun = async (
|
||||||
|
exportData: TenantFullExport,
|
||||||
|
targetTenantId: number
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
return await createTenantMergeDryRunWithClient(client, exportData, targetTenantId)
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user