feat(admin): IP pool allocation, billing exports, service attachments

- IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke
  with notification email (ip_allocated/ip_revoked), ip_pool_logs history.
- Orders/invoices: Excel export on branded template (BrandedReportWriter),
  shared billing filters, order item service info in list responses.
- Web design quote Excel export; activity log export.
- Service attachments with copy-to-invoice; deployment invoice attachments.
- Customer email attachments stored on disk and attached by path.
- Admin password change endpoint; user.fullname backfill migration;
  orders.user_id backfill from logs.
- Disable deploy workflow until CI/CD is set up.
- Ignore stored customer email and service attachment uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-15 19:58:36 +07:00
co-authored by Claude Opus 5.5
parent ea5697cb81
commit 46aa138eec
95 changed files with 4247 additions and 454 deletions
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('service_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
$table->string('filename');
$table->string('path');
$table->string('disk', 50)->default('public');
$table->string('mime_type', 100)->nullable();
$table->unsignedBigInteger('size')->default(0);
$table->string('uploaded_by', 100)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('service_attachments');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$userIds = DB::table('user')->pluck('id', 'username');
DB::table('log')
->where('description', 'like', '% created new Order with Order id: %')
->where('username', '!=', 'System')
->orderBy('id')
->each(function ($log) use ($userIds) {
$userId = $userIds[$log->username] ?? null;
if (! $userId || ! preg_match('/Order id: (\d+)$/', $log->description, $match)) {
return;
}
DB::table('orders')
->where('id', (int) $match[1])
->whereNull('user_id')
->update(['user_id' => $userId]);
});
}
public function down(): void {}
};
@@ -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
{
if (! Schema::hasColumn('user', 'fullname')) {
Schema::table('user', function (Blueprint $table) {
$table->string('fullname', 255)->nullable()->after('id');
});
}
if (Schema::hasColumns('user', ['firstname', 'lastname'])) {
DB::table('user')
->where(fn ($query) => $query->whereNull('fullname')->orWhere('fullname', ''))
->update(['fullname' => DB::raw("TRIM(CONCAT(COALESCE(firstname, ''), ' ', COALESCE(lastname, '')))")]);
}
}
public function down(): void {}
};
@@ -0,0 +1,35 @@
<?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('ip_pools', function (Blueprint $table) {
$table->string('ip_block')->nullable()->after('ip_address');
$table->unsignedBigInteger('module_network_id')->nullable()->change();
});
DB::table('ip_pools')
->where('status', 'free')
->whereNull('service_id')
->whereRaw("UPPER(TRIM(allocation_status)) = 'ALLOCATED'")
->update(['status' => 'assigned']);
Schema::table('ip_pools', function (Blueprint $table) {
$table->dropColumn('allocation_status');
});
}
public function down(): void
{
Schema::table('ip_pools', function (Blueprint $table) {
$table->string('allocation_status')->nullable()->after('revoked_at');
$table->dropColumn('ip_block');
});
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ip_pools', function (Blueprint $table) {
$table->foreignId('customer_id')->nullable()->after('customer_name')->constrained('customers')->nullOnDelete();
});
Schema::create('ip_pool_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('ip_pool_id')->constrained('ip_pools')->cascadeOnDelete();
$table->string('ip_address', 45);
$table->string('action', 30)->index();
$table->string('status_from', 20)->nullable();
$table->string('status_to', 20)->nullable();
$table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete();
$table->string('customer_name')->nullable();
$table->unsignedBigInteger('service_id')->nullable();
$table->string('vm_name')->nullable();
$table->text('email_to')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->text('note')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('ip_pool_logs');
Schema::table('ip_pools', function (Blueprint $table) {
$table->dropConstrainedForeignId('customer_id');
});
}
};
@@ -0,0 +1,20 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$seeder = new TemplateSeeder();
$seeder->seedScenarioIfMissing('ip_allocated');
$seeder->seedScenarioIfMissing('ip_revoked');
}
public function down(): void
{
DB::table('template')->whereIn('scenario', ['ip_allocated', 'ip_revoked'])->delete();
}
};
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->reseedScenario('ip_allocated');
}
public function down(): void {}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ip_pool_logs', function (Blueprint $table) {
$table->date('effective_date')->nullable()->after('action');
$table->string('service_name')->nullable()->after('customer_name');
});
}
public function down(): void
{
Schema::table('ip_pool_logs', function (Blueprint $table) {
$table->dropColumn(['effective_date', 'service_name']);
});
}
};