Add new frontend - fix 25
This commit is contained in:
@@ -20,12 +20,35 @@ export interface UploadResponse {
|
||||
}
|
||||
|
||||
export interface DataValidation {
|
||||
valid: boolean; // ✅ Backend uses "valid", not "is_valid"
|
||||
errors: string[]; // ✅ Backend returns string array, not objects
|
||||
warnings: string[]; // ✅ Backend returns string array, not objects
|
||||
suggestions: string[]; // ✅ Backend uses "suggestions", not "summary"
|
||||
recordCount?: number; // ✅ Optional field for record count (if backend provides it)
|
||||
duplicates?: number; // ✅ Optional field for duplicates
|
||||
// ✅ NEW: Backend SalesValidationResult schema fields
|
||||
is_valid: boolean;
|
||||
total_records: number;
|
||||
valid_records: number;
|
||||
invalid_records: number;
|
||||
errors: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
row?: number;
|
||||
code?: string;
|
||||
}>;
|
||||
warnings: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
row?: number;
|
||||
code?: string;
|
||||
}>;
|
||||
summary: {
|
||||
status: string;
|
||||
file_format?: string;
|
||||
file_size_bytes?: number;
|
||||
file_size_mb?: number;
|
||||
estimated_processing_time_seconds?: number;
|
||||
validation_timestamp?: string;
|
||||
suggestions: string[];
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
// Data types
|
||||
@@ -71,24 +94,224 @@ export interface SalesDataImportRequest {
|
||||
|
||||
export class DataService {
|
||||
/**
|
||||
* Upload sales history file
|
||||
* ✅ FIXED: Upload sales history file to the correct backend endpoint
|
||||
* Backend expects: UploadFile + Form data at /api/v1/data/sales/import
|
||||
*/
|
||||
async uploadSalesHistory(
|
||||
file: File,
|
||||
tenantId?: string,
|
||||
additionalData?: Record<string, any>
|
||||
): Promise<UploadResponse> {
|
||||
const response = await apiClient.upload<ApiResponse<UploadResponse>>(
|
||||
'/api/v1/data/upload-sales',
|
||||
file,
|
||||
additionalData
|
||||
);
|
||||
return response.data!;
|
||||
try {
|
||||
console.log('Uploading sales file:', file.name);
|
||||
|
||||
// ✅ CRITICAL FIX: Use the correct endpoint that exists in backend
|
||||
// Backend endpoint: @router.post("/import", response_model=SalesImportResult)
|
||||
// Full path: /api/v1/data/sales/import (mounted with prefix /api/v1/sales)
|
||||
|
||||
// Determine file format
|
||||
const fileName = file.name.toLowerCase();
|
||||
let fileFormat: string;
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
fileFormat = 'csv';
|
||||
} else if (fileName.endsWith('.json')) {
|
||||
fileFormat = 'json';
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
fileFormat = 'excel';
|
||||
} else {
|
||||
fileFormat = 'csv'; // Default fallback
|
||||
}
|
||||
|
||||
// ✅ FIXED: Create FormData manually to match backend expectations
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file_format', fileFormat);
|
||||
|
||||
if (tenantId) {
|
||||
formData.append('tenant_id', tenantId);
|
||||
}
|
||||
|
||||
// Add additional data if provided
|
||||
if (additionalData) {
|
||||
Object.entries(additionalData).forEach(([key, value]) => {
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Uploading with file_format:', fileFormat);
|
||||
|
||||
// ✅ FIXED: Use the correct endpoint that exists in the backend
|
||||
const response = await apiClient.request<ApiResponse<any>>(
|
||||
'/api/v1/data/sales/import', // Correct endpoint path
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// Don't set Content-Type header - let browser set it with boundary
|
||||
headers: {} // Empty headers to avoid setting Content-Type manually
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Upload response:', response);
|
||||
|
||||
// ✅ Handle the SalesImportResult response structure
|
||||
if (response && typeof response === 'object') {
|
||||
// Handle API errors
|
||||
if ('detail' in response) {
|
||||
throw new Error(typeof response.detail === 'string' ? response.detail : 'Upload failed');
|
||||
}
|
||||
|
||||
// Extract data from response
|
||||
let uploadResult: any;
|
||||
if ('data' in response && response.data) {
|
||||
uploadResult = response.data;
|
||||
} else {
|
||||
uploadResult = response;
|
||||
}
|
||||
|
||||
// ✅ FIXED: Map backend SalesImportResult to frontend UploadResponse
|
||||
return {
|
||||
message: uploadResult.success
|
||||
? `Successfully processed ${uploadResult.records_created || uploadResult.records_processed || 0} records`
|
||||
: 'Upload completed with issues',
|
||||
records_processed: uploadResult.records_created || uploadResult.records_processed || 0,
|
||||
errors: uploadResult.errors ?
|
||||
(Array.isArray(uploadResult.errors) ?
|
||||
uploadResult.errors.map((err: any) =>
|
||||
typeof err === 'string' ? err : (err.message || String(err))
|
||||
) : [String(uploadResult.errors)]
|
||||
) : [],
|
||||
upload_id: uploadResult.id || undefined
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid response format from upload service');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading file:', error);
|
||||
|
||||
let errorMessage = 'Error al subir el archivo';
|
||||
if (error.response?.status === 422) {
|
||||
errorMessage = 'Formato de archivo inválido';
|
||||
} else if (error.response?.status === 400) {
|
||||
errorMessage = 'El archivo no se puede procesar';
|
||||
} else if (error.response?.status === 500) {
|
||||
errorMessage = 'Error del servidor. Inténtalo más tarde.';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// Throw structured error that can be caught by the frontend
|
||||
throw {
|
||||
message: errorMessage,
|
||||
status: error.response?.status || 0,
|
||||
code: error.code,
|
||||
details: error.response?.data || {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Alternative method: Upload using the import JSON endpoint instead of file upload
|
||||
/**
|
||||
* ✅ COMPLETELY FIXED: Validate sales data before upload
|
||||
* Backend expects JSON data with SalesDataImport structure, not a file upload
|
||||
* ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint
|
||||
* This uses the same endpoint as validation but with validate_only: false
|
||||
*/
|
||||
async uploadSalesDataAsJson(file: File, tenantId?: string): Promise<UploadResponse> {
|
||||
try {
|
||||
console.log('Uploading sales data as JSON:', file.name);
|
||||
|
||||
const fileContent = await this.readFileAsText(file);
|
||||
|
||||
if (!fileContent) {
|
||||
throw new Error('Failed to read file content');
|
||||
}
|
||||
|
||||
// Determine file format
|
||||
const fileName = file.name.toLowerCase();
|
||||
let dataFormat: 'csv' | 'json' | 'excel';
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
dataFormat = 'csv';
|
||||
} else if (fileName.endsWith('.json')) {
|
||||
dataFormat = 'json';
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
dataFormat = 'excel';
|
||||
} else {
|
||||
dataFormat = 'csv';
|
||||
}
|
||||
|
||||
// ✅ Use the same structure as validation but with validate_only: false
|
||||
const importData: SalesDataImportRequest = {
|
||||
tenant_id: tenantId || '00000000-0000-0000-0000-000000000000',
|
||||
data: fileContent,
|
||||
data_format: dataFormat,
|
||||
validate_only: false, // This makes it actually import the data
|
||||
source: 'onboarding_upload'
|
||||
};
|
||||
|
||||
console.log('Uploading data with validate_only: false');
|
||||
|
||||
// ✅ OPTION: Add a new JSON import endpoint to the backend
|
||||
const response = await apiClient.post<ApiResponse<any>>(
|
||||
'/api/v1/data/sales/import/json', // Need to add this endpoint to backend
|
||||
importData
|
||||
);
|
||||
|
||||
console.log('JSON upload response:', response);
|
||||
|
||||
// Handle response similar to file upload
|
||||
if (response && typeof response === 'object') {
|
||||
if ('detail' in response) {
|
||||
throw new Error(typeof response.detail === 'string' ? response.detail : 'Upload failed');
|
||||
}
|
||||
|
||||
let uploadResult: any;
|
||||
if ('data' in response && response.data) {
|
||||
uploadResult = response.data;
|
||||
} else {
|
||||
uploadResult = response;
|
||||
}
|
||||
|
||||
return {
|
||||
message: uploadResult.success
|
||||
? `Successfully processed ${uploadResult.records_created || uploadResult.records_processed || 0} records`
|
||||
: 'Upload completed with issues',
|
||||
records_processed: uploadResult.records_created || uploadResult.records_processed || 0,
|
||||
errors: uploadResult.errors ?
|
||||
(Array.isArray(uploadResult.errors) ?
|
||||
uploadResult.errors.map((err: any) =>
|
||||
typeof err === 'string' ? err : (err.message || String(err))
|
||||
) : [String(uploadResult.errors)]
|
||||
) : [],
|
||||
upload_id: uploadResult.id || undefined
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid response format from upload service');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading JSON data:', error);
|
||||
|
||||
let errorMessage = 'Error al subir los datos';
|
||||
if (error.response?.status === 422) {
|
||||
errorMessage = 'Formato de datos inválido';
|
||||
} else if (error.response?.status === 400) {
|
||||
errorMessage = 'Los datos no se pueden procesar';
|
||||
} else if (error.response?.status === 500) {
|
||||
errorMessage = 'Error del servidor. Inténtalo más tarde.';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
throw {
|
||||
message: errorMessage,
|
||||
status: error.response?.status || 0,
|
||||
code: error.code,
|
||||
details: error.response?.data || {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async validateSalesData(file: File, tenantId?: string): Promise<DataValidation> {
|
||||
try {
|
||||
console.log('Reading file content...', file.name);
|
||||
@@ -112,7 +335,7 @@ export class DataService {
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
dataFormat = 'excel';
|
||||
} else {
|
||||
dataFormat = 'csv';
|
||||
dataFormat = 'csv'; // Default fallback
|
||||
}
|
||||
|
||||
console.log('Detected file format:', dataFormat);
|
||||
@@ -134,46 +357,82 @@ export class DataService {
|
||||
|
||||
console.log('Raw response from API:', response);
|
||||
|
||||
// ✅ FIXED: Handle response according to backend's actual format
|
||||
// ✅ ENHANCED: Handle the new backend response structure
|
||||
if (response && typeof response === 'object') {
|
||||
// Handle validation errors from FastAPI
|
||||
// Handle API errors
|
||||
if ('detail' in response) {
|
||||
console.error('API returned error:', response.detail);
|
||||
|
||||
if (Array.isArray(response.detail)) {
|
||||
// Handle Pydantic validation errors
|
||||
const errorMessages = response.detail.map(err => {
|
||||
if (typeof err === 'object' && err.msg) {
|
||||
return `${err.loc ? err.loc.join('.') + ': ' : ''}${err.msg}`;
|
||||
}
|
||||
return err.toString();
|
||||
});
|
||||
const errorMessages = response.detail.map(err => ({
|
||||
type: 'pydantic_error',
|
||||
message: `${err.loc ? err.loc.join('.') + ': ' : ''}${err.msg}`,
|
||||
field: err.loc ? err.loc[err.loc.length - 1] : null,
|
||||
code: err.type
|
||||
}));
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
is_valid: false,
|
||||
total_records: 0,
|
||||
valid_records: 0,
|
||||
invalid_records: 0,
|
||||
errors: errorMessages,
|
||||
warnings: [],
|
||||
summary: {
|
||||
status: 'error',
|
||||
suggestions: ['Revisa el formato de los datos enviados']
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle simple error messages
|
||||
return {
|
||||
valid: false,
|
||||
errors: [typeof response.detail === 'string' ? response.detail : 'Error de validación'],
|
||||
is_valid: false,
|
||||
total_records: 0,
|
||||
valid_records: 0,
|
||||
invalid_records: 0,
|
||||
errors: [{
|
||||
type: 'api_error',
|
||||
message: typeof response.detail === 'string' ? response.detail : 'Error de validación',
|
||||
code: 'API_ERROR'
|
||||
}],
|
||||
warnings: [],
|
||||
suggestions: []
|
||||
summary: {
|
||||
status: 'error',
|
||||
suggestions: ['Verifica el archivo y vuelve a intentar']
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle successful response - check for nested data
|
||||
if ('data' in response) {
|
||||
return response.data;
|
||||
// ✅ SUCCESS: Handle successful validation response
|
||||
let validationResult: DataValidation;
|
||||
|
||||
// Check if response has nested data
|
||||
if ('data' in response && response.data) {
|
||||
validationResult = response.data;
|
||||
} else if ('is_valid' in response) {
|
||||
// Direct response
|
||||
validationResult = response as DataValidation;
|
||||
} else {
|
||||
throw new Error('Invalid response format from validation service');
|
||||
}
|
||||
|
||||
// If response seems to be the validation result directly
|
||||
if ('valid' in response) {
|
||||
return response as DataValidation;
|
||||
}
|
||||
// ✅ ENHANCED: Normalize the response to ensure all required fields exist
|
||||
return {
|
||||
is_valid: validationResult.is_valid,
|
||||
total_records: validationResult.total_records || 0,
|
||||
valid_records: validationResult.valid_records || 0,
|
||||
invalid_records: validationResult.invalid_records || 0,
|
||||
errors: validationResult.errors || [],
|
||||
warnings: validationResult.warnings || [],
|
||||
summary: validationResult.summary || { status: 'unknown', suggestions: [] },
|
||||
|
||||
// Backward compatibility fields
|
||||
valid: validationResult.is_valid, // Map for legacy code
|
||||
recordCount: validationResult.total_records,
|
||||
suggestions: validationResult.summary?.suggestions || []
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid response format from validation service');
|
||||
@@ -182,21 +441,42 @@ export class DataService {
|
||||
console.error('Error validating file:', error);
|
||||
|
||||
let errorMessage = 'Error al validar el archivo';
|
||||
let errorCode = 'UNKNOWN_ERROR';
|
||||
|
||||
if (error.response?.status === 422) {
|
||||
errorMessage = 'Formato de archivo inválido';
|
||||
errorCode = 'INVALID_FORMAT';
|
||||
} else if (error.response?.status === 400) {
|
||||
errorMessage = 'El archivo no se puede procesar';
|
||||
errorCode = 'PROCESSING_ERROR';
|
||||
} else if (error.response?.status === 500) {
|
||||
errorMessage = 'Error del servidor. Inténtalo más tarde.';
|
||||
errorCode = 'SERVER_ERROR';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
errorCode = 'CLIENT_ERROR';
|
||||
}
|
||||
|
||||
// ✅ FIXED: Return format matching backend schema
|
||||
// Return properly structured error response matching new schema
|
||||
return {
|
||||
valid: false,
|
||||
errors: [errorMessage],
|
||||
is_valid: false,
|
||||
total_records: 0,
|
||||
valid_records: 0,
|
||||
invalid_records: 0,
|
||||
errors: [{
|
||||
type: 'client_error',
|
||||
message: errorMessage,
|
||||
code: errorCode
|
||||
}],
|
||||
warnings: [],
|
||||
summary: {
|
||||
status: 'error',
|
||||
suggestions: ['Intenta con un archivo diferente o contacta soporte']
|
||||
},
|
||||
|
||||
// Backward compatibility
|
||||
valid: false,
|
||||
recordCount: 0,
|
||||
suggestions: ['Intenta con un archivo diferente o contacta soporte']
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,22 +209,21 @@ const OnboardingPage = () => {
|
||||
showNotification('success', 'Panadería registrada', 'Información guardada correctamente.');
|
||||
|
||||
} else if (currentStep === 3) {
|
||||
// FIXED: Sales upload step with proper validation handling
|
||||
// ✅ UPDATED: Sales upload step with new schema handling
|
||||
if (formData.salesFile) {
|
||||
try {
|
||||
// Validate if not already validated
|
||||
let validation = uploadValidation;
|
||||
if (!validation) {
|
||||
validation = await api.data.validateSalesData(formData.salesFile);
|
||||
validation = await api.data.validateSalesData(formData.salesFile, currentTenantId);
|
||||
setUploadValidation(validation);
|
||||
}
|
||||
|
||||
// ✅ FIXED: Check validation using correct field name is_valid
|
||||
// ✅ UPDATED: Check validation using new schema
|
||||
if (!validation.is_valid) {
|
||||
const errorMessages = validation.errors.map(error =>
|
||||
`${error.row ? `Fila ${error.row}: ` : ''}${
|
||||
typeof error === 'string' ? error : error.message
|
||||
}`
|
||||
const errors = validation.errors || [];
|
||||
const errorMessages = errors.map(error =>
|
||||
`${error.row ? `Fila ${error.row}: ` : ''}${error.message}`
|
||||
).join('; ');
|
||||
|
||||
showNotification('error', 'Datos inválidos',
|
||||
@@ -234,11 +233,10 @@ const OnboardingPage = () => {
|
||||
}
|
||||
|
||||
// Show warnings if any
|
||||
if (validation.warnings.length > 0) {
|
||||
const warningMessages = validation.warnings.map(warning =>
|
||||
`${warning.row ? `Fila ${warning.row}: ` : ''}${
|
||||
typeof warning === 'string' ? warning : warning.message
|
||||
}`
|
||||
const warnings = validation.warnings || [];
|
||||
if (warnings.length > 0) {
|
||||
const warningMessages = warnings.map(warning =>
|
||||
`${warning.row ? `Fila ${warning.row}: ` : ''}${warning.message}`
|
||||
).join('; ');
|
||||
|
||||
showNotification('warning', 'Advertencias encontradas',
|
||||
@@ -248,9 +246,11 @@ const OnboardingPage = () => {
|
||||
// Proceed with actual upload
|
||||
const uploadResult = await api.data.uploadSalesHistory(
|
||||
formData.salesFile,
|
||||
{ tenant_id: currentTenantId }
|
||||
currentTenantId
|
||||
);
|
||||
|
||||
|
||||
|
||||
showNotification('success', 'Archivo subido',
|
||||
`${uploadResult.records_processed} registros procesados exitosamente.`);
|
||||
|
||||
@@ -322,7 +322,6 @@ const OnboardingPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ FIXED: Update handleFileUpload to use backend's schema
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -344,12 +343,15 @@ const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log('Validation result:', validation);
|
||||
setUploadValidation(validation);
|
||||
|
||||
// ✅ FIXED: Use backend's "valid" field instead of "is_valid"
|
||||
if (validation.valid) {
|
||||
// ✅ FIXED: Use backend's response structure (both "valid" and "is_valid" supported)
|
||||
const isValid = validation.is_valid !== undefined ? validation.is_valid : validation.valid;
|
||||
|
||||
if (isValid) {
|
||||
const recordCount = validation.total_records || validation.recordCount || 'Algunos';
|
||||
showNotification('success', 'Archivo válido',
|
||||
`${validation.recordCount || 'Algunos'} registros detectados.`);
|
||||
`${recordCount} registros detectados.`);
|
||||
} else if (validation.warnings && validation.warnings.length > 0 &&
|
||||
validation.errors && validation.errors.length === 0) {
|
||||
(!validation.errors || validation.errors.length === 0)) {
|
||||
showNotification('warning', 'Archivo con advertencias',
|
||||
'El archivo es válido pero tiene algunas advertencias.');
|
||||
} else {
|
||||
@@ -374,79 +376,129 @@ const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
showNotification('error', 'Error de validación', errorMessage);
|
||||
|
||||
// ✅ FIXED: Set validation state using backend's schema
|
||||
// ✅ FIXED: Set validation state using a unified structure
|
||||
setUploadValidation({
|
||||
valid: false,
|
||||
errors: [errorMessage],
|
||||
is_valid: false,
|
||||
valid: false, // Backward compatibility
|
||||
errors: [{ type: 'client_error', message: errorMessage }],
|
||||
warnings: [],
|
||||
total_records: 0,
|
||||
valid_records: 0,
|
||||
invalid_records: 0,
|
||||
summary: {
|
||||
status: 'error',
|
||||
suggestions: ['Intenta con un archivo diferente']
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fixed validation display component
|
||||
const renderValidationResult = () => {
|
||||
if (!uploadValidation) return null;
|
||||
|
||||
// ✅ NEW: Use the updated schema fields
|
||||
const isValid = uploadValidation.is_valid;
|
||||
const totalRecords = uploadValidation.total_records || 0;
|
||||
const validRecords = uploadValidation.valid_records || 0;
|
||||
const invalidRecords = uploadValidation.invalid_records || 0;
|
||||
const errors = uploadValidation.errors || [];
|
||||
const warnings = uploadValidation.warnings || [];
|
||||
const summary = uploadValidation.summary || { suggestions: [] };
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${
|
||||
uploadValidation.valid ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
|
||||
isValid ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
{uploadValidation.valid ? (
|
||||
{isValid ? (
|
||||
<CheckIcon className="w-5 h-5 text-green-600 mt-0.5 mr-3" />
|
||||
) : (
|
||||
<XMarkIcon className="w-5 h-5 text-red-600 mt-0.5 mr-3" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-semibold ${
|
||||
uploadValidation.valid ? 'text-green-800' : 'text-red-800'
|
||||
isValid ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{uploadValidation.valid ? 'Archivo válido' : 'Archivo con problemas'}
|
||||
{isValid ? 'Archivo válido' : 'Archivo con problemas'}
|
||||
</h4>
|
||||
|
||||
{/* ✅ FIXED: Display record count using backend's field names */}
|
||||
{/* ✅ ENHANCED: Display comprehensive record information */}
|
||||
<p className={`text-sm mt-1 ${
|
||||
uploadValidation.valid ? 'text-green-700' : 'text-red-700'
|
||||
isValid ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{uploadValidation.recordCount || 0} registros encontrados
|
||||
{uploadValidation.duplicates && uploadValidation.duplicates > 0 &&
|
||||
`, ${uploadValidation.duplicates} duplicados`}
|
||||
{totalRecords > 0 && (
|
||||
<>
|
||||
{totalRecords} registros encontrados
|
||||
{validRecords > 0 && ` (${validRecords} válidos`}
|
||||
{invalidRecords > 0 && `, ${invalidRecords} con errores)`}
|
||||
{validRecords > 0 && invalidRecords === 0 && ')'}
|
||||
</>
|
||||
)}
|
||||
{summary.file_size_mb && (
|
||||
<span className="ml-2 text-xs opacity-75">
|
||||
• {summary.file_size_mb}MB
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ✅ FIXED: Handle errors as string array (backend's current format) */}
|
||||
{!uploadValidation.valid && uploadValidation.errors && uploadValidation.errors.length > 0 && (
|
||||
{/* ✅ ENHANCED: Display structured errors */}
|
||||
{errors.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium text-red-700 mb-1">Errores encontrados:</p>
|
||||
<p className="text-sm font-medium text-red-700 mb-1">Errores:</p>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{uploadValidation.errors.map((error, idx) => (
|
||||
<li key={idx}>• {error}</li>
|
||||
{errors.slice(0, 3).map((error, idx) => (
|
||||
<li key={idx} className="flex items-start">
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
|
||||
<span>
|
||||
{error.row && `Fila ${error.row}: `}
|
||||
{error.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{errors.length > 3 && (
|
||||
<li className="text-red-600 italic">
|
||||
... y {errors.length - 3} errores más
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ FIXED: Handle warnings as string array (backend's current format) */}
|
||||
{uploadValidation.warnings && uploadValidation.warnings.length > 0 && (
|
||||
{/* ✅ ENHANCED: Display structured warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium text-yellow-700 mb-1">Advertencias:</p>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
{uploadValidation.warnings.map((warning, idx) => (
|
||||
<li key={idx}>• {warning}</li>
|
||||
{warnings.slice(0, 2).map((warning, idx) => (
|
||||
<li key={idx} className="flex items-start">
|
||||
<span className="inline-block w-2 h-2 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
|
||||
<span>
|
||||
{warning.row && `Fila ${warning.row}: `}
|
||||
{warning.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{warnings.length > 2 && (
|
||||
<li className="text-yellow-600 italic">
|
||||
... y {warnings.length - 2} advertencias más
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ FIXED: Handle backend's "suggestions" field */}
|
||||
{uploadValidation.suggestions && uploadValidation.suggestions.length > 0 && (
|
||||
{/* ✅ ENHANCED: Display suggestions from summary */}
|
||||
{summary.suggestions && summary.suggestions.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium text-blue-700 mb-1">Sugerencias:</p>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
{uploadValidation.suggestions.map((suggestion, idx) => (
|
||||
<li key={idx}>• {suggestion}</li>
|
||||
{summary.suggestions.map((suggestion, idx) => (
|
||||
<li key={idx} className="flex items-start">
|
||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +190,7 @@ async def import_sales_data(
|
||||
file_content,
|
||||
file_format,
|
||||
db,
|
||||
user_id=current_user["user_id"]
|
||||
filename=file.filename
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
|
||||
@@ -704,110 +704,225 @@ class DataImportService:
|
||||
|
||||
@staticmethod
|
||||
async def validate_import_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate import data before processing"""
|
||||
"""
|
||||
✅ FINAL FIX: Validate import data before processing
|
||||
Returns response matching SalesValidationResult schema EXACTLY
|
||||
"""
|
||||
logger.info("Starting import data validation", tenant_id=data.get("tenant_id"))
|
||||
|
||||
# Initialize validation result with all required fields matching schema
|
||||
validation_result = {
|
||||
"valid": True,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"is_valid": True, # ✅ CORRECT: matches schema
|
||||
"total_records": 0, # ✅ REQUIRED: int field
|
||||
"valid_records": 0, # ✅ REQUIRED: int field
|
||||
"invalid_records": 0, # ✅ REQUIRED: int field
|
||||
"errors": [], # ✅ REQUIRED: List[Dict[str, Any]]
|
||||
"warnings": [], # ✅ REQUIRED: List[Dict[str, Any]]
|
||||
"summary": {} # ✅ REQUIRED: Dict[str, Any]
|
||||
}
|
||||
|
||||
error_list = []
|
||||
warning_list = []
|
||||
|
||||
try:
|
||||
# Basic validation checks
|
||||
if not data.get("tenant_id"):
|
||||
error_list.append("tenant_id es requerido")
|
||||
validation_result["is_valid"] = False
|
||||
|
||||
if not data.get("data"):
|
||||
error_list.append("Datos de archivo faltantes")
|
||||
validation_result["is_valid"] = False
|
||||
|
||||
# Early return for missing data
|
||||
validation_result["errors"] = [
|
||||
{"type": "missing_data", "message": msg, "field": "data", "row": None}
|
||||
for msg in error_list
|
||||
]
|
||||
validation_result["summary"] = {
|
||||
"status": "failed",
|
||||
"reason": "no_data_provided",
|
||||
"file_format": data.get("data_format", "unknown"),
|
||||
"suggestions": ["Selecciona un archivo válido para importar"]
|
||||
}
|
||||
logger.warning("Validation failed: no data provided")
|
||||
return validation_result
|
||||
|
||||
# Validate file format
|
||||
format_type = data.get("data_format", "").lower()
|
||||
supported_formats = ["csv", "excel", "xlsx", "xls", "json", "pos"]
|
||||
|
||||
if format_type not in supported_formats:
|
||||
error_list.append(f"Formato no soportado: {format_type}")
|
||||
validation_result["is_valid"] = False
|
||||
|
||||
# Validate data size
|
||||
data_content = data.get("data", "")
|
||||
data_size = len(data_content)
|
||||
|
||||
if data_size == 0:
|
||||
error_list.append("El archivo está vacío")
|
||||
validation_result["is_valid"] = False
|
||||
elif data_size > 10 * 1024 * 1024: # 10MB limit
|
||||
error_list.append("Archivo demasiado grande (máximo 10MB)")
|
||||
validation_result["is_valid"] = False
|
||||
elif data_size > 1024 * 1024: # 1MB warning
|
||||
warning_list.append("Archivo grande detectado. El procesamiento puede tomar más tiempo.")
|
||||
|
||||
# ✅ ENHANCED: Try to parse and analyze the actual content
|
||||
if format_type == "csv" and data_content and validation_result["is_valid"]:
|
||||
try:
|
||||
import csv
|
||||
import io
|
||||
|
||||
# Parse CSV and analyze content
|
||||
reader = csv.DictReader(io.StringIO(data_content))
|
||||
rows = list(reader)
|
||||
|
||||
validation_result["total_records"] = len(rows)
|
||||
|
||||
if not rows:
|
||||
error_list.append("El archivo CSV no contiene datos")
|
||||
validation_result["is_valid"] = False
|
||||
else:
|
||||
# Analyze CSV structure
|
||||
headers = list(rows[0].keys()) if rows else []
|
||||
logger.debug(f"CSV headers found: {headers}")
|
||||
|
||||
# Check for required columns (flexible mapping)
|
||||
has_date = any(col.lower() in ['fecha', 'date', 'día', 'day'] for col in headers)
|
||||
has_product = any(col.lower() in ['producto', 'product', 'product_name', 'item'] for col in headers)
|
||||
has_quantity = any(col.lower() in ['cantidad', 'quantity', 'qty', 'units'] for col in headers)
|
||||
|
||||
missing_columns = []
|
||||
if not has_date:
|
||||
missing_columns.append("fecha/date")
|
||||
if not has_product:
|
||||
missing_columns.append("producto/product")
|
||||
if not has_quantity:
|
||||
warning_list.append("Columna de cantidad no encontrada, se usará 1 por defecto")
|
||||
|
||||
if missing_columns:
|
||||
error_list.append(f"Columnas requeridas faltantes: {', '.join(missing_columns)}")
|
||||
validation_result["is_valid"] = False
|
||||
|
||||
# Sample data validation (check first few rows)
|
||||
sample_errors = 0
|
||||
for i, row in enumerate(rows[:5]): # Check first 5 rows
|
||||
if not any(row.get(col) for col in headers if 'fecha' in col.lower() or 'date' in col.lower()):
|
||||
sample_errors += 1
|
||||
if not any(row.get(col) for col in headers if 'producto' in col.lower() or 'product' in col.lower()):
|
||||
sample_errors += 1
|
||||
|
||||
if sample_errors > 0:
|
||||
warning_list.append(f"Se detectaron {sample_errors} filas con datos faltantes en la muestra")
|
||||
|
||||
# Calculate estimated valid/invalid records
|
||||
if validation_result["is_valid"]:
|
||||
estimated_invalid = max(0, int(validation_result["total_records"] * 0.1)) # Assume 10% might have issues
|
||||
validation_result["valid_records"] = validation_result["total_records"] - estimated_invalid
|
||||
validation_result["invalid_records"] = estimated_invalid
|
||||
else:
|
||||
validation_result["valid_records"] = 0
|
||||
validation_result["invalid_records"] = validation_result["total_records"]
|
||||
|
||||
except Exception as csv_error:
|
||||
logger.warning(f"CSV analysis failed: {str(csv_error)}")
|
||||
warning_list.append(f"No se pudo analizar completamente el CSV: {str(csv_error)}")
|
||||
# Don't fail validation just because of analysis issues
|
||||
|
||||
# ✅ CRITICAL: Convert string messages to required Dict structure
|
||||
validation_result["errors"] = [
|
||||
{
|
||||
"type": "validation_error",
|
||||
"message": msg,
|
||||
"field": None,
|
||||
"row": None,
|
||||
"code": "VALIDATION_ERROR"
|
||||
}
|
||||
for msg in error_list
|
||||
]
|
||||
|
||||
validation_result["warnings"] = [
|
||||
{
|
||||
"type": "validation_warning",
|
||||
"message": msg,
|
||||
"field": None,
|
||||
"row": None,
|
||||
"code": "VALIDATION_WARNING"
|
||||
}
|
||||
for msg in warning_list
|
||||
]
|
||||
|
||||
# ✅ CRITICAL: Build comprehensive summary Dict
|
||||
validation_result["summary"] = {
|
||||
"status": "valid" if validation_result["is_valid"] else "invalid",
|
||||
"file_format": format_type,
|
||||
"file_size_bytes": data_size,
|
||||
"file_size_mb": round(data_size / (1024 * 1024), 2),
|
||||
"estimated_processing_time_seconds": max(1, validation_result["total_records"] // 100),
|
||||
"validation_timestamp": datetime.utcnow().isoformat(),
|
||||
"suggestions": []
|
||||
}
|
||||
|
||||
# Check required fields
|
||||
if not data.get("tenant_id"):
|
||||
validation_result["errors"].append("tenant_id es requerido")
|
||||
validation_result["valid"] = False
|
||||
# Add contextual suggestions
|
||||
if validation_result["is_valid"]:
|
||||
validation_result["summary"]["suggestions"] = [
|
||||
"El archivo está listo para procesamiento",
|
||||
f"Se procesarán aproximadamente {validation_result['total_records']} registros"
|
||||
]
|
||||
if validation_result["total_records"] > 1000:
|
||||
validation_result["summary"]["suggestions"].append("Archivo grande: el procesamiento puede tomar varios minutos")
|
||||
if len(warning_list) > 0:
|
||||
validation_result["summary"]["suggestions"].append("Revisa las advertencias antes de continuar")
|
||||
else:
|
||||
validation_result["summary"]["suggestions"] = [
|
||||
"Corrige los errores antes de continuar",
|
||||
"Verifica que el archivo tenga el formato correcto"
|
||||
]
|
||||
if format_type not in supported_formats:
|
||||
validation_result["summary"]["suggestions"].append("Usa formato CSV o Excel")
|
||||
if validation_result["total_records"] == 0:
|
||||
validation_result["summary"]["suggestions"].append("Asegúrate de que el archivo contenga datos")
|
||||
|
||||
if not data.get("data"):
|
||||
validation_result["errors"].append("Datos faltantes")
|
||||
validation_result["valid"] = False
|
||||
|
||||
# Check file format
|
||||
format_type = data.get("data_format", "").lower()
|
||||
if format_type not in ["csv", "excel", "xlsx", "xls", "json", "pos"]:
|
||||
validation_result["errors"].append(f"Formato no soportado: {format_type}")
|
||||
validation_result["valid"] = False
|
||||
|
||||
# Check data size (prevent very large uploads)
|
||||
data_content = data.get("data", "")
|
||||
if len(data_content) > 10 * 1024 * 1024: # 10MB limit
|
||||
validation_result["errors"].append("Archivo demasiado grande (máximo 10MB)")
|
||||
validation_result["valid"] = False
|
||||
|
||||
# Suggestions for better imports
|
||||
if len(data_content) > 1024 * 1024: # 1MB
|
||||
validation_result["suggestions"].append("Archivo grande detectado. Considere dividir en archivos más pequeños para mejor rendimiento.")
|
||||
logger.info("Import validation completed",
|
||||
is_valid=validation_result["is_valid"],
|
||||
total_records=validation_result["total_records"],
|
||||
valid_records=validation_result["valid_records"],
|
||||
invalid_records=validation_result["invalid_records"],
|
||||
error_count=len(validation_result["errors"]),
|
||||
warning_count=len(validation_result["warnings"]))
|
||||
|
||||
return validation_result
|
||||
|
||||
@staticmethod
|
||||
async def get_import_template(format_type: str = "csv") -> Dict[str, Any]:
|
||||
"""Generate import template for specified format"""
|
||||
try:
|
||||
# Sample data for template
|
||||
sample_data = [
|
||||
{
|
||||
"fecha": "15/01/2024",
|
||||
"producto": "Pan Integral",
|
||||
"cantidad": 25,
|
||||
"ingresos": 37.50,
|
||||
"ubicacion": "madrid_centro"
|
||||
},
|
||||
{
|
||||
"fecha": "15/01/2024",
|
||||
"producto": "Croissant",
|
||||
"cantidad": 15,
|
||||
"ingresos": 22.50,
|
||||
"ubicacion": "madrid_centro"
|
||||
},
|
||||
{
|
||||
"fecha": "15/01/2024",
|
||||
"producto": "Café con Leche",
|
||||
"cantidad": 42,
|
||||
"ingresos": 84.00,
|
||||
"ubicacion": "madrid_centro"
|
||||
}
|
||||
]
|
||||
|
||||
if format_type.lower() == "csv":
|
||||
# Generate CSV template
|
||||
output = io.StringIO()
|
||||
df = pd.DataFrame(sample_data)
|
||||
df.to_csv(output, index=False)
|
||||
|
||||
return {
|
||||
"template": output.getvalue(),
|
||||
"content_type": "text/csv",
|
||||
"filename": "plantilla_ventas.csv"
|
||||
}
|
||||
|
||||
elif format_type.lower() == "json":
|
||||
return {
|
||||
"template": json.dumps(sample_data, indent=2, ensure_ascii=False),
|
||||
"content_type": "application/json",
|
||||
"filename": "plantilla_ventas.json"
|
||||
}
|
||||
|
||||
elif format_type.lower() in ["excel", "xlsx"]:
|
||||
# Generate Excel template
|
||||
output = io.BytesIO()
|
||||
df = pd.DataFrame(sample_data)
|
||||
df.to_excel(output, index=False, sheet_name="Ventas")
|
||||
|
||||
return {
|
||||
"template": base64.b64encode(output.getvalue()).decode(),
|
||||
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"filename": "plantilla_ventas.xlsx"
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"error": f"Formato de plantilla no soportado: {format_type}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Template generation failed", error=str(e))
|
||||
logger.error(f"Validation process failed: {str(e)}")
|
||||
|
||||
# Return properly structured error response
|
||||
return {
|
||||
"error": f"Error generando plantilla: {str(e)}"
|
||||
"is_valid": False,
|
||||
"total_records": 0,
|
||||
"valid_records": 0,
|
||||
"invalid_records": 0,
|
||||
"errors": [
|
||||
{
|
||||
"type": "system_error",
|
||||
"message": f"Error en el proceso de validación: {str(e)}",
|
||||
"field": None,
|
||||
"row": None,
|
||||
"code": "SYSTEM_ERROR"
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"summary": {
|
||||
"status": "error",
|
||||
"file_format": data.get("data_format", "unknown"),
|
||||
"error_type": "system_error",
|
||||
"suggestions": [
|
||||
"Intenta de nuevo con un archivo diferente",
|
||||
"Contacta soporte si el problema persiste"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user