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:
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ const ENTITY_OPTIONS = [
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
{ value: 'xlsx', label: 'XLSX' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
];
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user