From 4daf52ef3e209a530a299f673b855291a4df4f94 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Sat, 25 Jul 2026 11:05:34 +0700 Subject: [PATCH] feat: Add customer notes functionality and enhance invoice/order models with date fields --- .../Api/Admin/Customer/CustomerController.php | 253 +++++++++++++++++- app/Models/Invoice.php | 1 + app/Models/Note.php | 7 + app/Models/Order.php | 5 + ...25_000001_add_service_id_to_note_table.php | 28 ++ ...25_000002_change_note_note_to_longtext.php | 18 ++ ...7_25_000003_add_date_to_invoices_table.php | 26 ++ ..._07_25_000004_add_date_to_orders_table.php | 26 ++ routes/api.php | 15 ++ 9 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_07_25_000001_add_service_id_to_note_table.php create mode 100644 database/migrations/2026_07_25_000002_change_note_note_to_longtext.php create mode 100644 database/migrations/2026_07_25_000003_add_date_to_invoices_table.php create mode 100644 database/migrations/2026_07_25_000004_add_date_to_orders_table.php diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index dea478d..f0db3e7 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -8,6 +8,7 @@ use App\Http\Resources\Api\Admin\CustomerResource; use App\Mail\Customer\ManualCustomerEmail; use App\Models\Customer; use App\Models\Invoice; +use App\Models\Note; use App\Models\Order; use App\Models\Template; use App\Models\Transaction; @@ -801,12 +802,67 @@ class CustomerController extends Controller ['payment'] ); + // Frontend đọc ngày thanh toán qua `date_paid` — alias từ cột paid_at. + $invoices->each(fn($invoice) => $invoice->date_paid = $invoice->paid_at); + return response()->json([ 'error' => false, 'data' => $invoices ]); } + /** + * Sửa hóa đơn của khách hàng, kể cả số tiền (sub_total/tax/total). Không + * tự đồng bộ lại invoice_items hay customer.total. + */ + public function updateInvoice(string $uuid, int $invoiceId, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $invoice = Invoice::where('customer_id', $customer->id) + ->where('id', $invoiceId) + ->first(); + if (!$invoice) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy hóa đơn'], 404); + } + + $data = $request->validate([ + 'invoice_number' => 'nullable|string|max:50', + 'status' => 'nullable|integer|in:1,2,3,4,5', + 'payment_method' => 'nullable|in:transfer,balance', + 'date' => 'nullable|date', + 'due_date' => 'nullable|date', + 'date_paid' => 'nullable|date', + 'note' => 'nullable|string', + 'sub_total' => 'nullable|integer|min:0', + 'tax' => 'nullable|integer|min:0', + 'total' => 'nullable|integer|min:0', + ]); + + // `date_paid` là tên field phía frontend — cột thật trong DB là paid_at. + if (array_key_exists('date_paid', $data)) { + $data['paid_at'] = $data['date_paid']; + unset($data['date_paid']); + } + + // Đi qua repository (không gọi $invoice->update() trực tiếp) để cache + // của invoiceRepository->filter() (dùng ở getInvoices) được xoá theo, + // nếu không danh sách hóa đơn sẽ hiển thị dữ liệu cũ cho tới khi cache hết hạn. + $this->invoiceRepository->update($invoiceId, $data); + + $invoice = $invoice->refresh(); + $invoice->date_paid = $invoice->paid_at; + + return response()->json([ + 'error' => false, + 'message' => 'Đã cập nhật hóa đơn', + 'data' => $invoice->load('payment'), + ]); + } + public function getTransactions(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); @@ -829,6 +885,46 @@ class CustomerController extends Controller ]); } + /** + * Sửa thông tin giao dịch, kể cả amount (để sửa số tiền nhập/hiển thị sai). + * Không tự động cộng/trừ lại customer.total khi sửa amount — chiều +/- của + * amount phụ thuộc vào type (balance trừ tiền, transfer/admin_add_total + * cộng tiền,...) và không có field nào lưu lại chiều đó, nên tự suy luận + * dễ làm sai số dư hơn là sửa đúng. Nếu cần đồng bộ lại số dư, dùng + * "Cộng tiền" ở tab Tổng quan sau khi sửa xong. + */ + public function updateTransaction(string $uuid, int $transactionId, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $transaction = Transaction::where('customer_id', $customer->id) + ->where('id', $transactionId) + ->first(); + if (!$transaction) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy giao dịch'], 404); + } + + $data = $request->validate([ + 'trans' => 'nullable|string|max:255', + 'note' => 'nullable|string', + 'type' => 'nullable|string|max:50', + 'amount' => 'nullable|integer', + ]); + + // Đi qua repository để cache của transactionRepository->filter() + // (dùng ở getTransactions) được xoá theo — xem ghi chú ở updateInvoice. + $this->transactionRepository->update($transactionId, $data); + + return response()->json([ + 'error' => false, + 'message' => 'Đã cập nhật giao dịch', + 'data' => $transaction->refresh()->load('user'), + ]); + } + public function getDomains(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); @@ -992,6 +1088,7 @@ class CustomerController extends Controller 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', + 'price' => 'nullable|numeric|min:0', // module_vmware fields 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', @@ -1008,10 +1105,14 @@ class CustomerController extends Controller DB::beginTransaction(); $service->fill(collect($data)->except([ - 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port', + 'price', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port', ])->all()); $service->save(); + if (array_key_exists('price', $data) && $data['price'] !== null && $service->orderItem) { + $service->orderItem->update(['price' => $data['price']]); + } + // Đồng bộ sang module_vmware nếu có gán VM if ($service->vm_id) { $vmData = array_filter([ @@ -1133,6 +1234,7 @@ class CustomerController extends Controller $order = Order::create([ 'order_num' => OrderHelper::generateOrderNumber(), 'customer_id' => $customer->id, + 'date' => now()->toDateString(), 'sub_total' => $basePrice, 'discount_amount' => 0, 'total' => $basePrice, @@ -1201,6 +1303,7 @@ class CustomerController extends Controller 'customer_id' => $customer->id, 'order_id' => $order->id, 'invoice_type' => 1, + 'date' => now()->toDateString(), 'sub_total' => $basePrice, 'discount_amount' => 0, 'tax' => 0, @@ -1357,6 +1460,46 @@ class CustomerController extends Controller ]); } + /** + * Sửa đơn hàng của khách hàng, kể cả số tiền (sub_total/discount_amount/ + * total). Không tự đồng bộ lại invoice/invoice_item hay customer.total — + * nếu số tiền hóa đơn liên kết cũng cần sửa, sửa riêng ở updateInvoice. + */ + public function updateOrder(string $uuid, int $orderId, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $order = Order::where('customer_id', $customer->id) + ->where('id', $orderId) + ->first(); + if (!$order) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy đơn hàng'], 404); + } + + $data = $request->validate([ + 'order_num' => 'nullable|string|max:255', + 'status' => 'nullable|integer|in:0,1,2,3', + 'payment_method' => 'nullable|in:transfer,balance', + 'date' => 'nullable|date', + 'sub_total' => 'nullable|integer|min:0', + 'discount_amount' => 'nullable|integer|min:0', + 'total' => 'nullable|integer|min:0', + ]); + + // Đi qua repository để cache của orderRepository->filter() + // (dùng ở getOrders) được xoá theo — xem ghi chú ở updateInvoice. + $this->orderRepository->update($orderId, $data); + + return response()->json([ + 'error' => false, + 'message' => 'Đã cập nhật đơn hàng', + 'data' => $order->refresh()->load(['payment', 'invoice']), + ]); + } + public function updateStatus(string $uuid, Request $request): JsonResponse { $request->validate(['status' => ['required', 'integer', 'in:0,1,2,3']]); @@ -1453,6 +1596,114 @@ class CustomerController extends Controller return response()->json(['error' => false, 'message' => "Đã cập nhật trạng thái {$count} khách hàng"]); } + /** + * Ghi chú của khách hàng (không gắn với dịch vụ cụ thể). + */ + public function getCustomerNotes(string $uuid): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $notes = Note::with('user:id,username') + ->where('customer_id', $customer->id) + ->whereNull('service_id') + ->latest() + ->get(); + + return response()->json(['error' => false, 'data' => $notes]); + } + + public function storeCustomerNote(string $uuid, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $data = $request->validate(['note' => 'required|string']); + + $note = Note::create([ + 'note' => $data['note'], + 'customer_id' => $customer->id, + 'user_id' => Auth::id(), + ]); + + return response()->json([ + 'error' => false, + 'message' => 'Đã thêm ghi chú', + 'data' => $note->load('user:id,username'), + ]); + } + + public function destroyCustomerNote(string $uuid, int $noteId): JsonResponse + { + return $this->destroyNote($uuid, $noteId); + } + + /** + * Ghi chú gắn với 1 dịch vụ cụ thể của khách hàng. + */ + public function getServiceNotes(string $uuid, int $serviceId): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $notes = Note::with('user:id,username') + ->where('customer_id', $customer->id) + ->where('service_id', $serviceId) + ->latest() + ->get(); + + return response()->json(['error' => false, 'data' => $notes]); + } + + public function storeServiceNote(string $uuid, int $serviceId, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $data = $request->validate(['note' => 'required|string']); + + $note = Note::create([ + 'note' => $data['note'], + 'customer_id' => $customer->id, + 'service_id' => $serviceId, + 'user_id' => Auth::id(), + ]); + + return response()->json([ + 'error' => false, + 'message' => 'Đã thêm ghi chú', + 'data' => $note->load('user:id,username'), + ]); + } + + public function destroyServiceNote(string $uuid, int $serviceId, int $noteId): JsonResponse + { + return $this->destroyNote($uuid, $noteId); + } + + private function destroyNote(string $uuid, int $noteId): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $deleted = Note::where('id', $noteId)->where('customer_id', $customer->id)->delete(); + if (!$deleted) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy ghi chú'], 404); + } + + return response()->json(['error' => false, 'message' => 'Đã xoá ghi chú']); + } + private function cycleToMonths(?string $cycle): int { return match ($cycle) { diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 7dbafc8..4917147 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -17,6 +17,7 @@ class Invoice extends Model protected $guarded = []; protected $casts = [ + 'date' => 'date', 'due_date' => 'date', 'paid_at' => 'datetime', 'auto_renew' => 'boolean', diff --git a/app/Models/Note.php b/app/Models/Note.php index 175d8de..287f0ce 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class Note extends Model { @@ -14,6 +15,12 @@ class Note extends Model protected $fillable = [ 'note', 'customer_id', + 'service_id', 'user_id' ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } } diff --git a/app/Models/Order.php b/app/Models/Order.php index ecfcb75..02e3507 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -15,6 +15,11 @@ class Order extends Model protected $table = 'orders'; protected $guarded = []; + + protected $casts = [ + 'date' => 'date', + ]; + public function customer() { return $this->belongsTo(Customer::class); diff --git a/database/migrations/2026_07_25_000001_add_service_id_to_note_table.php b/database/migrations/2026_07_25_000001_add_service_id_to_note_table.php new file mode 100644 index 0000000..bddd4fe --- /dev/null +++ b/database/migrations/2026_07_25_000001_add_service_id_to_note_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('service_id')->nullable()->index()->after('customer_id'); + }); + + DB::statement('ALTER TABLE note MODIFY note LONGTEXT NULL'); + } + + public function down(): void + { + Schema::table('note', function (Blueprint $table) { + $table->dropColumn('service_id'); + }); + + DB::statement('ALTER TABLE note MODIFY note VARCHAR(255) NULL'); + } +}; diff --git a/database/migrations/2026_07_25_000002_change_note_note_to_longtext.php b/database/migrations/2026_07_25_000002_change_note_note_to_longtext.php new file mode 100644 index 0000000..97aa050 --- /dev/null +++ b/database/migrations/2026_07_25_000002_change_note_note_to_longtext.php @@ -0,0 +1,18 @@ +date('date')->nullable()->after('invoice_number'); + }); + + // Backfill: ngày lập = ngày tạo, cho các hóa đơn đã có sẵn. + DB::statement('UPDATE invoices SET date = DATE(created_at) WHERE date IS NULL'); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('date'); + }); + } +}; diff --git a/database/migrations/2026_07_25_000004_add_date_to_orders_table.php b/database/migrations/2026_07_25_000004_add_date_to_orders_table.php new file mode 100644 index 0000000..e039caf --- /dev/null +++ b/database/migrations/2026_07_25_000004_add_date_to_orders_table.php @@ -0,0 +1,26 @@ +date('date')->nullable()->after('order_num'); + }); + + // Backfill: ngày đặt = ngày tạo, cho các đơn hàng đã có sẵn. + DB::statement('UPDATE orders SET date = DATE(created_at) WHERE date IS NULL'); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('date'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index e7c2b63..3020070 100644 --- a/routes/api.php +++ b/routes/api.php @@ -132,9 +132,24 @@ Route::prefix('v1') 'perm:customer.edit', ); Route::put('{uuid}/services/{serviceId}', 'updateService')->middleware('perm:customer.edit'); + Route::get('{uuid}/notes', 'getCustomerNotes')->middleware('perm:customer.view'); + Route::post('{uuid}/notes', 'storeCustomerNote')->middleware('perm:customer.edit'); + Route::delete('{uuid}/notes/{noteId}', 'destroyCustomerNote')->middleware('perm:customer.edit'); + Route::get('{uuid}/services/{serviceId}/notes', 'getServiceNotes')->middleware('perm:customer.view'); + Route::post('{uuid}/services/{serviceId}/notes', 'storeServiceNote')->middleware('perm:customer.edit'); + Route::delete('{uuid}/services/{serviceId}/notes/{noteId}', 'destroyServiceNote')->middleware( + 'perm:customer.edit', + ); Route::get('{uuid}/orders', 'getOrders')->middleware('perm:customer.view'); + Route::put('{uuid}/orders/{orderId}', 'updateOrder')->middleware('perm:customer.edit'); Route::get('{uuid}/invoices', 'getInvoices')->middleware('perm:customer.view'); + Route::put('{uuid}/invoices/{invoiceId}', 'updateInvoice')->middleware( + 'perm:customer.edit', + ); Route::get('{uuid}/transactions', 'getTransactions')->middleware('perm:customer.view'); + Route::put('{uuid}/transactions/{transactionId}', 'updateTransaction')->middleware( + 'perm:customer.edit', + ); Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view'); Route::get('{uuid}/dns-domains', 'getDnsDomains')->middleware('perm:customer.view'); Route::get('{uuid}/dns-domains/{domainId}/records', 'getDnsDomainRecords')->middleware(