webmoney/backend/app/Services/Import/ImportService.php
marco c787077a39 fix: associar automaticamente transações ao centro de custo Geral
- 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
2025-12-19 16:26:27 +01:00

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;
}
}