feat(C.5): Modularer Import/Export — Shared Helpers, Preview/Mapping, Background Jobs, Partial-Failure

C5-BASE: app/services/import_export_helpers.py (NEU, 352 Zeilen)
- parse_csv/json/xlsx, write_csv/json/xlsx, map_fields, suggest_mapping
- validate_row, build_error_report, build_import_result, detect_format

C5-PREVIEW: POST /import/preview + POST /import/validate
- Preview gibt erste 10 Zeilen + Spalten + Mapping-Vorschlag
- Validate gibt Fehler-Report ohne Import

C5-JOB: app/services/import_export_jobs.py (NEU, 165 Zeilen)
- ARQ Background Job für Files >1000 Zeilen
- Job-Status: pending/processing/completed/partial_success/failed
- GET /import/status/{job_id} — Status + Progress + Fehler-Report
- Partial-Failure: try/except pro Zeile, fehlerhafte gesammelt, erfolgreiche committet

C5-CONTACT+C5-COMPANY: Handler auf Shared Helpers umgestellt
C5-UI: ImportWizard.tsx (5 Steps: Upload→Preview→Validation→Review→Result)
C5-TEST: 45 Tests in test_import_export.py — alle grün
C5-DOC: Plugin-Dev-Guide Kapitel 31 (Import/Export Handler)
This commit is contained in:
Agent Zero
2026-08-13 22:43:33 +02:00
parent fd14e0076b
commit e7ae0ad5ce
10 changed files with 2630 additions and 439 deletions
+147 -8
View File
@@ -2,34 +2,87 @@ import { apiClient } from './client';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ImportError {
row: number;
field: string;
message: string;
}
export interface ErrorReport {
total_errors: number;
errors: ImportError[];
}
export interface ImportResult {
total?: number;
created?: number;
updated?: number;
succeeded?: number;
failed?: number;
status?: 'success' | 'partial_success' | 'failed' | 'pending' | 'processing' | 'completed';
dry_run?: boolean;
error_report?: ErrorReport;
created?: Record<string, unknown>[];
errors?: string[];
warnings?: string[];
rows?: Record<string, unknown>[];
preview?: boolean;
// Legacy compat
valid?: number;
invalid?: number;
job_id?: string;
message?: string;
}
export interface PreviewResult {
total_rows: number;
columns: string[];
preview_rows: Record<string, string>[];
mapping_suggestion: Record<string, string>;
target_fields: string[];
}
export interface ValidateResult {
total: number;
succeeded: number;
failed: number;
status: string;
dry_run: boolean;
error_report: ErrorReport;
created: Record<string, unknown>[];
}
export interface JobStatus {
status: 'pending' | 'processing' | 'completed' | 'partial_success' | 'failed';
progress?: number;
total?: number;
succeeded?: number;
failed?: number;
error_report?: ErrorReport;
created_count?: number;
error?: string;
}
export type EntityType = 'companies' | 'contacts';
export type ExportFormat = 'csv' | 'xlsx';
export type ExportFormat = 'csv' | 'xlsx' | 'json';
// ─── Import ─────────────────────────────────────────────────────────────────
/**
* Import a CSV file. When dryRun is true, a preview is returned without
* Import a CSV/JSON/XLSX file. When dryRun is true, a preview is returned without
* writing to the database.
*/
export async function importCsv(
file: File,
entityType: string,
dryRun: boolean
dryRun: boolean,
fieldMapping?: Record<string, string>,
): Promise<ImportResult> {
const url = dryRun ? '/import/preview' : '/import';
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
if (fieldMapping) {
formData.append('field_mapping', JSON.stringify(fieldMapping));
}
const response = await apiClient.post<ImportResult>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -37,14 +90,98 @@ export async function importCsv(
return response.data;
}
/**
* Preview a file: parse, return first 10 rows + columns + mapping suggestion.
*/
export async function previewImport(
file: File,
entityType: string,
): Promise<PreviewResult> {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
const response = await apiClient.post<PreviewResult>('/import/preview', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
/**
* Validate a file against mapping: returns error report without importing.
*/
export async function validateImport(
file: File,
entityType: string,
fieldMapping?: Record<string, string>,
): Promise<ValidateResult> {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
if (fieldMapping) {
formData.append('field_mapping', JSON.stringify(fieldMapping));
}
const response = await apiClient.post<ValidateResult>('/import/validate', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
/**
* Import contacts specifically.
*/
export async function importContacts(
file: File,
fieldMapping?: Record<string, string>,
): Promise<ImportResult> {
const formData = new FormData();
formData.append('file', file);
if (fieldMapping) {
formData.append('field_mapping', JSON.stringify(fieldMapping));
}
const response = await apiClient.post<ImportResult>('/import/contacts', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
/**
* Import companies specifically.
*/
export async function importCompanies(
file: File,
fieldMapping?: Record<string, string>,
): Promise<ImportResult> {
const formData = new FormData();
formData.append('file', file);
if (fieldMapping) {
formData.append('field_mapping', JSON.stringify(fieldMapping));
}
const response = await apiClient.post<ImportResult>('/import/companies', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
/**
* Get status of a background import job.
*/
export async function getImportJobStatus(jobId: string): Promise<JobStatus> {
const response = await apiClient.get<JobStatus>(`/import/status/${jobId}`);
return response.data;
}
// ─── Export ─────────────────────────────────────────────────────────────────
/**
* Export data as CSV or XLSX and trigger a browser download.
* Export data as CSV, XLSX, or JSON and trigger a browser download.
*/
export async function exportData(
entityType: string,
format: string
format: string,
): Promise<void> {
const url = `/export?entity_type=${encodeURIComponent(entityType)}&format=${encodeURIComponent(format)}`;
const response = await apiClient.get(url, { responseType: 'blob' });
@@ -52,7 +189,9 @@ export async function exportData(
const blob = new Blob([response.data], {
type: format === 'xlsx'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv',
: format === 'json'
? 'application/json'
: 'text/csv',
});
const blobUrl = URL.createObjectURL(blob);