feat(admin): manual deposit recording, transaction export, ticket filters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-15 21:53:41 +07:00
co-authored by Claude Opus 5.5
parent 40bf6b54f9
commit 0cdf224309
7 changed files with 336 additions and 33 deletions
@@ -17,26 +17,29 @@ class TicketController extends Controller
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
/**
* List all tickets (paginated, filterable by status / customer_id / search).
*/
public function index(Request $request)
{
$filters = $request->validate([
'status' => ['nullable', 'array'],
'status.*' => ['string', 'in:open,pending,resolved,closed'],
'room' => ['nullable', 'string', 'max:50'],
'customer_id' => ['nullable', 'integer'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'search' => ['nullable', 'string', 'max:255'],
]);
$query = Ticket::query()
->with(['customer'])
->latest('id');
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($request->filled('customer_id')) {
$query->where('customer_id', $request->input('customer_id'));
}
if ($request->filled('search')) {
$query->where('subject', 'like', '%' . $request->input('search') . '%');
}
->latest('id')
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room))
->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->when($filters['search'] ?? null, fn ($q, $search) => $q->where(
fn ($q) => $q->where('subject', 'like', "%{$search}%")->orWhere('ticket_num', 'like', "%{$search}%")
));
$paginated = $query->paginate($request->input('per_page', 15));
@@ -3,12 +3,15 @@
namespace App\Http\Controllers\Api\Admin\Transaction;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Api\Admin\Transaction\Concerns\ExportsTransactions;
use App\Http\Controllers\Api\Admin\Transaction\Concerns\RecordsManualDeposits;
use App\Http\Controllers\Controller;
use App\Mail\Customer\ManualDepositStatus;
use App\Models\Customer;
use App\Models\Notification;
use App\Models\Log;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -18,6 +21,9 @@ use Illuminate\Support\Facades\Storage;
class AdminTransactionController extends Controller
{
use ExportsTransactions;
use RecordsManualDeposits;
public function pendingCount(): JsonResponse
{
$count = Transaction::where('type', 'manual')
@@ -30,23 +36,7 @@ class AdminTransactionController extends Controller
public function index(Request $request): JsonResponse
{
$query = Transaction::with('customer')
->where('type', 'manual')
->orderByDesc('created_at');
if ($request->filled('status')) {
$query->where('status', $request->integer('status'));
}
if ($request->filled('filter')) {
$search = $request->string('filter')->trim();
$query->where(function ($q) use ($search) {
$q->where('public_code', 'like', "%{$search}%")
->orWhereHas('customer', fn($c) => $c
->where('fullname', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
});
}
$query = $this->filteredQuery($request->only(['filter', 'status']))->with('user');
$perPage = $request->integer('per_page', 15);
$paginated = $query->paginate($perPage);
@@ -66,6 +56,7 @@ class AdminTransactionController extends Controller
'status_label' => TransactionStatus::from((int) $t->status)->label(),
'proof_image' => $t->proof_image_urls[0] ?? null,
'created_at' => $t->created_at?->toISOString(),
'created_by' => $t->user?->name ?: $t->user?->username,
'customer' => $t->customer ? [
'uuid' => $t->customer->uuid,
'fullname' => $t->customer->fullname,
@@ -331,6 +322,42 @@ class AdminTransactionController extends Controller
return response()->json(['error' => false, 'message' => 'Đã xoá giao dịch']);
}
private function filteredQuery(array $filters): Builder
{
$query = Transaction::with('customer')
->where('type', 'manual')
->orderByDesc('created_at');
$status = $filters['status'] ?? null;
if ($status !== null && $status !== '' && $status !== []) {
$query->whereIn('status', array_map('intval', (array) $status));
}
$search = trim((string) ($filters['filter'] ?? ''));
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('public_code', 'like', "%{$search}%")
->orWhereHas('customer', fn($c) => $c
->where('fullname', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
});
}
if (! empty($filters['customer_id'])) {
$query->where('customer_id', (int) $filters['customer_id']);
}
if (! empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
return $query;
}
private function releasePending(Customer $customer, Transaction $transaction, int $amount): void
{
if ($transaction->proof_image) {
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Api\Admin\Transaction\Concerns;
use App\Exports\BrandedReportWriter;
use App\Exports\TransactionsExport;
use App\Http\Requests\BillingExportRequest;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
trait ExportsTransactions
{
public function export(BillingExportRequest $request, BrandedReportWriter $writer): BinaryFileResponse
{
$query = $this->filteredQuery(
$request->safe()->only(['filter', 'status', 'date_from', 'date_to', 'customer_id'])
)->with('user');
return $writer->download(new TransactionsExport($query), 'giao-dich-' . now()->format('Y-m-d-His') . '.xlsx');
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Api\Admin\Transaction\Concerns;
use App\Enums\TransactionStatus;
use App\Models\Customer;
use App\Models\Log;
use App\Models\Notification;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
trait RecordsManualDeposits
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'customer_id' => 'required|integer|exists:customers,id',
'amount' => 'required|integer|min:1000',
'note' => 'nullable|string|max:1000',
'proof' => 'nullable|image|mimes:jpeg,jpg,png,webp|max:5120',
]);
$admin = Auth::guard('admin')->user();
$proofPath = $request->file('proof')?->store('deposit-proofs/' . $data['customer_id'], 'local');
try {
$transaction = DB::transaction(function () use ($data, $admin, $proofPath, $request) {
$customer = Customer::whereKey($data['customer_id'])->lockForUpdate()->firstOrFail();
$amount = (int) $data['amount'];
$creditEarned = Customer::creditFor($amount);
$publicCode = Transaction::newPublicCode('MAN');
$transaction = Transaction::create([
'uuid' => (string) Str::uuid(),
'public_code' => $publicCode,
'trans' => str_replace('-', '', $publicCode),
'amount' => $amount,
'received_amount' => $amount,
'customer_id' => $customer->id,
'user_id' => $admin?->id,
'type' => 'manual',
'status' => TransactionStatus::COMPLETED->value,
'note' => trim($data['note'] ?? '') ?: null,
'proof_image' => $proofPath,
]);
$customer->total += $amount;
$customer->credit += $creditEarned;
$customer->save();
Log::create([
'agent' => 'admin',
'client_ip' => $request->ip(),
'description' => sprintf(
'%s nạp tiền thủ công cho khách hàng: %s VNĐ (+%d credit) - %s',
$admin?->username ?? $admin?->email ?? 'Admin',
number_format($amount),
$creditEarned,
$transaction->public_code,
),
'customer_id' => $customer->id,
]);
Notification::notifyCustomer($customer, [
'type' => 'deposit_success',
'title' => 'Nạp tiền thành công',
'body' => 'Giao dịch ' . $transaction->public_code . ' đã được cộng vào tài khoản.',
'link' => '/lich-su-giao-dich/' . $transaction->uuid,
'data' => [
'transaction_uuid' => $transaction->uuid,
'amount' => $amount,
'public_code' => $transaction->public_code,
],
]);
return $transaction;
});
} catch (\Throwable $e) {
if ($proofPath) {
Storage::disk('local')->delete($proofPath);
}
throw $e;
}
return response()->json([
'error' => false,
'message' => 'Đã nạp tiền cho khách hàng',
'data' => ['uuid' => $transaction->uuid, 'public_code' => $transaction->public_code],
]);
}
}