with(['keywords', 'children.keywords']); if ($request->has('type')) { $query->ofType($request->type); } if ($request->has('is_active')) { $query->where('is_active', $request->boolean('is_active')); } // Se flat=true, retorna todas as categorias if ($request->boolean('flat')) { $categories = $query->orderBy('name')->get(); } else { // Retorna apenas categorias raiz com filhas aninhadas $categories = $query->root()->orderBy('order')->orderBy('name')->get(); } return response()->json([ 'success' => true, 'data' => $categories, 'types' => Category::TYPES, ]); } /** * Criar nova categoria */ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:100', 'parent_id' => 'nullable|exists:categories,id', 'type' => ['nullable', Rule::in(array_keys(Category::TYPES))], 'description' => 'nullable|string', 'color' => 'nullable|string|max:7', 'icon' => 'nullable|string|max:50', 'order' => 'nullable|integer', 'is_active' => 'nullable|boolean', 'keywords' => 'nullable|array', 'keywords.*' => 'string|max:100', ]); $user = Auth::user(); $plan = $user->currentPlan(); // Check subcategory limit if parent_id is provided if (!empty($validated['parent_id']) && $plan) { $limits = $plan->limits ?? []; $subcategoryLimit = $limits['subcategories'] ?? null; if ($subcategoryLimit !== null) { $currentSubcategories = Category::where('user_id', $user->id) ->whereNotNull('parent_id') ->count(); if ($currentSubcategories >= $subcategoryLimit) { return response()->json([ 'success' => false, 'message' => "Has alcanzado el límite de {$subcategoryLimit} subcategorías de tu plan. Actualiza a Pro para subcategorías ilimitadas.", 'error' => 'plan_limit_exceeded', 'data' => [ 'resource' => 'subcategories', 'current' => $currentSubcategories, 'limit' => $subcategoryLimit, 'plan' => $plan->name, 'upgrade_url' => '/pricing', ], ], 403); } } } // Verificar se parent_id pertence ao usuário if (!empty($validated['parent_id'])) { $parent = Category::where('user_id', Auth::id()) ->findOrFail($validated['parent_id']); // Herdar tipo do pai se não especificado if (empty($validated['type'])) { $validated['type'] = $parent->type; } } $keywords = $validated['keywords'] ?? []; unset($validated['keywords']); $validated['user_id'] = Auth::id(); $validated['type'] = $validated['type'] ?? Category::TYPE_EXPENSE; DB::beginTransaction(); try { $category = Category::create($validated); // Adicionar palavras-chave foreach ($keywords as $keyword) { $category->keywords()->create([ 'keyword' => trim($keyword), 'is_case_sensitive' => false, 'is_active' => true, ]); } DB::commit(); return response()->json([ 'success' => true, 'message' => 'Categoria criada com sucesso', 'data' => $category->load(['keywords', 'parent', 'children']), ], 201); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'success' => false, 'message' => 'Erro ao criar categoria: ' . $e->getMessage(), ], 500); } } /** * Exibir uma categoria específica */ public function show(int $id): JsonResponse { $category = Category::where('user_id', Auth::id()) ->with(['keywords', 'parent', 'children.keywords']) ->findOrFail($id); return response()->json([ 'success' => true, 'data' => $category, ]); } /** * Atualizar uma categoria */ public function update(Request $request, int $id): JsonResponse { $category = Category::where('user_id', Auth::id())->findOrFail($id); $validated = $request->validate([ 'name' => 'sometimes|required|string|max:100', 'parent_id' => 'nullable|exists:categories,id', 'type' => ['nullable', Rule::in(array_keys(Category::TYPES))], 'description' => 'nullable|string', 'color' => 'nullable|string|max:7', 'icon' => 'nullable|string|max:50', 'order' => 'nullable|integer', 'is_active' => 'nullable|boolean', 'keywords' => 'nullable|array', 'keywords.*' => 'string|max:100', ]); // Verificar se parent_id pertence ao usuário e não é a própria categoria if (!empty($validated['parent_id'])) { if ($validated['parent_id'] == $id) { return response()->json([ 'success' => false, 'message' => 'Uma categoria não pode ser pai de si mesma', ], 422); } Category::where('user_id', Auth::id())->findOrFail($validated['parent_id']); } $keywords = $validated['keywords'] ?? null; unset($validated['keywords']); DB::beginTransaction(); try { $category->update($validated); // Se keywords foram fornecidas, sincronizar if ($keywords !== null) { $category->keywords()->delete(); foreach ($keywords as $keyword) { $category->keywords()->create([ 'keyword' => trim($keyword), 'is_case_sensitive' => false, 'is_active' => true, ]); } } DB::commit(); return response()->json([ 'success' => true, 'message' => 'Categoria atualizada com sucesso', 'data' => $category->fresh()->load(['keywords', 'parent', 'children']), ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'success' => false, 'message' => 'Erro ao atualizar categoria: ' . $e->getMessage(), ], 500); } } /** * Deletar uma categoria (soft delete) */ public function destroy(int $id): JsonResponse { $category = Category::where('user_id', Auth::id())->findOrFail($id); // Se tem filhas, mover para nível raiz if ($category->children()->exists()) { $category->children()->update(['parent_id' => null]); } $category->keywords()->delete(); $category->delete(); return response()->json([ 'success' => true, 'message' => 'Categoria excluída com sucesso', ]); } /** * Adicionar palavra-chave a uma categoria */ public function addKeyword(Request $request, int $id): JsonResponse { $category = Category::where('user_id', Auth::id())->findOrFail($id); $validated = $request->validate([ 'keyword' => 'required|string|max:100', 'is_case_sensitive' => 'nullable|boolean', ]); $keyword = $category->keywords()->create([ 'keyword' => trim($validated['keyword']), 'is_case_sensitive' => $validated['is_case_sensitive'] ?? false, 'is_active' => true, ]); return response()->json([ 'success' => true, 'message' => 'Palavra-chave adicionada com sucesso', 'data' => $keyword, ], 201); } /** * Remover palavra-chave de uma categoria */ public function removeKeyword(int $id, int $keywordId): JsonResponse { $category = Category::where('user_id', Auth::id())->findOrFail($id); $keyword = $category->keywords()->findOrFail($keywordId); $keyword->delete(); return response()->json([ 'success' => true, 'message' => 'Palavra-chave removida com sucesso', ]); } /** * Encontrar categoria por texto (usando palavras-chave) */ public function matchByText(Request $request): JsonResponse { $validated = $request->validate([ 'text' => 'required|string', 'type' => ['nullable', Rule::in(array_keys(Category::TYPES))], ]); $text = $validated['text']; $textLower = strtolower($text); $query = CategoryKeyword::whereHas('category', function ($query) use ($validated) { $query->where('user_id', Auth::id())->where('is_active', true); if (!empty($validated['type'])) { $query->ofType($validated['type']); } }) ->where('is_active', true) ->with('category'); $keywords = $query->get(); $matches = []; foreach ($keywords as $keyword) { $searchText = $keyword->is_case_sensitive ? $text : $textLower; $searchKeyword = $keyword->is_case_sensitive ? $keyword->keyword : strtolower($keyword->keyword); if (str_contains($searchText, $searchKeyword)) { $matches[] = [ 'category' => $keyword->category, 'matched_keyword' => $keyword->keyword, ]; } } return response()->json([ 'success' => true, 'data' => $matches, ]); } /** * Reordenar categorias */ public function reorder(Request $request): JsonResponse { $validated = $request->validate([ 'orders' => 'required|array', 'orders.*.id' => 'required|exists:categories,id', 'orders.*.order' => 'required|integer', ]); DB::beginTransaction(); try { foreach ($validated['orders'] as $orderData) { Category::where('user_id', Auth::id()) ->where('id', $orderData['id']) ->update(['order' => $orderData['order']]); } DB::commit(); return response()->json([ 'success' => true, 'message' => 'Ordem atualizada com sucesso', ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'success' => false, 'message' => 'Erro ao reordenar categorias: ' . $e->getMessage(), ], 500); } } /** * Categorizar transações em lote baseado em palavras-chave */ public function categorizeBatch(Request $request): JsonResponse { $validated = $request->validate([ 'only_uncategorized' => 'nullable|boolean', 'transaction_ids' => 'nullable|array', 'transaction_ids.*' => 'integer|exists:transactions,id', ]); $onlyUncategorized = $validated['only_uncategorized'] ?? true; $transactionIds = $validated['transaction_ids'] ?? null; // Buscar todas as categorias com keywords do usuário $categories = Category::where('user_id', Auth::id()) ->whereNotNull('parent_id') // Apenas subcategorias ->with('keywords') ->where('is_active', true) ->get(); // Construir mapa de keywords -> categoria $keywordMap = []; foreach ($categories as $category) { foreach ($category->keywords as $keyword) { if ($keyword->is_active) { $keywordMap[strtoupper($keyword->keyword)] = $category->id; } } } if (empty($keywordMap)) { return response()->json([ 'success' => false, 'message' => 'Nenhuma palavra-chave configurada nas categorias', 'data' => [ 'categorized' => 0, 'skipped' => 0, ] ]); } // Buscar transações para categorizar $query = \App\Models\Transaction::where('user_id', Auth::id()); if ($onlyUncategorized) { $query->whereNull('category_id'); } if ($transactionIds) { $query->whereIn('id', $transactionIds); } $transactions = $query->get(); $categorized = 0; $skipped = 0; foreach ($transactions as $transaction) { // Usar description ou original_description $text = strtoupper($transaction->original_description ?? $transaction->description ?? ''); $matched = false; foreach ($keywordMap as $keyword => $categoryId) { if (str_contains($text, $keyword)) { $transaction->category_id = $categoryId; $transaction->save(); $categorized++; $matched = true; break; // Usar primeira keyword que bater } } if (!$matched) { $skipped++; } } return response()->json([ 'success' => true, 'message' => "Categorização em lote concluída", 'data' => [ 'categorized' => $categorized, 'skipped' => $skipped, 'total_keywords' => count($keywordMap), ] ]); } /** * Preview da categorização em lote (sem salvar) */ public function categorizeBatchPreview(Request $request): JsonResponse { $validated = $request->validate([ 'only_uncategorized' => 'nullable|boolean', 'preview_limit' => 'nullable|integer|min:1|max:100', 'filters' => 'nullable|array', 'filters.account_id' => 'nullable|integer', 'filters.category_id' => 'nullable|integer', 'filters.cost_center_id' => 'nullable|integer', 'filters.type' => 'nullable|string|in:credit,debit', 'filters.status' => 'nullable|string|in:pending,completed,cancelled', 'filters.start_date' => 'nullable|date', 'filters.end_date' => 'nullable|date', 'filters.search' => 'nullable|string|max:255', ]); $onlyUncategorized = $validated['only_uncategorized'] ?? true; $previewLimit = $validated['preview_limit'] ?? 50; $filters = $validated['filters'] ?? []; // Buscar todas as categorias com keywords do usuário $categories = Category::where('user_id', Auth::id()) ->whereNotNull('parent_id') ->with(['keywords', 'parent']) ->where('is_active', true) ->get(); // Construir mapa de keywords -> categoria $keywordMap = []; $categoryNames = []; foreach ($categories as $category) { $categoryNames[$category->id] = ($category->parent ? $category->parent->name . ' > ' : '') . $category->name; foreach ($category->keywords as $keyword) { if ($keyword->is_active) { $keywordMap[strtoupper($keyword->keyword)] = [ 'category_id' => $category->id, 'category_name' => $categoryNames[$category->id], 'keyword' => $keyword->keyword, ]; } } } // Buscar transações com filtros aplicados $query = \App\Models\Transaction::where('user_id', Auth::id()); if ($onlyUncategorized) { $query->whereNull('category_id'); } // Aplicar filtros if (!empty($filters['account_id'])) { $query->where('account_id', $filters['account_id']); } if (!empty($filters['category_id'])) { $query->where('category_id', $filters['category_id']); } if (!empty($filters['cost_center_id'])) { $query->where('cost_center_id', $filters['cost_center_id']); } if (!empty($filters['type'])) { $query->where('type', $filters['type']); } if (!empty($filters['status'])) { $query->where('status', $filters['status']); } if (!empty($filters['start_date'])) { $query->where(function ($q) use ($filters) { $q->where('effective_date', '>=', $filters['start_date']) ->orWhere(function ($q2) use ($filters) { $q2->whereNull('effective_date') ->where('planned_date', '>=', $filters['start_date']); }); }); } if (!empty($filters['end_date'])) { $query->where(function ($q) use ($filters) { $q->where('effective_date', '<=', $filters['end_date']) ->orWhere(function ($q2) use ($filters) { $q2->whereNull('effective_date') ->where('planned_date', '<=', $filters['end_date']); }); }); } if (!empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('description', 'like', "%{$search}%") ->orWhere('original_description', 'like', "%{$search}%") ->orWhere('reference', 'like', "%{$search}%") ->orWhere('notes', 'like', "%{$search}%"); }); } $allTransactions = $query->get(); $preview = []; $wouldCategorize = 0; $wouldSkip = 0; $transactionIds = []; foreach ($allTransactions as $transaction) { $text = strtoupper($transaction->original_description ?? $transaction->description ?? ''); $matched = null; foreach ($keywordMap as $keyword => $info) { if (str_contains($text, $keyword)) { $matched = $info; break; } } if ($matched) { $wouldCategorize++; $transactionIds[] = $transaction->id; // Só adiciona preview até o limite if (count($preview) < $previewLimit) { $preview[] = [ 'transaction_id' => $transaction->id, 'description' => $transaction->description, 'amount' => $transaction->amount ?? $transaction->planned_amount, 'matched_keyword' => $matched['keyword'], 'category_id' => $matched['category_id'], 'category_name' => $matched['category_name'], ]; } } else { $wouldSkip++; } } // Contar total sem categoria (com filtros) $totalUncategorized = $allTransactions->whereNull('category_id')->count(); // Preparar lista de transações para seleção $transactions = $allTransactions->take(100)->map(function ($t) { return [ 'id' => $t->id, 'description' => $t->description, 'amount' => $t->amount ?? $t->planned_amount, 'type' => $t->type, 'effective_date' => $t->effective_date?->format('d/m/Y'), 'planned_date' => $t->planned_date?->format('d/m/Y'), ]; })->values(); return response()->json([ 'success' => true, 'data' => [ 'preview' => $preview, 'would_categorize' => $wouldCategorize, 'would_skip' => $wouldSkip, 'total_uncategorized' => $totalUncategorized, 'total_keywords' => count($keywordMap), 'total_filtered' => $allTransactions->count(), 'transaction_ids' => $transactionIds, 'transactions' => $transactions, ] ]); } /** * Categorização em lote manual - aplicar categoria/centro de custo selecionados */ public function categorizeBatchManual(Request $request): JsonResponse { $validated = $request->validate([ 'category_id' => 'nullable|integer|exists:categories,id', 'cost_center_id' => 'nullable|integer|exists:cost_centers,id', 'filters' => 'nullable|array', 'filters.account_id' => 'nullable|integer', 'filters.type' => 'nullable|string|in:credit,debit', 'filters.status' => 'nullable|string|in:pending,completed,cancelled', 'filters.start_date' => 'nullable|date', 'filters.end_date' => 'nullable|date', 'filters.search' => 'nullable|string|max:255', 'add_keyword' => 'nullable|boolean', 'transaction_ids' => 'nullable|array', 'transaction_ids.*' => 'integer', ]); $categoryId = $validated['category_id'] ?? null; $costCenterId = $validated['cost_center_id'] ?? null; $filters = $validated['filters'] ?? []; $addKeyword = $validated['add_keyword'] ?? false; $transactionIds = $validated['transaction_ids'] ?? null; // Verificar se pelo menos uma opção foi selecionada if (!$categoryId && !$costCenterId) { return response()->json([ 'success' => false, 'message' => 'Selecione pelo menos uma categoria ou centro de custo', ], 400); } // Verificar se a categoria pertence ao usuário if ($categoryId) { $category = Category::where('id', $categoryId) ->where('user_id', Auth::id()) ->first(); if (!$category) { return response()->json([ 'success' => false, 'message' => 'Categoria não encontrada', ], 404); } } // Verificar se o centro de custo pertence ao usuário if ($costCenterId) { $costCenter = \App\Models\CostCenter::where('id', $costCenterId) ->where('user_id', Auth::id()) ->first(); if (!$costCenter) { return response()->json([ 'success' => false, 'message' => 'Centro de custo não encontrado', ], 404); } } // Construir query - usar IDs se fornecidos, senão usar filtros $query = \App\Models\Transaction::where('user_id', Auth::id()); if (!empty($transactionIds)) { // Usar IDs específicos $query->whereIn('id', $transactionIds); } else { // Usar filtros if (!empty($filters['account_id'])) { $query->where('account_id', $filters['account_id']); } if (!empty($filters['type'])) { $query->where('type', $filters['type']); } if (!empty($filters['status'])) { $query->where('status', $filters['status']); } if (!empty($filters['start_date'])) { $query->where(function ($q) use ($filters) { $q->where('effective_date', '>=', $filters['start_date']) ->orWhere(function ($q2) use ($filters) { $q2->whereNull('effective_date') ->where('planned_date', '>=', $filters['start_date']); }); }); } if (!empty($filters['end_date'])) { $query->where(function ($q) use ($filters) { $q->where('effective_date', '<=', $filters['end_date']) ->orWhere(function ($q2) use ($filters) { $q2->whereNull('effective_date') ->where('planned_date', '<=', $filters['end_date']); }); }); } if (!empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('description', 'like', "%{$search}%") ->orWhere('original_description', 'like', "%{$search}%") ->orWhere('reference', 'like', "%{$search}%") ->orWhere('notes', 'like', "%{$search}%"); }); } } // Atualizar transações $updateData = []; if ($categoryId) { $updateData['category_id'] = $categoryId; } if ($costCenterId) { $updateData['cost_center_id'] = $costCenterId; } $updated = $query->update($updateData); // Adicionar keyword se solicitado e houver termo de busca $keywordAdded = false; $keywordText = null; if ($addKeyword && !empty($filters['search']) && strlen(trim($filters['search'])) >= 2) { $keywordText = trim($filters['search']); // Adicionar keyword à categoria (se selecionada) if ($categoryId) { $existingCategoryKeyword = \App\Models\CategoryKeyword::where('category_id', $categoryId) ->whereRaw('UPPER(keyword) = ?', [strtoupper($keywordText)]) ->first(); if (!$existingCategoryKeyword) { \App\Models\CategoryKeyword::create([ 'category_id' => $categoryId, 'keyword' => $keywordText, 'is_case_sensitive' => false, 'is_active' => true, ]); $keywordAdded = true; } } // Adicionar keyword ao centro de custo (se selecionado) if ($costCenterId) { $existingCostCenterKeyword = \App\Models\CostCenterKeyword::where('cost_center_id', $costCenterId) ->whereRaw('UPPER(keyword) = ?', [strtoupper($keywordText)]) ->first(); if (!$existingCostCenterKeyword) { \App\Models\CostCenterKeyword::create([ 'cost_center_id' => $costCenterId, 'keyword' => $keywordText, 'is_case_sensitive' => false, 'is_active' => true, ]); $keywordAdded = true; } } } return response()->json([ 'success' => true, 'message' => 'Categorização em lote concluída', 'data' => [ 'updated' => $updated, 'keyword_added' => $keywordAdded, 'keyword_text' => $keywordText, ] ]); } }