feat(admin): manual deposit recording, transaction export, ticket filters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40bf6b54f9
commit
0cdf224309
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Enums\TransactionStatus;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class TransactionsExport implements BrandedReport
|
||||
{
|
||||
public function __construct(private readonly Builder $query) {}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Danh sách giao dịch nạp tiền';
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Transaction;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminManualDepositTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Role::firstOrCreate(['name' => '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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user