parsers as $parserClass) { if ($parserClass::supports($extension)) { return new $parserClass(); } } throw new \InvalidArgumentException("Unsupported file type: $extension"); } /** * Get preview of file contents */ public function getPreview(string $filePath, int $rows = 15): array { $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $parser = $this->getParser($extension); return $parser->getPreview($filePath, $rows); } /** * Get headers from file */ public function getHeaders(string $filePath, array $options = []): array { $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $parser = $this->getParser($extension); return $parser->getHeaders($filePath, $options); } /** * Parse file with mapping */ public function parseFile(string $filePath, ImportMapping $mapping): array { $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $parser = $this->getParser($extension); $options = [ 'header_row' => $mapping->header_row, 'data_start_row' => $mapping->data_start_row, ]; return $parser->parse($filePath, $options); } /** * Apply mapping to a row of data */ public function applyMapping(array $row, ImportMapping $mapping): array { $mapped = []; $columnMappings = $mapping->column_mappings; foreach ($columnMappings as $field => $config) { if (empty($config['columns'])) { continue; } $values = []; foreach ($config['columns'] as $colIndex) { if (isset($row[$colIndex]) && $row[$colIndex] !== null && $row[$colIndex] !== '') { $values[] = $row[$colIndex]; } } $separator = $config['concat_separator'] ?? ' '; $value = implode($separator, $values); // Processar valor baseado no tipo de campo $mapped[$field] = $this->processFieldValue($field, $value, $mapping); } return $mapped; } /** * Process field value based on field type */ protected function processFieldValue(string $field, $value, ImportMapping $mapping) { if ($value === null || $value === '') { return null; } $fieldConfig = ImportMapping::MAPPABLE_FIELDS[$field] ?? null; if (!$fieldConfig) { return $value; } switch ($fieldConfig['type']) { case 'date': return $this->parseDate($value, $mapping->date_format); case 'decimal': return $this->parseDecimal( $value, $mapping->decimal_separator, $mapping->thousands_separator ); default: return trim((string) $value); } } /** * Parse date value */ protected function parseDate($value, string $format): ?string { if ($value instanceof \DateTimeInterface) { return $value->format('Y-m-d'); } $value = trim((string) $value); if (empty($value)) { return null; } // Tentar vários formatos comuns $formats = [ $format, 'd/m/Y', 'm/d/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y', 'Y/m/d', ]; foreach ($formats as $fmt) { try { $date = Carbon::createFromFormat($fmt, $value); if ($date && $date->format($fmt) === $value) { return $date->format('Y-m-d'); } } catch (\Exception $e) { continue; } } // Tentar parse genérico try { return Carbon::parse($value)->format('Y-m-d'); } catch (\Exception $e) { return null; } } /** * Parse decimal value */ protected function parseDecimal($value, string $decimalSeparator, string $thousandsSeparator): ?float { if (is_numeric($value)) { return floatval($value); } $value = trim((string) $value); if (empty($value)) { return null; } // Remover símbolos de moeda e espaços $value = preg_replace('/[€$R\s]/', '', $value); // Substituir separadores if ($thousandsSeparator !== '') { $value = str_replace($thousandsSeparator, '', $value); } if ($decimalSeparator !== '.') { $value = str_replace($decimalSeparator, '.', $value); } // Verificar se é numérico após processamento if (is_numeric($value)) { return floatval($value); } return null; } /** * Import transactions from file */ public function importTransactions( string $filePath, ImportMapping $mapping, int $userId, ?int $accountId = null, ?int $categoryId = null, ?int $costCenterId = null ): ImportLog { $originalFilename = basename($filePath); $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); // Usar ID do mapeamento apenas se foi persistido (existe no banco) $mappingId = $mapping->exists ? $mapping->id : null; // Criar log de importação $importLog = ImportLog::create([ 'user_id' => $userId, 'import_mapping_id' => $mappingId, 'original_filename' => $originalFilename, 'file_type' => $extension, 'status' => ImportLog::STATUS_PROCESSING, ]); try { // Parse arquivo $parsed = $this->parseFile($filePath, $mapping); $importLog->update(['total_rows' => $parsed['total_rows']]); $imported = 0; $skipped = 0; $errors = 0; $errorDetails = []; // Usar conta/categoria/centro de custo do mapping se não especificados $accountId = $accountId ?? $mapping->default_account_id; $categoryId = $categoryId ?? $mapping->default_category_id; $costCenterId = $costCenterId ?? $mapping->default_cost_center_id; DB::beginTransaction(); foreach ($parsed['data'] as $rowIndex => $row) { try { $mapped = $this->applyMapping($row, $mapping); // Validar campos obrigatórios if (empty($mapped['effective_date']) || !isset($mapped['amount'])) { $skipped++; $errorDetails[] = [ 'row' => $rowIndex + 1, 'error' => 'Campos obrigatórios ausentes (data ou valor)', 'reason' => 'missing_required', ]; continue; } // Determinar tipo (crédito/débito) baseado no valor $amount = $mapped['amount']; $type = 'debit'; if (isset($mapped['type'])) { $typeValue = strtolower($mapped['type']); if (in_array($typeValue, ['credit', 'crédito', 'credito', 'c', '+'])) { $type = 'credit'; } elseif (in_array($typeValue, ['debit', 'débito', 'debito', 'd', '-'])) { $type = 'debit'; } } elseif ($amount > 0) { $type = 'credit'; } elseif ($amount < 0) { $type = 'debit'; $amount = abs($amount); } // Obter descrição original (para hash e referência) $originalDescription = $mapped['description'] ?? ''; // Obter saldo se disponível no extrato (usado APENAS para o hash) $balance = $mapped['balance'] ?? null; // Gerar hash único para evitar duplicidade (data + valor + descrição + saldo se houver) // O saldo é usado APENAS para diferenciar transações idênticas no mesmo dia // NÃO é armazenado na BD para não interferir no cálculo dinâmico de saldo $importHash = Transaction::generateImportHash( $mapped['effective_date'], $amount, $originalDescription, $balance // Pode ser null se não mapeado ); // Verificar se já existe transação com este hash if (Transaction::existsByHash($userId, $importHash)) { $skipped++; $errorDetails[] = [ 'row' => $rowIndex + 1, 'error' => 'Transação já importada anteriormente', 'reason' => 'duplicate', 'hash' => substr($importHash, 0, 16) . '...', ]; continue; } // Criar transação importada // Nota: Transações importadas são sempre 'completed' e sem categoria // A categorização deve ser feita manualmente pelo usuário após a importação Transaction::create([ 'user_id' => $userId, 'account_id' => $accountId, 'category_id' => null, // Importações são sempre sem categoria 'cost_center_id' => $costCenterId, 'amount' => abs($amount), 'planned_amount' => abs($amount), 'type' => $type, 'description' => $originalDescription, 'original_description' => $originalDescription, 'effective_date' => $mapped['effective_date'], 'planned_date' => $mapped['planned_date'] ?? $mapped['effective_date'], 'status' => 'completed', // Importações são sempre concluídas 'notes' => $mapped['notes'] ?? null, 'reference' => $mapped['reference'] ?? null, 'import_hash' => $importHash, 'import_log_id' => $importLog->id, ]); $imported++; } catch (\Exception $e) { $errors++; $errorDetails[] = [ 'row' => $rowIndex + 1, 'error' => $e->getMessage(), 'data' => $row, ]; Log::warning("Import error at row $rowIndex", [ 'error' => $e->getMessage(), 'row' => $row, ]); } } DB::commit(); $importLog->markAsCompleted($imported, $skipped, $errors, $errorDetails); } catch (\Exception $e) { DB::rollBack(); $importLog->markAsFailed([ 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } return $importLog->fresh(); } /** * Create predefined mapping for known bank formats */ public function createBankPreset(string $bankName, int $userId): ImportMapping { $presets = [ 'bbva' => [ 'name' => 'BBVA España', 'bank_name' => 'BBVA', 'file_type' => 'xlsx', 'header_row' => 4, 'data_start_row' => 5, 'date_format' => 'd/m/Y', 'decimal_separator' => ',', 'thousands_separator' => '.', 'column_mappings' => [ 'effective_date' => ['columns' => [0], 'concat_separator' => null], 'planned_date' => ['columns' => [1], 'concat_separator' => null], 'description' => ['columns' => [2, 3], 'concat_separator' => ' - '], 'amount' => ['columns' => [4], 'concat_separator' => null], 'notes' => ['columns' => [8], 'concat_separator' => null], ], ], 'santander' => [ 'name' => 'Santander España', 'bank_name' => 'Santander', 'file_type' => 'xls', 'header_row' => 7, 'data_start_row' => 8, 'date_format' => 'd/m/Y', 'decimal_separator' => ',', 'thousands_separator' => '.', 'column_mappings' => [ 'effective_date' => ['columns' => [0], 'concat_separator' => null], 'planned_date' => ['columns' => [1], 'concat_separator' => null], 'description' => ['columns' => [2], 'concat_separator' => null], 'amount' => ['columns' => [3], 'concat_separator' => null], ], ], 'caixa' => [ 'name' => 'CaixaBank', 'bank_name' => 'CaixaBank', 'file_type' => 'xlsx', 'header_row' => 0, 'data_start_row' => 1, 'date_format' => 'd/m/Y', 'decimal_separator' => ',', 'thousands_separator' => '.', 'column_mappings' => [ 'effective_date' => ['columns' => [0], 'concat_separator' => null], 'description' => ['columns' => [1], 'concat_separator' => null], 'amount' => ['columns' => [2], 'concat_separator' => null], ], ], ]; $key = strtolower($bankName); if (!isset($presets[$key])) { throw new \InvalidArgumentException("Unknown bank preset: $bankName"); } $preset = $presets[$key]; $preset['user_id'] = $userId; return ImportMapping::create($preset); } /** * Get available bank presets */ public function getAvailablePresets(): array { return [ 'bbva' => [ 'name' => 'BBVA España', 'file_types' => ['xlsx'], 'description' => 'Extrato BBVA em formato Excel', ], 'santander' => [ 'name' => 'Santander España', 'file_types' => ['xls'], 'description' => 'Extrato Santander em formato Excel 97-2003', ], 'caixa' => [ 'name' => 'CaixaBank', 'file_types' => ['xlsx'], 'description' => 'Extrato CaixaBank em formato Excel', ], ]; } /** * Suggest mapping based on file headers */ public function suggestMapping(array $headers): array { $suggestions = []; $patterns = [ 'effective_date' => [ '/fecha.*valor/i', '/f\.?\s*valor/i', '/date/i', '/data/i', '/fecha.*operaci[oó]n/i', '/data.*efetiva/i', '/value.*date/i' ], 'planned_date' => [ '/fecha(?!.*valor)/i', '/planned.*date/i', '/data.*planejada/i', '/fecha.*contable/i' ], 'description' => [ '/concepto/i', '/descri[çc][ãa]o/i', '/description/i', '/memo/i', '/movimiento/i', '/name/i', '/payee/i' ], 'amount' => [ '/importe/i', '/valor/i', '/amount/i', '/monto/i', '/trnamt/i', '/quantia/i', '/value/i' ], 'balance' => [ '/saldo/i', '/balance/i', '/disponible/i', '/available/i' ], 'type' => [ '/tipo/i', '/type/i', '/trntype/i', '/credit.*debit/i' ], 'notes' => [ '/observa/i', '/notes/i', '/notas/i', '/comment/i' ], 'reference' => [ '/refer[êe]ncia/i', '/reference/i', '/fitid/i', '/n[úu]mero/i' ], ]; foreach ($headers as $index => $header) { if ($header === null || $header === '') { continue; } $headerStr = (string) $header; foreach ($patterns as $field => $fieldPatterns) { foreach ($fieldPatterns as $pattern) { if (preg_match($pattern, $headerStr)) { if (!isset($suggestions[$field])) { $suggestions[$field] = [ 'columns' => [$index], 'concat_separator' => null, 'header_name' => $headerStr, ]; } break 2; } } } } return $suggestions; } }