feat: Add customer notes functionality and enhance invoice/order models with date fields
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -17,6 +17,7 @@ class Invoice extends Model
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
'auto_renew' => 'boolean',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user