diff --git a/app/Exports/TransactionsExport.php b/app/Exports/TransactionsExport.php new file mode 100644 index 0000000..c1c4209 --- /dev/null +++ b/app/Exports/TransactionsExport.php @@ -0,0 +1,67 @@ +query; + } + + public function moneyColumns(): array + { + return ['E', 'F', 'G']; + } + + public function headings(): array + { + return [ + 'Mã giao dịch', 'Khách hàng', 'Email', 'Số điện thoại', 'Khách khai', 'Thực nhận', 'Chênh lệch', + 'Trạng thái', 'Chứng từ', 'Người nạp', 'Ghi chú', 'Ngày tạo', + ]; + } + + /** + * @param Transaction $transaction + */ + public function map($transaction): array + { + $received = $transaction->received_amount === null ? null : (int) $transaction->received_amount; + + return [ + $transaction->public_code ?? $transaction->trans, + $transaction->customer?->fullname, + $transaction->customer?->email, + $transaction->customer?->phone, + (int) $transaction->amount, + $received, + $received === null ? null : $received - (int) $transaction->amount, + $this->statusLabel($transaction), + $transaction->proof_image ? 'Có' : 'Không', + $transaction->user?->name ?: $transaction->user?->username, + $transaction->note, + $transaction->created_at?->format('d/m/Y H:i'), + ]; + } + + private function statusLabel(Transaction $transaction): string + { + if ((int) $transaction->status === TransactionStatus::PENDING->value && ! $transaction->proof_image) { + return 'Chưa gửi chứng từ'; + } + + return TransactionStatus::tryFrom((int) $transaction->status)?->label() ?? (string) $transaction->status; + } +} diff --git a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php index d93589a..0501231 100644 --- a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php +++ b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php @@ -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)); diff --git a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php index 8e3c4f2..f35c6fe 100644 --- a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php +++ b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php @@ -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) { diff --git a/app/Http/Controllers/Api/Admin/Transaction/Concerns/ExportsTransactions.php b/app/Http/Controllers/Api/Admin/Transaction/Concerns/ExportsTransactions.php new file mode 100644 index 0000000..fb44052 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Transaction/Concerns/ExportsTransactions.php @@ -0,0 +1,20 @@ +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'); + } +} diff --git a/app/Http/Controllers/Api/Admin/Transaction/Concerns/RecordsManualDeposits.php b/app/Http/Controllers/Api/Admin/Transaction/Concerns/RecordsManualDeposits.php new file mode 100644 index 0000000..924fcda --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Transaction/Concerns/RecordsManualDeposits.php @@ -0,0 +1,98 @@ +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], + ]); + } +} diff --git a/routes/api/admin/billing.php b/routes/api/admin/billing.php index 01e40f4..a7f4a8e 100644 --- a/routes/api/admin/billing.php +++ b/routes/api/admin/billing.php @@ -90,6 +90,8 @@ Route::prefix('transactions') ->controller(AdminTransactionController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:customer.view'); + Route::post('/', 'store')->middleware('perm:customer.edit'); + Route::get('export', 'export')->middleware('perm:customer.view'); Route::get('pending-count', 'pendingCount')->middleware('perm:customer.view'); Route::post('{uuid}/approve', 'approve')->middleware('perm:customer.edit'); Route::post('{uuid}/reject', 'reject')->middleware('perm:customer.edit'); diff --git a/tests/Feature/Api/Admin/Transaction/AdminManualDepositTest.php b/tests/Feature/Api/Admin/Transaction/AdminManualDepositTest.php new file mode 100644 index 0000000..65d5a8e --- /dev/null +++ b/tests/Feature/Api/Admin/Transaction/AdminManualDepositTest.php @@ -0,0 +1,86 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + } + + public function test_admin_deposit_credits_customer_and_stores_proof(): void + { + Storage::fake('local'); + $customer = Customer::factory()->create(['total' => 100_000, 'credit' => 0, 'total_pending' => 0]); + + $this->actingAs($this->admin, 'admin')->post('/api/v1/administrator/transactions', [ + 'customer_id' => $customer->id, + 'amount' => 500_000, + 'note' => 'CK Vietcombank', + 'proof' => UploadedFile::fake()->image('proof.png'), + ])->assertOk(); + + $customer->refresh(); + $this->assertSame(600_000, (int) $customer->total); + $this->assertSame(5_000, (int) $customer->credit); + $this->assertSame(0, (int) $customer->total_pending); + + $transaction = Transaction::where('customer_id', $customer->id)->sole(); + $this->assertSame('manual', $transaction->type); + $this->assertSame(1, (int) $transaction->status); + $this->assertSame(500_000, (int) $transaction->received_amount); + $this->assertSame($this->admin->id, $transaction->user_id); + $this->assertStringStartsWith('MAN-', $transaction->public_code); + Storage::disk('local')->assertExists($transaction->proof_image); + + $this->actingAs($this->admin, 'admin')->getJson('/api/v1/administrator/transactions') + ->assertJsonPath('data.items.0.created_by', $this->admin->name ?: $this->admin->username); + } + + public function test_export_writes_filtered_rows_into_template(): void + { + $customer = Customer::factory()->create(); + $other = Customer::factory()->create(); + foreach ([$customer, $customer, $other] as $owner) { + $this->actingAs($this->admin, 'admin') + ->post('/api/v1/administrator/transactions', ['customer_id' => $owner->id, 'amount' => 10_000]) + ->assertOk(); + } + + $all = $this->exportedSheet('/api/v1/administrator/transactions/export'); + $this->assertSame('DANH SÁCH GIAO DỊCH NẠP TIỀN', $all->getCell('A6')->getValue()); + $this->assertSame('Mã giao dịch', $all->getCell('A7')->getValue()); + $this->assertSame(10, $all->getHighestDataRow()); + + $this->assertSame(9, $this->exportedSheet("/api/v1/administrator/transactions/export?customer_id={$customer->id}")->getHighestDataRow()); + $this->assertSame(7, $this->exportedSheet('/api/v1/administrator/transactions/export?status[]=0')->getHighestDataRow()); + } + + private function exportedSheet(string $url): \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet + { + $response = $this->actingAs($this->admin, 'admin')->get($url)->assertOk(); + + return IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet(); + } +}