feat: Add customer notes functionality and enhance invoice/order models with date fields

This commit is contained in:
baodev0
2026-07-25 11:05:34 +07:00
parent cd61e1d242
commit 4daf52ef3e
9 changed files with 378 additions and 1 deletions
@@ -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) {
+1
View File
@@ -17,6 +17,7 @@ class Invoice extends Model
protected $guarded = [];
protected $casts = [
'date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'auto_renew' => 'boolean',
+7
View File
@@ -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');
}
}
+5
View File
@@ -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);
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// ponytail: raw SQL for the column type change — this repo doesn't have doctrine/dbal installed
return new class extends Migration
{
public function up(): void
{
Schema::table('note', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,18 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
// ponytail: raw SQL for the column type change — this repo doesn't have doctrine/dbal installed
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE note MODIFY note LONGTEXT NULL');
}
public function down(): void
{
DB::statement('ALTER TABLE note MODIFY note TEXT NULL');
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->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');
});
}
};
+15
View File
@@ -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(