Add tenant merge review page
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 39s
Build and Push Docker Images / build-frontend (push) Successful in 1m43s
Build and Push Docker Images / build-central-services-api (push) Successful in 23s
Build and Push Docker Images / build-docs (push) Successful in 24s
Build and Push Docker Images / build-website (push) Successful in 26s
Build and Push Docker Images / build-central-services-admin (push) Successful in 24s
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 39s
Build and Push Docker Images / build-frontend (push) Successful in 1m43s
Build and Push Docker Images / build-central-services-api (push) Successful in 23s
Build and Push Docker Images / build-docs (push) Successful in 24s
Build and Push Docker Images / build-website (push) Successful in 26s
Build and Push Docker Images / build-central-services-admin (push) Successful in 24s
This commit is contained in:
@@ -61,6 +61,35 @@ export type TenantImportResult = {
|
|||||||
filesDone?: number
|
filesDone?: number
|
||||||
filesTotal?: number
|
filesTotal?: number
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
reviewUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantMergeKind = "import" | "existing" | "conflict" | "id_collision"
|
||||||
|
export type TenantMergeDecision = "source" | "target"
|
||||||
|
export type TenantMergeItem = {
|
||||||
|
id: string
|
||||||
|
table: string
|
||||||
|
kind: TenantMergeKind
|
||||||
|
label: string
|
||||||
|
sourceKey: string
|
||||||
|
targetKey: string | null
|
||||||
|
defaultDecision: TenantMergeDecision
|
||||||
|
allowedDecisions: TenantMergeDecision[]
|
||||||
|
differences: string[]
|
||||||
|
sourceRow: Record<string, any>
|
||||||
|
targetRow: Record<string, any> | null
|
||||||
|
}
|
||||||
|
export type TenantMergeReview = {
|
||||||
|
importId: string
|
||||||
|
tenantId: number
|
||||||
|
status: string
|
||||||
|
filename: string
|
||||||
|
summary: Record<TenantMergeKind, number>
|
||||||
|
tables: string[]
|
||||||
|
total: number
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
items: TenantMergeItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TenantExportJob = {
|
export type TenantExportJob = {
|
||||||
@@ -222,6 +251,20 @@ export const useAdmin = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTenantImportReview = async (
|
||||||
|
importId: string,
|
||||||
|
query: { kind?: string; table?: string; offset?: number; limit?: number } = {},
|
||||||
|
): Promise<TenantMergeReview> => {
|
||||||
|
return await $api(`/api/admin/tenant-imports/${importId}/review`, { query })
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeTenantImportMerge = async (importId: string, decisions: Record<string, TenantMergeDecision>) => {
|
||||||
|
return await $api(`/api/admin/tenant-imports/${importId}/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { decisions },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getSystemStatus = async (): Promise<SystemStatus> => {
|
const getSystemStatus = async (): Promise<SystemStatus> => {
|
||||||
return await $api("/api/admin/system-status")
|
return await $api("/api/admin/system-status")
|
||||||
}
|
}
|
||||||
@@ -262,5 +305,7 @@ export const useAdmin = () => {
|
|||||||
getTenantExport,
|
getTenantExport,
|
||||||
downloadTenantExport,
|
downloadTenantExport,
|
||||||
importTenant,
|
importTenant,
|
||||||
|
getTenantImportReview,
|
||||||
|
executeTenantImportMerge,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
248
frontend/pages/administration/tenant-imports/[importId].vue
Normal file
248
frontend/pages/administration/tenant-imports/[importId].vue
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TenantMergeDecision, TenantMergeItem, TenantMergeKind, TenantMergeReview } from "~/composables/useAdmin"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const admin = useAdmin()
|
||||||
|
const importId = String(route.params.importId)
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const executing = ref(false)
|
||||||
|
const executionResult = ref<any>(null)
|
||||||
|
const review = ref<TenantMergeReview | null>(null)
|
||||||
|
const tenantId = computed(() => review.value?.tenantId || 0)
|
||||||
|
const decisions = reactive<Record<string, TenantMergeDecision>>({})
|
||||||
|
const conflictItems = ref<TenantMergeItem[]>([])
|
||||||
|
const kindFilter = ref<string>("")
|
||||||
|
const tableFilter = ref<string>("")
|
||||||
|
const offset = ref(0)
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
const kindOptions = [
|
||||||
|
{ label: "Alle Arten", value: "" },
|
||||||
|
{ label: "Wird importiert", value: "import" },
|
||||||
|
{ label: "Bereits vorhanden", value: "existing" },
|
||||||
|
{ label: "Konflikt", value: "conflict" },
|
||||||
|
{ label: "ID wird remappt", value: "id_collision" },
|
||||||
|
]
|
||||||
|
const kindLabels: Record<TenantMergeKind, string> = {
|
||||||
|
import: "Wird importiert",
|
||||||
|
existing: "Bereits vorhanden",
|
||||||
|
conflict: "Konflikt",
|
||||||
|
id_collision: "ID wird remappt",
|
||||||
|
}
|
||||||
|
const kindColors: Record<TenantMergeKind, string> = {
|
||||||
|
import: "success",
|
||||||
|
existing: "neutral",
|
||||||
|
conflict: "warning",
|
||||||
|
id_collision: "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedConflicts = computed(() => conflictItems.value.filter((item) => !decisions[item.id]).length)
|
||||||
|
const hasNextPage = computed(() => Boolean(review.value && review.value.offset + review.value.items.length < review.value.total))
|
||||||
|
|
||||||
|
const loadConflictDecisions = async () => {
|
||||||
|
conflictItems.value = []
|
||||||
|
let currentOffset = 0
|
||||||
|
while (true) {
|
||||||
|
const page = await admin.getTenantImportReview(importId, { kind: "conflict", offset: currentOffset, limit: 500 })
|
||||||
|
conflictItems.value.push(...page.items)
|
||||||
|
currentOffset += page.items.length
|
||||||
|
if (!page.items.length || currentOffset >= page.total) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReview = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
review.value = await admin.getTenantImportReview(importId, {
|
||||||
|
kind: kindFilter.value || undefined,
|
||||||
|
table: tableFilter.value || undefined,
|
||||||
|
offset: offset.value,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Merge-Bericht konnte nicht geladen werden",
|
||||||
|
description: err?.data?.error || err?.message,
|
||||||
|
color: "red",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAndLoad = async () => {
|
||||||
|
offset.value = 0
|
||||||
|
await loadReview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAllConflicts = (decision: TenantMergeDecision) => {
|
||||||
|
for (const item of conflictItems.value) decisions[item.id] = decision
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeMerge = async () => {
|
||||||
|
if (unresolvedConflicts.value || executing.value) return
|
||||||
|
executing.value = true
|
||||||
|
try {
|
||||||
|
const result: any = await admin.executeTenantImportMerge(importId, decisions)
|
||||||
|
executionResult.value = result
|
||||||
|
toast.add({
|
||||||
|
title: "Tenant-Merge abgeschlossen",
|
||||||
|
description: `${result?.result?.imported || 0} importiert, ${result?.result?.updated || 0} aus der Quelle übernommen.`,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: err?.data?.reviewRequired ? "Dry-Run wurde aktualisiert" : "Tenant-Merge fehlgeschlagen",
|
||||||
|
description: err?.data?.error || err?.message,
|
||||||
|
color: "red",
|
||||||
|
})
|
||||||
|
if (err?.data?.reviewRequired) {
|
||||||
|
Object.keys(decisions).forEach((key) => delete decisions[key])
|
||||||
|
await loadConflictDecisions()
|
||||||
|
await loadReview()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
executing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadReview(), loadConflictDecisions()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Tenant-Import prüfen</h1>
|
||||||
|
<p class="text-sm text-gray-500">Dry-Run {{ importId }}</p>
|
||||||
|
</div>
|
||||||
|
<UButton color="neutral" variant="soft" icon="i-heroicons-arrow-left" @click="router.push(`/administration/tenants/${tenantId}`)">
|
||||||
|
Zurück zum Tenant
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
title="Noch keine Daten wurden verändert"
|
||||||
|
description="Importierte und bereits vorhandene Datensätze werden automatisch behandelt. Für jeden Konflikt ist vor der Ausführung eine Entscheidung erforderlich."
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="executionResult"
|
||||||
|
title="Tenant-Merge abgeschlossen"
|
||||||
|
:description="`${executionResult.result.imported} Datensätze importiert, ${executionResult.result.updated} aus der Quelle übernommen, ${executionResult.result.retained} im Ziel beibehalten und ${executionResult.result.remappedIds} IDs remappt. Dateien: ${executionResult.files.restored} wiederhergestellt, ${executionResult.files.skipped} übersprungen.`"
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UCard v-if="executionResult">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold">Ausführung nach Tabelle</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead><tr><th class="py-2">Tabelle</th><th>Importiert</th><th>Quelle übernommen</th><th>Ziel behalten</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(counts, table) in executionResult.result.tables" :key="table" class="border-t">
|
||||||
|
<td class="py-2 font-medium">{{ table }}</td>
|
||||||
|
<td>{{ counts.imported }}</td>
|
||||||
|
<td>{{ counts.updated }}</td>
|
||||||
|
<td>{{ counts.retained }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<div v-if="review" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<UCard><p class="text-sm text-gray-500">Wird importiert</p><p class="text-2xl font-semibold">{{ review.summary.import }}</p></UCard>
|
||||||
|
<UCard><p class="text-sm text-gray-500">Vorhanden</p><p class="text-2xl font-semibold">{{ review.summary.existing }}</p></UCard>
|
||||||
|
<UCard><p class="text-sm text-gray-500">ID-Remapping</p><p class="text-2xl font-semibold">{{ review.summary.id_collision }}</p></UCard>
|
||||||
|
<UCard><p class="text-sm text-gray-500">Konflikte</p><p class="text-2xl font-semibold">{{ review.summary.conflict }}</p></UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<UFormField label="Art" class="min-w-48">
|
||||||
|
<USelect v-model="kindFilter" :items="kindOptions" value-key="value" label-key="label" @update:model-value="resetAndLoad" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Tabelle" class="min-w-56">
|
||||||
|
<USelect
|
||||||
|
v-model="tableFilter"
|
||||||
|
:items="[{ label: 'Alle Tabellen', value: '' }, ...(review?.tables || []).map((table) => ({ label: table, value: table }))]"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
@update:model-value="resetAndLoad"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<UButton color="neutral" variant="soft" @click="setAllConflicts('target')">Alle Konflikte: Ziel behalten</UButton>
|
||||||
|
<UButton color="warning" variant="soft" @click="setAllConflicts('source')">Alle Konflikte: Quelle übernehmen</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div v-if="loading" class="py-8 text-center text-gray-500">Dry-Run wird geladen …</div>
|
||||||
|
<div v-else-if="!review?.items.length" class="py-8 text-center text-gray-500">Keine Einträge für diesen Filter.</div>
|
||||||
|
<div v-else class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
<div v-for="item in review.items" :key="item.id" class="space-y-2 py-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UBadge :color="kindColors[item.kind] as any" variant="soft">{{ kindLabels[item.kind] }}</UBadge>
|
||||||
|
<span class="font-medium">{{ item.label }}</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ item.table }} · {{ item.sourceKey }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="item.differences.length" class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Abweichende Felder: {{ item.differences.join(", ") }}
|
||||||
|
</p>
|
||||||
|
<div v-if="item.kind === 'conflict'" class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
size="sm"
|
||||||
|
:color="decisions[item.id] === 'target' ? 'primary' : 'neutral'"
|
||||||
|
:variant="decisions[item.id] === 'target' ? 'solid' : 'soft'"
|
||||||
|
@click="decisions[item.id] = 'target'"
|
||||||
|
>Ziel behalten</UButton>
|
||||||
|
<UButton
|
||||||
|
size="sm"
|
||||||
|
:color="decisions[item.id] === 'source' ? 'warning' : 'neutral'"
|
||||||
|
:variant="decisions[item.id] === 'source' ? 'solid' : 'soft'"
|
||||||
|
@click="decisions[item.id] = 'source'"
|
||||||
|
>Quelle übernehmen</UButton>
|
||||||
|
</div>
|
||||||
|
<details v-if="item.kind === 'conflict'" class="text-xs">
|
||||||
|
<summary class="cursor-pointer text-gray-500">Quelldaten und Zieldaten anzeigen</summary>
|
||||||
|
<div class="mt-2 grid gap-2 lg:grid-cols-2">
|
||||||
|
<pre class="overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">{{ JSON.stringify(item.sourceRow, null, 2) }}</pre>
|
||||||
|
<pre class="overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">{{ JSON.stringify(item.targetRow, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="review" class="mt-4 flex items-center justify-between border-t pt-4">
|
||||||
|
<UButton color="neutral" variant="soft" :disabled="offset === 0" @click="offset = Math.max(0, offset - limit); loadReview()">Zurück</UButton>
|
||||||
|
<span class="text-sm text-gray-500">{{ offset + 1 }}–{{ Math.min(offset + review.items.length, review.total) }} von {{ review.total }}</span>
|
||||||
|
<UButton color="neutral" variant="soft" :disabled="!hasNextPage" @click="offset += limit; loadReview()">Weiter</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p :class="unresolvedConflicts ? 'text-orange-600' : 'text-green-600'">
|
||||||
|
{{ unresolvedConflicts ? `${unresolvedConflicts} Konflikte benötigen noch eine Entscheidung.` : "Alle Konflikte sind entschieden." }}
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-play"
|
||||||
|
color="primary"
|
||||||
|
:loading="executing"
|
||||||
|
:disabled="loading || unresolvedConflicts > 0 || Boolean(executionResult)"
|
||||||
|
@click="executeMerge"
|
||||||
|
>Merge ausführen</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -206,7 +206,7 @@ const importTenantExport = async (event: Event) => {
|
|||||||
filename: job.filename || file.name,
|
filename: job.filename || file.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!["ready", "failed"].includes(job.status)) {
|
while (!["ready", "review", "failed"].includes(job.status)) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
job = await admin.getTenantExport(importJobId)
|
job = await admin.getTenantExport(importJobId)
|
||||||
tenantImportProgress.value = {
|
tenantImportProgress.value = {
|
||||||
@@ -221,6 +221,11 @@ const importTenantExport = async (event: Event) => {
|
|||||||
throw new Error(job.error || "Import konnte nicht abgeschlossen werden.")
|
throw new Error(job.error || "Import konnte nicht abgeschlossen werden.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (job.status === "review") {
|
||||||
|
await router.push(`/administration/tenant-imports/${importJobId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await fetchTenant()
|
await fetchTenant()
|
||||||
await auth.fetchMe()
|
await auth.fetchMe()
|
||||||
await auth.switchTenant(String(job.tenantId || targetTenantId))
|
await auth.switchTenant(String(job.tenantId || targetTenantId))
|
||||||
|
|||||||
Reference in New Issue
Block a user