- Corrigir validação de keywords permitindo array vazio - Atualizar todas transações existentes sem centro de custo para Geral - Criar centro de custo Geral para usuários sem ele - Associar automaticamente novas transações criadas ao Geral - Associar automaticamente transferências ao Geral - Associar automaticamente transações importadas ao Geral
539 lines
19 KiB
PHP
Executable File
539 lines
19 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Services\Import;
|
|
|
|
use App\Models\ImportMapping;
|
|
use App\Models\ImportLog;
|
|
use App\Models\Transaction;
|
|
use App\Models\Category;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class ImportService
|
|
{
|
|
protected array $parsers = [
|
|
ExcelParser::class,
|
|
CsvParser::class,
|
|
OfxParser::class,
|
|
PdfParser::class,
|
|
];
|
|
|
|
/**
|
|
* Get appropriate parser for file type
|
|
*/
|
|
public function getParser(string $extension): FileParserInterface
|
|
{
|
|
foreach ($this->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;
|
|
|
|
// Se não há centro de custo definido, usar o Geral (sistema)
|
|
if (!$costCenterId) {
|
|
$generalCostCenter = \App\Models\CostCenter::where('user_id', $userId)
|
|
->where('is_system', true)
|
|
->first();
|
|
$costCenterId = $generalCostCenter ? $generalCostCenter->id : null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|