webmoney/backend/app/Http/Controllers/Api/CategoryController.php
marco 54cccdd095 refactor: migração para desenvolvimento direto no servidor
- Removido README.md padrão do Laravel (backend)
- Removidos scripts de deploy (não mais necessários)
- Atualizado copilot-instructions.md para novo fluxo
- Adicionada documentação de auditoria do servidor
- Sincronizado código de produção com repositório

Novo workflow:
- Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney)
- Mudanças PHP são instantâneas
- Mudanças React requerem 'npm run build'
- Commit após validação funcional
2025-12-19 11:45:32 +01:00

783 lines
28 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\CategoryKeyword;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class CategoryController extends Controller
{
/**
* Listar todas as categorias do usuário (hierárquico)
*/
public function index(Request $request): JsonResponse
{
$query = Category::where('user_id', Auth::id())
->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,
]
]);
}
}