Initial commit

This commit is contained in:
2025-11-20 23:21:05 +08:00
commit 13bc6db529
378 changed files with 54527 additions and 0 deletions

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,152 @@
<?php
namespace Database\Factories;
use App\Models\CashierLedgerEntry;
use App\Models\FinanceDocument;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CashierLedgerEntry>
*/
class CashierLedgerEntryFactory extends Factory
{
protected $model = CashierLedgerEntry::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$entryTypes = ['receipt', 'payment'];
$paymentMethods = ['cash', 'bank_transfer', 'check'];
$entryType = $this->faker->randomElement($entryTypes);
$amount = $this->faker->randomFloat(2, 100, 50000);
$balanceBefore = $this->faker->randomFloat(2, 0, 100000);
// Calculate balance after based on entry type
$balanceAfter = $entryType === 'receipt'
? $balanceBefore + $amount
: $balanceBefore - $amount;
return [
'entry_type' => $entryType,
'entry_date' => $this->faker->dateTimeBetween('-30 days', 'now'),
'amount' => $amount,
'payment_method' => $this->faker->randomElement($paymentMethods),
'bank_account' => $this->faker->company() . ' Bank - ' . $this->faker->numerify('##########'),
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'recorded_by_cashier_id' => User::factory(),
'recorded_at' => now(),
];
}
/**
* Indicate that the entry is a receipt (incoming money).
*/
public function receipt(): static
{
return $this->state(function (array $attributes) {
$balanceBefore = $attributes['balance_before'] ?? 0;
$amount = $attributes['amount'];
return [
'entry_type' => 'receipt',
'balance_after' => $balanceBefore + $amount,
];
});
}
/**
* Indicate that the entry is a payment (outgoing money).
*/
public function payment(): static
{
return $this->state(function (array $attributes) {
$balanceBefore = $attributes['balance_before'] ?? 0;
$amount = $attributes['amount'];
return [
'entry_type' => 'payment',
'balance_after' => $balanceBefore - $amount,
];
});
}
/**
* Indicate that the entry is linked to a finance document.
*/
public function withFinanceDocument(): static
{
return $this->state(fn (array $attributes) => [
'finance_document_id' => FinanceDocument::factory(),
]);
}
/**
* Indicate that the payment method is cash.
*/
public function cash(): static
{
return $this->state(fn (array $attributes) => [
'payment_method' => 'cash',
'bank_account' => null,
]);
}
/**
* Indicate that the payment method is bank transfer.
*/
public function bankTransfer(): static
{
return $this->state(fn (array $attributes) => [
'payment_method' => 'bank_transfer',
'bank_account' => $this->faker->company() . ' Bank - ' . $this->faker->numerify('##########'),
]);
}
/**
* Indicate that the payment method is check.
*/
public function check(): static
{
return $this->state(fn (array $attributes) => [
'payment_method' => 'check',
'transaction_reference' => 'CHK' . $this->faker->numerify('######'),
]);
}
/**
* Create a sequence of entries with running balance for a specific account.
*/
public function sequence(string $bankAccount, float $initialBalance = 0): static
{
static $currentBalance;
if ($currentBalance === null) {
$currentBalance = $initialBalance;
}
return $this->state(function (array $attributes) use ($bankAccount, &$currentBalance) {
$amount = $attributes['amount'];
$entryType = $attributes['entry_type'];
$balanceBefore = $currentBalance;
$balanceAfter = $entryType === 'receipt'
? $balanceBefore + $amount
: $balanceBefore - $amount;
$currentBalance = $balanceAfter;
return [
'bank_account' => $bankAccount,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
];
});
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Database\Factories;
use App\Models\FinanceDocument;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\FinanceDocument>
*/
class FinanceDocumentFactory extends Factory
{
protected $model = FinanceDocument::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$amount = $this->faker->randomFloat(2, 100, 100000);
$requestTypes = ['expense_reimbursement', 'advance_payment', 'purchase_request', 'petty_cash'];
$statuses = ['pending', 'approved_cashier', 'approved_accountant', 'approved_chair', 'rejected'];
return [
'title' => $this->faker->sentence(6),
'description' => $this->faker->paragraph(3),
'amount' => $amount,
'request_type' => $this->faker->randomElement($requestTypes),
'status' => $this->faker->randomElement($statuses),
'submitted_by_id' => User::factory(),
'submitted_at' => now(),
'amount_tier' => $this->determineAmountTier($amount),
'requires_board_meeting' => $amount > 50000,
];
}
/**
* Indicate that the document is pending approval.
*/
public function pending(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'pending',
]);
}
/**
* Indicate that the document is approved by cashier.
*/
public function approvedByCashier(): static
{
return $this->state(fn (array $attributes) => [
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
'cashier_approved_by_id' => User::factory(),
'cashier_approved_at' => now(),
]);
}
/**
* Indicate that the document is approved by accountant.
*/
public function approvedByAccountant(): static
{
return $this->state(fn (array $attributes) => [
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'cashier_approved_by_id' => User::factory(),
'cashier_approved_at' => now(),
'accountant_approved_by_id' => User::factory(),
'accountant_approved_at' => now(),
]);
}
/**
* Indicate that the document is approved by chair.
*/
public function approvedByChair(): static
{
return $this->state(fn (array $attributes) => [
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
'cashier_approved_by_id' => User::factory(),
'cashier_approved_at' => now(),
'accountant_approved_by_id' => User::factory(),
'accountant_approved_at' => now(),
'chair_approved_by_id' => User::factory(),
'chair_approved_at' => now(),
]);
}
/**
* Indicate that the document is rejected.
*/
public function rejected(): static
{
return $this->state(fn (array $attributes) => [
'status' => FinanceDocument::STATUS_REJECTED,
'rejection_reason' => $this->faker->sentence(10),
'rejected_at' => now(),
]);
}
/**
* Indicate that the document is a small amount (< 5000).
*/
public function smallAmount(): static
{
return $this->state(fn (array $attributes) => [
'amount' => $this->faker->randomFloat(2, 100, 4999),
'amount_tier' => 'small',
'requires_board_meeting' => false,
]);
}
/**
* Indicate that the document is a medium amount (5000-50000).
*/
public function mediumAmount(): static
{
return $this->state(fn (array $attributes) => [
'amount' => $this->faker->randomFloat(2, 5000, 50000),
'amount_tier' => 'medium',
'requires_board_meeting' => false,
]);
}
/**
* Indicate that the document is a large amount (> 50000).
*/
public function largeAmount(): static
{
return $this->state(fn (array $attributes) => [
'amount' => $this->faker->randomFloat(2, 50001, 200000),
'amount_tier' => 'large',
'requires_board_meeting' => true,
]);
}
/**
* Indicate that the document is an expense reimbursement.
*/
public function expenseReimbursement(): static
{
return $this->state(fn (array $attributes) => [
'request_type' => 'expense_reimbursement',
]);
}
/**
* Indicate that the document is an advance payment.
*/
public function advancePayment(): static
{
return $this->state(fn (array $attributes) => [
'request_type' => 'advance_payment',
]);
}
/**
* Indicate that the document is a purchase request.
*/
public function purchaseRequest(): static
{
return $this->state(fn (array $attributes) => [
'request_type' => 'purchase_request',
]);
}
/**
* Indicate that the document is petty cash.
*/
public function pettyCash(): static
{
return $this->state(fn (array $attributes) => [
'request_type' => 'petty_cash',
]);
}
/**
* Indicate that payment order has been created.
*/
public function withPaymentOrder(): static
{
return $this->state(fn (array $attributes) => [
'payment_order_created_at' => now(),
'payment_order_created_by_id' => User::factory(),
]);
}
/**
* Indicate that payment has been executed.
*/
public function paymentExecuted(): static
{
return $this->state(fn (array $attributes) => [
'payment_order_created_at' => now(),
'payment_verified_at' => now(),
'payment_executed_at' => now(),
]);
}
/**
* Determine amount tier based on amount.
*/
protected function determineAmountTier(float $amount): string
{
if ($amount < 5000) {
return 'small';
} elseif ($amount <= 50000) {
return 'medium';
} else {
return 'large';
}
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Database\Factories;
use App\Models\FinanceDocument;
use App\Models\PaymentOrder;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PaymentOrder>
*/
class PaymentOrderFactory extends Factory
{
protected $model = PaymentOrder::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$paymentMethods = ['cash', 'check', 'bank_transfer'];
$paymentMethod = $this->faker->randomElement($paymentMethods);
$attributes = [
'finance_document_id' => FinanceDocument::factory(),
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => $this->faker->name(),
'payment_amount' => $this->faker->randomFloat(2, 100, 50000),
'payment_method' => $paymentMethod,
'status' => 'pending_verification',
'verification_status' => 'pending',
'execution_status' => 'pending',
'created_by_accountant_id' => User::factory(),
];
// Add bank details for bank transfers
if ($paymentMethod === 'bank_transfer') {
$attributes['payee_bank_name'] = $this->faker->company() . ' Bank';
$attributes['payee_bank_code'] = $this->faker->numerify('###');
$attributes['payee_account_number'] = $this->faker->numerify('##########');
}
return $attributes;
}
/**
* Indicate that the payment order is pending verification.
*/
public function pendingVerification(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'pending_verification',
'verification_status' => 'pending',
]);
}
/**
* Indicate that the payment order is verified.
*/
public function verified(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'verified',
'verification_status' => 'approved',
'verified_by_cashier_id' => User::factory(),
'verified_at' => now(),
]);
}
/**
* Indicate that the payment order is rejected during verification.
*/
public function rejectedDuringVerification(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'pending_verification',
'verification_status' => 'rejected',
'verified_by_cashier_id' => User::factory(),
'verified_at' => now(),
'verification_notes' => $this->faker->sentence(10),
]);
}
/**
* Indicate that the payment order is executed.
*/
public function executed(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'executed',
'verification_status' => 'approved',
'execution_status' => 'completed',
'verified_by_cashier_id' => User::factory(),
'verified_at' => now()->subHours(2),
'executed_by_cashier_id' => User::factory(),
'executed_at' => now(),
'transaction_reference' => 'TXN' . $this->faker->numerify('##########'),
]);
}
/**
* Indicate that the payment order is cancelled.
*/
public function cancelled(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'cancelled',
]);
}
/**
* Indicate that the payment method is cash.
*/
public function cash(): static
{
return $this->state(fn (array $attributes) => [
'payment_method' => 'cash',
'payee_bank_name' => null,
'payee_bank_code' => null,
'payee_account_number' => null,
]);
}
/**
* Indicate that the payment method is check.
*/
public function check(): static
{
return $this->state(fn (array $attributes) => [
'payment_method' => 'check',
'payee_bank_name' => null,
'payee_bank_code' => null,
'payee_account_number' => null,
]);
}
/**
* Indicate that the payment method is bank transfer.
*/
public function bankTransfer(): static
{
return $this->state(fn (array $attributes) => [
'payment_method' => 'bank_transfer',
'payee_bank_name' => $this->faker->company() . ' Bank',
'payee_bank_code' => $this->faker->numerify('###'),
'payee_account_number' => $this->faker->numerify('##########'),
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
}
};

View File

@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_categories', function (Blueprint $table) {
$table->id();
$table->string('name'); // 協會辦法, 法規, 會議記錄, 表格
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('icon')->nullable(); // emoji or FontAwesome class
$table->integer('sort_order')->default(0);
// Default access level for documents in this category
$table->enum('default_access_level', ['public', 'members', 'admin', 'board'])->default('members');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_categories');
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->foreignId('document_category_id')->constrained()->onDelete('cascade');
// Document metadata
$table->string('title');
$table->string('document_number')->unique()->nullable(); // e.g., BYL-2024-001
$table->text('description')->nullable();
$table->uuid('public_uuid')->unique(); // For public sharing links
// Access control
$table->enum('access_level', ['public', 'members', 'admin', 'board'])->default('members');
// Current version pointer (set after first version is created)
$table->foreignId('current_version_id')->nullable()->constrained('document_versions')->onDelete('set null');
// Status
$table->enum('status', ['active', 'archived'])->default('active');
$table->timestamp('archived_at')->nullable();
// User tracking
$table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade');
$table->foreignId('last_updated_by_user_id')->nullable()->constrained('users')->onDelete('set null');
// Statistics
$table->integer('view_count')->default(0);
$table->integer('download_count')->default(0);
$table->integer('version_count')->default(0);
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('document_category_id');
$table->index('access_level');
$table->index('status');
$table->index('public_uuid');
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('documents');
}
};

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_versions', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->onDelete('cascade');
// Version information
$table->string('version_number'); // 1.0, 1.1, 2.0, etc.
$table->text('version_notes')->nullable(); // What changed in this version
$table->boolean('is_current')->default(false); // Is this the current published version
// File information
$table->string('file_path'); // storage/documents/...
$table->string('original_filename');
$table->string('mime_type');
$table->unsignedBigInteger('file_size'); // in bytes
$table->string('file_hash')->nullable(); // SHA-256 hash for integrity verification
// User tracking
$table->foreignId('uploaded_by_user_id')->constrained('users')->onDelete('cascade');
$table->timestamp('uploaded_at');
// Make version immutable after creation (no updated_at)
$table->timestamps();
// Indexes
$table->index('document_id');
$table->index('version_number');
$table->index('is_current');
$table->index('uploaded_at');
// Unique constraint: only one current version per document
$table->unique(['document_id', 'is_current'], 'unique_current_version');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_versions');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_access_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->onDelete('cascade');
$table->foreignId('document_version_id')->nullable()->constrained()->onDelete('set null');
// Access information
$table->enum('action', ['view', 'download']); // What action was performed
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); // null if anonymous/public access
$table->string('ip_address')->nullable();
$table->text('user_agent')->nullable();
// Timestamps
$table->timestamp('accessed_at');
$table->timestamps();
// Indexes
$table->index('document_id');
$table->index('user_id');
$table->index('action');
$table->index('accessed_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_access_logs');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('members', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('full_name');
$table->string('email')->index();
$table->string('phone')->nullable();
$table->string('national_id_encrypted')->nullable();
$table->string('national_id_hash')->nullable()->index();
$table->date('membership_started_at')->nullable();
$table->date('membership_expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('members');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('membership_payments', function (Blueprint $table) {
$table->id();
$table->foreignId('member_id')->constrained()->cascadeOnDelete();
$table->date('paid_at');
$table->decimal('amount', 10, 2);
$table->string('method')->nullable();
$table->string('reference')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('membership_payments');
}
};

View File

@@ -0,0 +1,22 @@
<?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('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // permission id
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::drop($tableNames['role_has_permissions']);
Schema::drop($tableNames['model_has_roles']);
Schema::drop($tableNames['model_has_permissions']);
Schema::drop($tableNames['roles']);
Schema::drop($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,27 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
if (! class_exists(\Spatie\Permission\Models\Role::class)) {
return;
}
$roleClass = \Spatie\Permission\Models\Role::class;
$adminRole = $roleClass::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
User::where('is_admin', true)->each(function (User $user) use ($adminRole) {
$user->assignRole($adminRole);
});
}
public function down(): void
{
// no-op
}
};

View File

@@ -0,0 +1,22 @@
<?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('members', function (Blueprint $table) {
$table->timestamp('last_expiry_reminder_sent_at')->nullable()->after('membership_expires_at');
});
}
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn('last_expiry_reminder_sent_at');
});
}
};

View File

@@ -0,0 +1,26 @@
<?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('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('action');
$table->string('auditable_type')->nullable();
$table->unsignedBigInteger('auditable_id')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -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('finance_documents', function (Blueprint $table) {
$table->id();
$table->foreignId('member_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('submitted_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('title');
$table->decimal('amount', 10, 2)->nullable();
$table->string('status')->default('pending');
$table->text('description')->nullable();
$table->timestamp('submitted_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('finance_documents');
}
};

View File

@@ -0,0 +1,25 @@
<?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('members', function (Blueprint $table) {
$table->string('address_line_1')->nullable()->after('phone');
$table->string('address_line_2')->nullable()->after('address_line_1');
$table->string('city')->nullable()->after('address_line_2');
$table->string('postal_code')->nullable()->after('city');
});
}
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn(['address_line_1', 'address_line_2', 'city', 'postal_code']);
});
}
};

View File

@@ -0,0 +1,22 @@
<?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('roles', function (Blueprint $table) {
$table->string('description')->nullable()->after('guard_name');
});
}
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('description');
});
}
};

View File

@@ -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('members', function (Blueprint $table) {
$table->string('emergency_contact_name')->nullable()->after('postal_code');
$table->string('emergency_contact_phone')->nullable()->after('emergency_contact_name');
});
}
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn(['emergency_contact_name', 'emergency_contact_phone']);
});
}
};

View File

@@ -0,0 +1,22 @@
<?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('users', function (Blueprint $table) {
$table->string('profile_photo_path')->nullable()->after('is_admin');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('profile_photo_path');
});
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
// File attachment
$table->string('attachment_path')->nullable()->after('description');
// Cashier approval
$table->foreignId('approved_by_cashier_id')->nullable()->constrained('users')->nullOnDelete()->after('attachment_path');
$table->timestamp('cashier_approved_at')->nullable()->after('approved_by_cashier_id');
// Accountant approval
$table->foreignId('approved_by_accountant_id')->nullable()->constrained('users')->nullOnDelete()->after('cashier_approved_at');
$table->timestamp('accountant_approved_at')->nullable()->after('approved_by_accountant_id');
// Chair approval
$table->foreignId('approved_by_chair_id')->nullable()->constrained('users')->nullOnDelete()->after('accountant_approved_at');
$table->timestamp('chair_approved_at')->nullable()->after('approved_by_chair_id');
// Rejection fields
$table->foreignId('rejected_by_user_id')->nullable()->constrained('users')->nullOnDelete()->after('chair_approved_at');
$table->timestamp('rejected_at')->nullable()->after('rejected_by_user_id');
$table->text('rejection_reason')->nullable()->after('rejected_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
$table->dropForeign(['approved_by_cashier_id']);
$table->dropForeign(['approved_by_accountant_id']);
$table->dropForeign(['approved_by_chair_id']);
$table->dropForeign(['rejected_by_user_id']);
$table->dropColumn([
'attachment_path',
'approved_by_cashier_id',
'cashier_approved_at',
'approved_by_accountant_id',
'accountant_approved_at',
'approved_by_chair_id',
'chair_approved_at',
'rejected_by_user_id',
'rejected_at',
'rejection_reason',
]);
});
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chart_of_accounts', function (Blueprint $table) {
$table->id();
$table->string('account_code', 10)->unique()->comment('Account code (e.g., 4101)');
$table->string('account_name_zh')->comment('Chinese account name');
$table->string('account_name_en')->nullable()->comment('English account name');
$table->enum('account_type', ['asset', 'liability', 'net_asset', 'income', 'expense'])->comment('Account type');
$table->string('category')->nullable()->comment('Detailed category');
$table->foreignId('parent_account_id')->nullable()->constrained('chart_of_accounts')->nullOnDelete()->comment('Parent account for hierarchical structure');
$table->boolean('is_active')->default(true)->comment('Active status');
$table->integer('display_order')->default(0)->comment('Display order');
$table->text('description')->nullable()->comment('Account description');
$table->timestamps();
$table->index('account_type');
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('chart_of_accounts');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('budget_items', function (Blueprint $table) {
$table->id();
$table->foreignId('budget_id')->constrained()->cascadeOnDelete()->comment('Budget reference');
$table->foreignId('chart_of_account_id')->constrained()->cascadeOnDelete()->comment('Chart of account reference');
$table->decimal('budgeted_amount', 15, 2)->default(0)->comment('Budgeted amount');
$table->decimal('actual_amount', 15, 2)->default(0)->comment('Actual amount (calculated)');
$table->text('notes')->nullable()->comment('Item notes');
$table->timestamps();
$table->index(['budget_id', 'chart_of_account_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('budget_items');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('budgets', function (Blueprint $table) {
$table->id();
$table->integer('fiscal_year')->comment('Fiscal year (e.g., 2025)');
$table->string('name')->comment('Budget name');
$table->enum('period_type', ['annual', 'quarterly', 'monthly'])->default('annual')->comment('Budget period type');
$table->date('period_start')->comment('Period start date');
$table->date('period_end')->comment('Period end date');
$table->enum('status', ['draft', 'submitted', 'approved', 'active', 'closed'])->default('draft')->comment('Budget status');
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete()->comment('Created by user');
$table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete()->comment('Approved by user');
$table->timestamp('approved_at')->nullable()->comment('Approval timestamp');
$table->text('notes')->nullable()->comment('Budget notes');
$table->timestamps();
$table->index('fiscal_year');
$table->index('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('budgets');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('budget_item_id')->nullable()->constrained()->nullOnDelete()->comment('Budget item reference');
$table->foreignId('chart_of_account_id')->constrained()->cascadeOnDelete()->comment('Chart of account reference');
$table->date('transaction_date')->comment('Transaction date');
$table->decimal('amount', 15, 2)->comment('Transaction amount');
$table->enum('transaction_type', ['income', 'expense'])->comment('Transaction type');
$table->string('description')->comment('Transaction description');
$table->string('reference_number')->nullable()->comment('Reference/receipt number');
$table->foreignId('finance_document_id')->nullable()->constrained()->nullOnDelete()->comment('Related finance document');
$table->foreignId('membership_payment_id')->nullable()->constrained()->nullOnDelete()->comment('Related membership payment');
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete()->comment('Created by user');
$table->text('notes')->nullable()->comment('Additional notes');
$table->timestamps();
$table->index('transaction_date');
$table->index('transaction_type');
$table->index(['budget_item_id', 'transaction_date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('transactions');
}
};

View File

@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('financial_reports', function (Blueprint $table) {
$table->id();
$table->enum('report_type', ['revenue_expenditure', 'balance_sheet', 'property_inventory', 'internal_management'])->comment('Report type');
$table->integer('fiscal_year')->comment('Fiscal year');
$table->date('period_start')->comment('Period start date');
$table->date('period_end')->comment('Period end date');
$table->enum('status', ['draft', 'finalized', 'approved', 'submitted'])->default('draft')->comment('Report status');
$table->foreignId('budget_id')->nullable()->constrained()->nullOnDelete()->comment('Related budget');
$table->foreignId('generated_by_user_id')->constrained('users')->cascadeOnDelete()->comment('Generated by user');
$table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete()->comment('Approved by user');
$table->timestamp('approved_at')->nullable()->comment('Approval timestamp');
$table->string('file_path')->nullable()->comment('PDF/Excel file path');
$table->text('notes')->nullable()->comment('Report notes');
$table->timestamps();
$table->index(['report_type', 'fiscal_year']);
$table->index('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('financial_reports');
}
};

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issues', function (Blueprint $table) {
$table->id();
$table->string('issue_number')->unique()->comment('Auto-generated issue number (e.g., ISS-2025-001)');
$table->string('title');
$table->text('description')->nullable();
// Issue categorization
$table->enum('issue_type', ['work_item', 'project_task', 'maintenance', 'member_request'])
->default('work_item')
->comment('Type of issue');
$table->enum('status', ['new', 'assigned', 'in_progress', 'review', 'closed'])
->default('new')
->comment('Current workflow status');
$table->enum('priority', ['low', 'medium', 'high', 'urgent'])
->default('medium')
->comment('Priority level');
// User relationships
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete()->comment('User who created the issue');
$table->foreignId('assigned_to_user_id')->nullable()->constrained('users')->nullOnDelete()->comment('User assigned to work on this');
$table->foreignId('reviewer_id')->nullable()->constrained('users')->nullOnDelete()->comment('User assigned to review');
// Related entities
$table->foreignId('member_id')->nullable()->constrained('members')->nullOnDelete()->comment('Related member (for member requests)');
$table->foreignId('parent_issue_id')->nullable()->constrained('issues')->nullOnDelete()->comment('Parent issue for sub-tasks');
// Dates and time tracking
$table->date('due_date')->nullable()->comment('Deadline for completion');
$table->timestamp('closed_at')->nullable()->comment('When issue was closed');
$table->decimal('estimated_hours', 8, 2)->nullable()->comment('Estimated time to complete');
$table->decimal('actual_hours', 8, 2)->default(0)->comment('Actual time spent (sum of time logs)');
$table->timestamps();
$table->softDeletes();
// Indexes for common queries
$table->index('issue_type');
$table->index('status');
$table->index('priority');
$table->index('assigned_to_user_id');
$table->index('created_by_user_id');
$table->index('due_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issues');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_comments', function (Blueprint $table) {
$table->id();
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->text('comment_text');
$table->boolean('is_internal')->default(false)->comment('Hide from members if true');
$table->timestamps();
// Indexes
$table->index('issue_id');
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_comments');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('User who uploaded');
$table->string('file_name');
$table->string('file_path');
$table->unsignedBigInteger('file_size')->comment('File size in bytes');
$table->string('mime_type');
$table->timestamps();
// Indexes
$table->index('issue_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_attachments');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('custom_field_values', function (Blueprint $table) {
$table->id();
$table->foreignId('custom_field_id')->constrained('custom_fields')->cascadeOnDelete();
$table->morphs('customizable'); // customizable_type and customizable_id (for issues)
$table->json('value')->comment('Stored value (JSON for flexibility)');
$table->timestamps();
// Indexes
$table->index('custom_field_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('custom_field_values');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('custom_fields', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->enum('field_type', ['text', 'number', 'date', 'select'])->comment('Data type');
$table->json('options')->nullable()->comment('Options for select type fields');
$table->json('applies_to_issue_types')->comment('Which issue types can use this field');
$table->boolean('is_required')->default(false);
$table->integer('display_order')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('custom_fields');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_label_pivot', function (Blueprint $table) {
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('issue_label_id')->constrained('issue_labels')->cascadeOnDelete();
$table->timestamps();
// Composite primary key
$table->primary(['issue_id', 'issue_label_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_label_pivot');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_labels', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('color', 7)->default('#6B7280')->comment('Hex color code');
$table->text('description')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_labels');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_relationships', function (Blueprint $table) {
$table->id();
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('related_issue_id')->constrained('issues')->cascadeOnDelete();
$table->enum('relationship_type', ['blocks', 'blocked_by', 'related_to', 'duplicate_of'])
->comment('Type of relationship');
$table->timestamps();
// Indexes
$table->index('issue_id');
$table->index('related_issue_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_relationships');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_time_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->decimal('hours', 8, 2)->comment('Hours worked');
$table->text('description')->nullable()->comment('What was done');
$table->timestamp('logged_at')->comment('When the work was performed');
$table->timestamps();
// Indexes
$table->index('issue_id');
$table->index('user_id');
$table->index('logged_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_time_logs');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('issue_watchers', function (Blueprint $table) {
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
// Composite primary key to prevent duplicate watchers
$table->primary(['issue_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('issue_watchers');
}
};

View File

@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('membership_payments', function (Blueprint $table) {
// Payment verification workflow status
$table->enum('status', ['pending', 'approved_cashier', 'approved_accountant', 'approved_chair', 'rejected'])
->default('pending')
->after('reference');
// Payment method
$table->enum('payment_method', ['bank_transfer', 'convenience_store', 'cash', 'credit_card'])
->nullable()
->after('status');
// Receipt file upload
$table->string('receipt_path')->nullable()->after('payment_method');
// Submitted by (member self-submission)
$table->foreignId('submitted_by_user_id')->nullable()->after('receipt_path')
->constrained('users')->nullOnDelete();
// Cashier verification (Tier 1)
$table->foreignId('verified_by_cashier_id')->nullable()->after('submitted_by_user_id')
->constrained('users')->nullOnDelete();
$table->timestamp('cashier_verified_at')->nullable()->after('verified_by_cashier_id');
// Accountant verification (Tier 2)
$table->foreignId('verified_by_accountant_id')->nullable()->after('cashier_verified_at')
->constrained('users')->nullOnDelete();
$table->timestamp('accountant_verified_at')->nullable()->after('verified_by_accountant_id');
// Chair verification (Tier 3)
$table->foreignId('verified_by_chair_id')->nullable()->after('accountant_verified_at')
->constrained('users')->nullOnDelete();
$table->timestamp('chair_verified_at')->nullable()->after('verified_by_chair_id');
// Rejection tracking
$table->foreignId('rejected_by_user_id')->nullable()->after('chair_verified_at')
->constrained('users')->nullOnDelete();
$table->timestamp('rejected_at')->nullable()->after('rejected_by_user_id');
$table->text('rejection_reason')->nullable()->after('rejected_at');
// Admin notes
$table->text('notes')->nullable()->after('rejection_reason');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('membership_payments', function (Blueprint $table) {
$table->dropColumn([
'status',
'payment_method',
'receipt_path',
'submitted_by_user_id',
'verified_by_cashier_id',
'cashier_verified_at',
'verified_by_accountant_id',
'accountant_verified_at',
'verified_by_chair_id',
'chair_verified_at',
'rejected_by_user_id',
'rejected_at',
'rejection_reason',
'notes',
]);
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('members', function (Blueprint $table) {
// Membership status - distinguishes paid vs unpaid members
$table->enum('membership_status', ['pending', 'active', 'expired', 'suspended'])
->default('pending')
->after('membership_expires_at')
->comment('Payment verification status: pending (not paid), active (paid & activated), expired, suspended');
// Membership type - for different membership tiers
$table->enum('membership_type', ['regular', 'honorary', 'lifetime', 'student'])
->default('regular')
->after('membership_status')
->comment('Type of membership: regular (annual fee), honorary (no fee), lifetime (one-time), student (discounted)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn(['membership_status', 'membership_type']);
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* The unique constraint on (document_id, is_current) was too restrictive.
* It prevented having multiple versions with is_current = false.
* We only need to ensure ONE version has is_current = true, which is
* enforced in the application logic.
*/
public function up(): void
{
Schema::table('document_versions', function (Blueprint $table) {
$table->dropUnique('unique_current_version');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('document_versions', function (Blueprint $table) {
$table->unique(['document_id', 'is_current'], 'unique_current_version');
});
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create tags table
Schema::create('document_tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('color')->default('#6366f1'); // Indigo color
$table->text('description')->nullable();
$table->timestamps();
});
// Create pivot table for document-tag relationship
Schema::create('document_document_tag', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->onDelete('cascade');
$table->foreignId('document_tag_id')->constrained()->onDelete('cascade');
$table->timestamps();
$table->unique(['document_id', 'document_tag_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_document_tag');
Schema::dropIfExists('document_tags');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('documents', function (Blueprint $table) {
$table->date('expires_at')->nullable()->after('status');
$table->boolean('auto_archive_on_expiry')->default(false)->after('expires_at');
$table->text('expiry_notice')->nullable()->after('auto_archive_on_expiry');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('documents', function (Blueprint $table) {
$table->dropColumn(['expires_at', 'auto_archive_on_expiry', 'expiry_notice']);
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('system_settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique()->comment('Setting key (e.g., documents.qr_enabled)');
$table->text('value')->nullable()->comment('Setting value (JSON for complex values)');
$table->enum('type', ['string', 'integer', 'boolean', 'json', 'array'])->default('string')->comment('Value type for casting');
$table->string('group')->nullable()->index()->comment('Settings group (e.g., documents, security, notifications)');
$table->text('description')->nullable()->comment('Human-readable description of this setting');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('system_settings');
}
};

View File

@@ -0,0 +1,124 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
// 申請類型和金額分級
$table->enum('request_type', ['expense_reimbursement', 'advance_payment', 'purchase_request', 'petty_cash'])
->default('expense_reimbursement')
->after('status')
->comment('申請類型:費用報銷/預支借款/採購申請/零用金');
$table->enum('amount_tier', ['small', 'medium', 'large'])
->nullable()
->after('request_type')
->comment('金額層級:小額(<5000)/中額(5000-50000)/大額(>50000)');
// 會計科目分配(會計審核時填寫)
$table->foreignId('chart_of_account_id')->nullable()->after('amount_tier')->constrained('chart_of_accounts')->nullOnDelete();
$table->foreignId('budget_item_id')->nullable()->after('chart_of_account_id')->constrained('budget_items')->nullOnDelete();
// 理監事會議核准(大額)
$table->boolean('requires_board_meeting')->default(false)->after('chair_approved_at');
$table->date('board_meeting_date')->nullable()->after('requires_board_meeting');
$table->text('board_meeting_decision')->nullable()->after('board_meeting_date');
$table->foreignId('approved_by_board_meeting_id')->nullable()->after('board_meeting_decision')->constrained('users')->nullOnDelete();
$table->timestamp('board_meeting_approved_at')->nullable()->after('approved_by_board_meeting_id');
// 付款單製作(會計)
$table->foreignId('payment_order_created_by_accountant_id')->nullable()->after('board_meeting_approved_at')->constrained('users')->nullOnDelete();
$table->timestamp('payment_order_created_at')->nullable()->after('payment_order_created_by_accountant_id');
$table->enum('payment_method', ['bank_transfer', 'check', 'cash'])->nullable()->after('payment_order_created_at');
$table->string('payee_name', 100)->nullable()->after('payment_method');
$table->string('payee_bank_code', 10)->nullable()->after('payee_name');
$table->string('payee_account_number', 30)->nullable()->after('payee_bank_code');
$table->text('payment_notes')->nullable()->after('payee_account_number');
// 出納覆核付款單
$table->foreignId('payment_verified_by_cashier_id')->nullable()->after('payment_notes')->constrained('users')->nullOnDelete();
$table->timestamp('payment_verified_at')->nullable()->after('payment_verified_by_cashier_id');
$table->text('payment_verification_notes')->nullable()->after('payment_verified_at');
// 實際付款執行
$table->foreignId('payment_executed_by_cashier_id')->nullable()->after('payment_verification_notes')->constrained('users')->nullOnDelete();
$table->timestamp('payment_executed_at')->nullable()->after('payment_executed_by_cashier_id');
$table->string('payment_transaction_id', 50)->nullable()->after('payment_executed_at')->comment('銀行交易編號');
$table->string('payment_receipt_path')->nullable()->after('payment_transaction_id')->comment('付款憑證路徑');
$table->decimal('actual_payment_amount', 10, 2)->nullable()->after('payment_receipt_path')->comment('實付金額');
// 記帳階段 (外鍵稍後加上,因為相關表還不存在)
$table->unsignedBigInteger('cashier_ledger_entry_id')->nullable()->after('actual_payment_amount');
$table->timestamp('cashier_recorded_at')->nullable()->after('cashier_ledger_entry_id');
$table->unsignedBigInteger('accounting_transaction_id')->nullable()->after('cashier_recorded_at');
$table->timestamp('accountant_recorded_at')->nullable()->after('accounting_transaction_id');
// 月底核對
$table->enum('reconciliation_status', ['pending', 'matched', 'discrepancy', 'resolved'])->default('pending')->after('accountant_recorded_at');
$table->text('reconciliation_notes')->nullable()->after('reconciliation_status');
$table->timestamp('reconciled_at')->nullable()->after('reconciliation_notes');
$table->foreignId('reconciled_by_user_id')->nullable()->after('reconciled_at')->constrained('users')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('finance_documents', function (Blueprint $table) {
// 移除外鍵約束
$table->dropForeign(['chart_of_account_id']);
$table->dropForeign(['budget_item_id']);
$table->dropForeign(['approved_by_board_meeting_id']);
$table->dropForeign(['payment_order_created_by_accountant_id']);
$table->dropForeign(['payment_verified_by_cashier_id']);
$table->dropForeign(['payment_executed_by_cashier_id']);
$table->dropForeign(['reconciled_by_user_id']);
// 移除欄位
$table->dropColumn([
'request_type',
'amount_tier',
'chart_of_account_id',
'budget_item_id',
'requires_board_meeting',
'board_meeting_date',
'board_meeting_decision',
'approved_by_board_meeting_id',
'board_meeting_approved_at',
'payment_order_created_by_accountant_id',
'payment_order_created_at',
'payment_method',
'payee_name',
'payee_bank_code',
'payee_account_number',
'payment_notes',
'payment_verified_by_cashier_id',
'payment_verified_at',
'payment_verification_notes',
'payment_executed_by_cashier_id',
'payment_executed_at',
'payment_transaction_id',
'payment_receipt_path',
'actual_payment_amount',
'cashier_ledger_entry_id',
'cashier_recorded_at',
'accounting_transaction_id',
'accountant_recorded_at',
'reconciliation_status',
'reconciliation_notes',
'reconciled_at',
'reconciled_by_user_id',
]);
});
}
};

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payment_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('finance_document_id')->constrained('finance_documents')->cascadeOnDelete();
// 付款資訊
$table->string('payee_name', 100)->comment('收款人姓名');
$table->string('payee_bank_code', 10)->nullable()->comment('銀行代碼');
$table->string('payee_account_number', 30)->nullable()->comment('銀行帳號');
$table->string('payee_bank_name', 100)->nullable()->comment('銀行名稱');
$table->decimal('payment_amount', 10, 2)->comment('付款金額');
$table->enum('payment_method', ['bank_transfer', 'check', 'cash'])->comment('付款方式');
// 會計製單
$table->foreignId('created_by_accountant_id')->constrained('users')->cascadeOnDelete();
$table->string('payment_order_number', 50)->unique()->comment('付款單號');
$table->text('notes')->nullable();
// 出納覆核
$table->foreignId('verified_by_cashier_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('verified_at')->nullable();
$table->enum('verification_status', ['pending', 'approved', 'rejected'])->default('pending');
$table->text('verification_notes')->nullable();
// 執行付款
$table->foreignId('executed_by_cashier_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('executed_at')->nullable();
$table->enum('execution_status', ['pending', 'completed', 'failed'])->default('pending');
$table->string('transaction_reference', 100)->nullable()->comment('交易參考號');
// 憑證
$table->string('payment_receipt_path')->nullable()->comment('付款憑證路徑');
$table->enum('status', ['draft', 'pending_verification', 'verified', 'executed', 'cancelled'])->default('draft');
$table->timestamps();
$table->index('finance_document_id');
$table->index('status');
$table->index('verification_status');
$table->index('execution_status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('payment_orders');
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cashier_ledger_entries', function (Blueprint $table) {
$table->id();
$table->foreignId('finance_document_id')->nullable()->constrained('finance_documents')->cascadeOnDelete();
$table->date('entry_date')->comment('記帳日期');
$table->enum('entry_type', ['receipt', 'payment'])->comment('類型:收入/支出');
// 付款資訊
$table->enum('payment_method', ['bank_transfer', 'check', 'cash'])->comment('付款方式');
$table->string('bank_account', 100)->nullable()->comment('使用的銀行帳戶');
$table->decimal('amount', 10, 2)->comment('金額');
// 餘額追蹤
$table->decimal('balance_before', 10, 2)->comment('交易前餘額');
$table->decimal('balance_after', 10, 2)->comment('交易後餘額');
// 憑證資訊
$table->string('receipt_number', 50)->nullable()->comment('收據/憑證編號');
$table->string('transaction_reference', 100)->nullable()->comment('交易參考號');
// 記錄人員
$table->foreignId('recorded_by_cashier_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('recorded_at')->useCurrent();
$table->text('notes')->nullable();
$table->timestamps();
$table->index('finance_document_id');
$table->index('entry_date');
$table->index('entry_type');
$table->index('recorded_by_cashier_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cashier_ledger_entries');
}
};

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bank_reconciliations', function (Blueprint $table) {
$table->id();
$table->date('reconciliation_month')->comment('調節月份');
// 銀行對帳單
$table->decimal('bank_statement_balance', 10, 2)->comment('銀行對帳單餘額');
$table->date('bank_statement_date')->comment('對帳單日期');
$table->string('bank_statement_file_path')->nullable()->comment('對帳單檔案');
// 系統帳面
$table->decimal('system_book_balance', 10, 2)->comment('系統帳面餘額');
// 未達帳項JSON 格式)
$table->json('outstanding_checks')->nullable()->comment('未兌現支票');
$table->json('deposits_in_transit')->nullable()->comment('在途存款');
$table->json('bank_charges')->nullable()->comment('銀行手續費');
// 調節結果
$table->decimal('adjusted_balance', 10, 2)->comment('調整後餘額');
$table->decimal('discrepancy_amount', 10, 2)->default(0)->comment('差異金額');
$table->enum('reconciliation_status', ['pending', 'completed', 'discrepancy'])->default('pending');
// 執行人員
$table->foreignId('prepared_by_cashier_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('reviewed_by_accountant_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('approved_by_manager_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('prepared_at')->useCurrent();
$table->timestamp('reviewed_at')->nullable();
$table->timestamp('approved_at')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->index('reconciliation_month');
$table->index('reconciliation_status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bank_reconciliations');
}
};

View File

@@ -0,0 +1,92 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class AdvancedPermissionsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Create advanced document feature permissions
$permissions = [
[
'name' => 'manage_system_settings',
'description' => 'Access and modify system settings pages'
],
[
'name' => 'use_bulk_import',
'description' => 'Use document bulk import feature'
],
[
'name' => 'use_qr_codes',
'description' => 'Generate QR codes for documents'
],
[
'name' => 'view_document_statistics',
'description' => 'Access document statistics dashboard'
],
[
'name' => 'manage_document_tags',
'description' => 'Create, edit, and delete document tags'
],
[
'name' => 'manage_document_expiration',
'description' => 'Set expiration dates and configure auto-archive rules'
],
[
'name' => 'export_documents',
'description' => 'Export document lists and reports'
],
];
foreach ($permissions as $permissionData) {
Permission::firstOrCreate(
['name' => $permissionData['name']],
['guard_name' => 'web']
);
}
// Assign all advanced permissions to 'admin' role
$adminRole = Role::where('name', 'admin')->first();
if ($adminRole) {
foreach ($permissions as $permissionData) {
$permission = Permission::where('name', $permissionData['name'])->first();
if ($permission && !$adminRole->hasPermissionTo($permission)) {
$adminRole->givePermissionTo($permission);
}
}
$this->command->info('Advanced permissions assigned to admin role');
}
// Optionally assign some permissions to 'staff' role
$staffRole = Role::where('name', 'staff')->first();
if ($staffRole) {
$staffPermissions = [
'use_qr_codes',
'view_document_statistics',
'export_documents',
];
foreach ($staffPermissions as $permissionName) {
$permission = Permission::where('name', $permissionName)->first();
if ($permission && !$staffRole->hasPermissionTo($permission)) {
$staffRole->givePermissionTo($permission);
}
}
$this->command->info('Selected permissions assigned to staff role');
}
$this->command->info('Advanced permissions seeded successfully');
}
}

View File

@@ -0,0 +1,490 @@
<?php
namespace Database\Seeders;
use App\Models\ChartOfAccount;
use Illuminate\Database\Seeder;
class ChartOfAccountSeeder extends Seeder
{
/**
* Run the database seeds.
*
* Taiwan nonprofit standard chart of accounts (會計科目表)
*/
public function run(): void
{
$accounts = [
// Assets (資產) - 1xxx
[
'account_code' => '1101',
'account_name_zh' => '現金',
'account_name_en' => 'Cash',
'account_type' => 'asset',
'category' => '流動資產',
'display_order' => 10,
],
[
'account_code' => '1102',
'account_name_zh' => '零用金',
'account_name_en' => 'Petty Cash',
'account_type' => 'asset',
'category' => '流動資產',
'display_order' => 20,
],
[
'account_code' => '1201',
'account_name_zh' => '銀行存款',
'account_name_en' => 'Bank Deposits',
'account_type' => 'asset',
'category' => '流動資產',
'display_order' => 30,
],
[
'account_code' => '1301',
'account_name_zh' => '應收帳款',
'account_name_en' => 'Accounts Receivable',
'account_type' => 'asset',
'category' => '流動資產',
'display_order' => 40,
],
[
'account_code' => '1302',
'account_name_zh' => '其他應收款',
'account_name_en' => 'Other Receivables',
'account_type' => 'asset',
'category' => '流動資產',
'display_order' => 50,
],
[
'account_code' => '1401',
'account_name_zh' => '土地',
'account_name_en' => 'Land',
'account_type' => 'asset',
'category' => '固定資產',
'display_order' => 60,
],
[
'account_code' => '1402',
'account_name_zh' => '房屋及建築',
'account_name_en' => 'Buildings',
'account_type' => 'asset',
'category' => '固定資產',
'display_order' => 70,
],
[
'account_code' => '1403',
'account_name_zh' => '機器設備',
'account_name_en' => 'Machinery & Equipment',
'account_type' => 'asset',
'category' => '固定資產',
'display_order' => 80,
],
[
'account_code' => '1404',
'account_name_zh' => '辦公設備',
'account_name_en' => 'Office Equipment',
'account_type' => 'asset',
'category' => '固定資產',
'display_order' => 90,
],
[
'account_code' => '1405',
'account_name_zh' => '電腦設備',
'account_name_en' => 'Computer Equipment',
'account_type' => 'asset',
'category' => '固定資產',
'display_order' => 100,
],
[
'account_code' => '1501',
'account_name_zh' => '存出保證金',
'account_name_en' => 'Guarantee Deposits Paid',
'account_type' => 'asset',
'category' => '其他資產',
'display_order' => 110,
],
// Liabilities (負債) - 2xxx
[
'account_code' => '2101',
'account_name_zh' => '應付帳款',
'account_name_en' => 'Accounts Payable',
'account_type' => 'liability',
'category' => '流動負債',
'display_order' => 200,
],
[
'account_code' => '2102',
'account_name_zh' => '應付薪資',
'account_name_en' => 'Salaries Payable',
'account_type' => 'liability',
'category' => '流動負債',
'display_order' => 210,
],
[
'account_code' => '2103',
'account_name_zh' => '應付費用',
'account_name_en' => 'Accrued Expenses',
'account_type' => 'liability',
'category' => '流動負債',
'display_order' => 220,
],
[
'account_code' => '2104',
'account_name_zh' => '代收款',
'account_name_en' => 'Collections for Others',
'account_type' => 'liability',
'category' => '流動負債',
'display_order' => 230,
],
[
'account_code' => '2201',
'account_name_zh' => '長期借款',
'account_name_en' => 'Long-term Loans',
'account_type' => 'liability',
'category' => '長期負債',
'display_order' => 240,
],
// Net Assets/Fund Balance (淨資產/基金) - 3xxx
[
'account_code' => '3101',
'account_name_zh' => '累積餘絀',
'account_name_en' => 'Accumulated Surplus/Deficit',
'account_type' => 'net_asset',
'category' => '淨資產',
'display_order' => 300,
],
[
'account_code' => '3102',
'account_name_zh' => '本期餘絀',
'account_name_en' => 'Current Period Surplus/Deficit',
'account_type' => 'net_asset',
'category' => '淨資產',
'display_order' => 310,
],
[
'account_code' => '3201',
'account_name_zh' => '基金',
'account_name_en' => 'Fund Balance',
'account_type' => 'net_asset',
'category' => '基金',
'display_order' => 320,
],
// Income (收入) - 4xxx
[
'account_code' => '4101',
'account_name_zh' => '會費收入',
'account_name_en' => 'Membership Dues',
'account_type' => 'income',
'category' => '會費收入',
'display_order' => 400,
'description' => '會員繳交之常年會費',
],
[
'account_code' => '4102',
'account_name_zh' => '入會費收入',
'account_name_en' => 'Entrance Fees',
'account_type' => 'income',
'category' => '會費收入',
'display_order' => 410,
'description' => '新會員入會費',
],
[
'account_code' => '4201',
'account_name_zh' => '捐贈收入',
'account_name_en' => 'Donation Income',
'account_type' => 'income',
'category' => '捐贈收入',
'display_order' => 420,
'description' => '個人或團體捐贈',
],
[
'account_code' => '4202',
'account_name_zh' => '企業捐贈收入',
'account_name_en' => 'Corporate Donations',
'account_type' => 'income',
'category' => '捐贈收入',
'display_order' => 430,
'description' => '企業捐贈',
],
[
'account_code' => '4301',
'account_name_zh' => '政府補助收入',
'account_name_en' => 'Government Grants',
'account_type' => 'income',
'category' => '補助收入',
'display_order' => 440,
'description' => '政府機關補助款',
],
[
'account_code' => '4302',
'account_name_zh' => '計畫補助收入',
'account_name_en' => 'Project Grants',
'account_type' => 'income',
'category' => '補助收入',
'display_order' => 450,
'description' => '專案計畫補助',
],
[
'account_code' => '4401',
'account_name_zh' => '利息收入',
'account_name_en' => 'Interest Income',
'account_type' => 'income',
'category' => '其他收入',
'display_order' => 460,
],
[
'account_code' => '4402',
'account_name_zh' => '活動報名費收入',
'account_name_en' => 'Activity Registration Fees',
'account_type' => 'income',
'category' => '其他收入',
'display_order' => 470,
'description' => '各項活動報名費',
],
[
'account_code' => '4901',
'account_name_zh' => '雜項收入',
'account_name_en' => 'Miscellaneous Income',
'account_type' => 'income',
'category' => '其他收入',
'display_order' => 480,
],
// Expenses (支出) - 5xxx
// Personnel Expenses (人事費)
[
'account_code' => '5101',
'account_name_zh' => '薪資支出',
'account_name_en' => 'Salaries & Wages',
'account_type' => 'expense',
'category' => '人事費',
'display_order' => 500,
],
[
'account_code' => '5102',
'account_name_zh' => '勞健保費',
'account_name_en' => 'Labor & Health Insurance',
'account_type' => 'expense',
'category' => '人事費',
'display_order' => 510,
],
[
'account_code' => '5103',
'account_name_zh' => '退休金提撥',
'account_name_en' => 'Pension Contributions',
'account_type' => 'expense',
'category' => '人事費',
'display_order' => 520,
],
[
'account_code' => '5104',
'account_name_zh' => '加班費',
'account_name_en' => 'Overtime Pay',
'account_type' => 'expense',
'category' => '人事費',
'display_order' => 530,
],
[
'account_code' => '5105',
'account_name_zh' => '員工福利',
'account_name_en' => 'Employee Benefits',
'account_type' => 'expense',
'category' => '人事費',
'display_order' => 540,
],
// Operating Expenses (業務費)
[
'account_code' => '5201',
'account_name_zh' => '租金支出',
'account_name_en' => 'Rent Expense',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 550,
],
[
'account_code' => '5202',
'account_name_zh' => '水電費',
'account_name_en' => 'Utilities',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 560,
],
[
'account_code' => '5203',
'account_name_zh' => '郵電費',
'account_name_en' => 'Postage & Telecommunications',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 570,
],
[
'account_code' => '5204',
'account_name_zh' => '文具用品',
'account_name_en' => 'Office Supplies',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 580,
],
[
'account_code' => '5205',
'account_name_zh' => '印刷費',
'account_name_en' => 'Printing Expenses',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 590,
],
[
'account_code' => '5206',
'account_name_zh' => '旅運費',
'account_name_en' => 'Travel & Transportation',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 600,
],
[
'account_code' => '5207',
'account_name_zh' => '保險費',
'account_name_en' => 'Insurance Premiums',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 610,
],
[
'account_code' => '5208',
'account_name_zh' => '修繕費',
'account_name_en' => 'Repairs & Maintenance',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 620,
],
[
'account_code' => '5209',
'account_name_zh' => '會議費',
'account_name_en' => 'Meeting Expenses',
'account_type' => 'expense',
'category' => '業務費',
'display_order' => 630,
],
// Program/Activity Expenses (活動費)
[
'account_code' => '5301',
'account_name_zh' => '活動場地費',
'account_name_en' => 'Activity Venue Rental',
'account_type' => 'expense',
'category' => '活動費',
'display_order' => 640,
],
[
'account_code' => '5302',
'account_name_zh' => '活動講師費',
'account_name_en' => 'Speaker/Instructor Fees',
'account_type' => 'expense',
'category' => '活動費',
'display_order' => 650,
],
[
'account_code' => '5303',
'account_name_zh' => '活動餐費',
'account_name_en' => 'Activity Catering',
'account_type' => 'expense',
'category' => '活動費',
'display_order' => 660,
],
[
'account_code' => '5304',
'account_name_zh' => '活動材料費',
'account_name_en' => 'Activity Materials',
'account_type' => 'expense',
'category' => '活動費',
'display_order' => 670,
],
[
'account_code' => '5305',
'account_name_zh' => '活動宣傳費',
'account_name_en' => 'Activity Promotion',
'account_type' => 'expense',
'category' => '活動費',
'display_order' => 680,
],
// Administrative Expenses (行政管理費)
[
'account_code' => '5401',
'account_name_zh' => '稅捐',
'account_name_en' => 'Taxes',
'account_type' => 'expense',
'category' => '行政管理費',
'display_order' => 690,
],
[
'account_code' => '5402',
'account_name_zh' => '規費',
'account_name_en' => 'Administrative Fees',
'account_type' => 'expense',
'category' => '行政管理費',
'display_order' => 700,
],
[
'account_code' => '5403',
'account_name_zh' => '銀行手續費',
'account_name_en' => 'Bank Service Charges',
'account_type' => 'expense',
'category' => '行政管理費',
'display_order' => 710,
],
[
'account_code' => '5404',
'account_name_zh' => '電腦網路費',
'account_name_en' => 'IT & Network Expenses',
'account_type' => 'expense',
'category' => '行政管理費',
'display_order' => 720,
],
[
'account_code' => '5405',
'account_name_zh' => '專業服務費',
'account_name_en' => 'Professional Services',
'account_type' => 'expense',
'category' => '行政管理費',
'display_order' => 730,
'description' => '會計師、律師等專業服務費',
],
[
'account_code' => '5406',
'account_name_zh' => '折舊費用',
'account_name_en' => 'Depreciation',
'account_type' => 'expense',
'category' => '行政管理費',
'display_order' => 740,
],
// Other Expenses (其他支出)
[
'account_code' => '5901',
'account_name_zh' => '雜項支出',
'account_name_en' => 'Miscellaneous Expenses',
'account_type' => 'expense',
'category' => '其他支出',
'display_order' => 750,
],
[
'account_code' => '5902',
'account_name_zh' => '呆帳損失',
'account_name_en' => 'Bad Debt Expense',
'account_type' => 'expense',
'category' => '其他支出',
'display_order' => 760,
],
];
foreach ($accounts as $account) {
ChartOfAccount::create($account);
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// \App\Models\User::factory(10)->create();
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Database\Seeders;
use App\Models\DocumentCategory;
use Illuminate\Database\Seeder;
class DocumentCategorySeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$categories = [
[
'name' => '協會辦法',
'slug' => 'bylaws',
'description' => '協會章程、組織辦法等基本規範文件',
'icon' => '📜',
'sort_order' => 1,
'default_access_level' => 'public',
],
[
'name' => '法規與規範',
'slug' => 'regulations',
'description' => '內部規章、管理辦法、作業規範',
'icon' => '📋',
'sort_order' => 2,
'default_access_level' => 'members',
],
[
'name' => '會議記錄',
'slug' => 'meeting-minutes',
'description' => '理事會、會員大會、各委員會會議記錄',
'icon' => '📝',
'sort_order' => 3,
'default_access_level' => 'members',
],
[
'name' => '表格與申請書',
'slug' => 'forms',
'description' => '各類申請表格、會員服務表單',
'icon' => '📄',
'sort_order' => 4,
'default_access_level' => 'public',
],
[
'name' => '年度報告',
'slug' => 'annual-reports',
'description' => '年度工作報告、財務報告',
'icon' => '📊',
'sort_order' => 5,
'default_access_level' => 'members',
],
[
'name' => '活動文件',
'slug' => 'events',
'description' => '活動企劃、執行報告、相關文件',
'icon' => '🎯',
'sort_order' => 6,
'default_access_level' => 'members',
],
];
foreach ($categories as $category) {
DocumentCategory::updateOrCreate(
['slug' => $category['slug']],
$category
);
}
$this->command->info('Document categories seeded successfully!');
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class FinancialWorkflowPermissionsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Create permissions for financial workflow
$permissions = [
// Approval Stage Permissions
'approve_finance_cashier' => '出納審核財務申請單(第一階段)',
'approve_finance_accountant' => '會計審核財務申請單(第二階段)',
'approve_finance_chair' => '理事長審核財務申請單(第三階段)',
'approve_finance_board' => '理事會審核大額財務申請大於50,000',
// Payment Stage Permissions
'create_payment_order' => '會計製作付款單',
'verify_payment_order' => '出納覆核付款單',
'execute_payment' => '出納執行付款',
'upload_payment_receipt' => '上傳付款憑證',
// Recording Stage Permissions
'record_cashier_ledger' => '出納記錄現金簿',
'record_accounting_transaction' => '會計記錄會計分錄',
'view_cashier_ledger' => '查看出納現金簿',
'view_accounting_transactions' => '查看會計分錄',
// Reconciliation Permissions
'prepare_bank_reconciliation' => '出納製作銀行調節表',
'review_bank_reconciliation' => '會計覆核銀行調節表',
'approve_bank_reconciliation' => '主管核准銀行調節表',
// General Finance Document Permissions
'view_finance_documents' => '查看財務申請單',
'create_finance_documents' => '建立財務申請單',
'edit_finance_documents' => '編輯財務申請單',
'delete_finance_documents' => '刪除財務申請單',
// Chart of Accounts & Budget Permissions
'assign_chart_of_account' => '指定會計科目',
'assign_budget_item' => '指定預算項目',
// Dashboard & Reports Permissions
'view_finance_dashboard' => '查看財務儀表板',
'view_finance_reports' => '查看財務報表',
'export_finance_reports' => '匯出財務報表',
];
foreach ($permissions as $name => $description) {
Permission::firstOrCreate(
['name' => $name],
['guard_name' => 'web']
);
$this->command->info("Permission created: {$name}");
}
// Create roles for financial workflow
$roles = [
'finance_cashier' => [
'permissions' => [
// Approval stage
'approve_finance_cashier',
// Payment stage
'verify_payment_order',
'execute_payment',
'upload_payment_receipt',
// Recording stage
'record_cashier_ledger',
'view_cashier_ledger',
// Reconciliation
'prepare_bank_reconciliation',
// General
'view_finance_documents',
'view_finance_dashboard',
],
'description' => '出納 - 管錢(覆核付款單、執行付款、記錄現金簿、製作銀行調節表)',
],
'finance_accountant' => [
'permissions' => [
// Approval stage
'approve_finance_accountant',
// Payment stage
'create_payment_order',
// Recording stage
'record_accounting_transaction',
'view_accounting_transactions',
// Reconciliation
'review_bank_reconciliation',
// Chart of accounts & budget
'assign_chart_of_account',
'assign_budget_item',
// General
'view_finance_documents',
'view_finance_dashboard',
'view_finance_reports',
'export_finance_reports',
],
'description' => '會計 - 管帳(製作付款單、記錄會計分錄、覆核銀行調節表、指定會計科目)',
],
'finance_chair' => [
'permissions' => [
// Approval stage
'approve_finance_chair',
// Reconciliation
'approve_bank_reconciliation',
// General
'view_finance_documents',
'view_finance_dashboard',
'view_finance_reports',
'export_finance_reports',
],
'description' => '理事長 - 審核中大額財務申請、核准銀行調節表',
],
'finance_board_member' => [
'permissions' => [
// Approval stage (for large amounts)
'approve_finance_board',
// General
'view_finance_documents',
'view_finance_dashboard',
'view_finance_reports',
],
'description' => '理事 - 審核大額財務申請大於50,000',
],
'finance_requester' => [
'permissions' => [
'view_finance_documents',
'create_finance_documents',
'edit_finance_documents',
],
'description' => '財務申請人 - 可建立和編輯自己的財務申請單',
],
];
foreach ($roles as $roleName => $roleData) {
$role = Role::firstOrCreate(
['name' => $roleName],
['guard_name' => 'web']
);
// Assign permissions to role
$role->syncPermissions($roleData['permissions']);
$this->command->info("Role created: {$roleName} with permissions: " . implode(', ', $roleData['permissions']));
}
// Assign all financial workflow permissions to admin role (if exists)
$adminRole = Role::where('name', 'admin')->first();
if ($adminRole) {
$adminRole->givePermissionTo(array_keys($permissions));
$this->command->info("Admin role updated with all financial workflow permissions");
}
$this->command->info("\n=== Financial Workflow Roles & Permissions Created ===");
$this->command->info("Roles created:");
$this->command->info("1. finance_cashier - 出納(管錢)");
$this->command->info("2. finance_accountant - 會計(管帳)");
$this->command->info("3. finance_chair - 理事長");
$this->command->info("4. finance_board_member - 理事");
$this->command->info("5. finance_requester - 財務申請人");
$this->command->info("\nWorkflow stages:");
$this->command->info("1. Approval Stage: Cashier → Accountant → Chair (→ Board for large amounts)");
$this->command->info("2. Payment Stage: Accountant creates order → Cashier verifies → Cashier executes");
$this->command->info("3. Recording Stage: Cashier records ledger + Accountant records transactions");
$this->command->info("4. Reconciliation: Cashier prepares → Accountant reviews → Chair approves");
}
}

View File

@@ -0,0 +1,393 @@
<?php
namespace Database\Seeders;
use App\Models\BankReconciliation;
use App\Models\CashierLedgerEntry;
use App\Models\FinanceDocument;
use App\Models\PaymentOrder;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
/**
* Financial Workflow Test Data Seeder
*
* Generates comprehensive test data for the financial workflow system
*/
class FinancialWorkflowTestDataSeeder extends Seeder
{
protected User $requester;
protected User $cashier;
protected User $accountant;
protected User $chair;
protected User $boardMember;
/**
* Run the database seeds.
*/
public function run(): void
{
$this->command->info('🌱 Seeding financial workflow test data...');
// Create or get test users
$this->createTestUsers();
// Seed finance documents at various stages
$this->seedFinanceDocuments();
// Seed payment orders
$this->seedPaymentOrders();
// Seed cashier ledger entries
$this->seedCashierLedgerEntries();
// Seed bank reconciliations
$this->seedBankReconciliations();
$this->command->info('✅ Financial workflow test data seeded successfully!');
}
/**
* Create test users with appropriate roles
*/
protected function createTestUsers(): void
{
$this->command->info('Creating test users...');
$this->requester = User::firstOrCreate(
['email' => 'requester@test.com'],
[
'name' => 'Test Requester',
'password' => Hash::make('password'),
]
);
$this->requester->assignRole('finance_requester');
$this->cashier = User::firstOrCreate(
['email' => 'cashier@test.com'],
[
'name' => 'Test Cashier',
'password' => Hash::make('password'),
]
);
$this->cashier->assignRole('finance_cashier');
$this->accountant = User::firstOrCreate(
['email' => 'accountant@test.com'],
[
'name' => 'Test Accountant',
'password' => Hash::make('password'),
]
);
$this->accountant->assignRole('finance_accountant');
$this->chair = User::firstOrCreate(
['email' => 'chair@test.com'],
[
'name' => 'Test Chair',
'password' => Hash::make('password'),
]
);
$this->chair->assignRole('finance_chair');
$this->boardMember = User::firstOrCreate(
['email' => 'board@test.com'],
[
'name' => 'Test Board Member',
'password' => Hash::make('password'),
]
);
$this->boardMember->assignRole('finance_board_member');
$this->command->info('✓ Test users created');
}
/**
* Seed finance documents at various stages of the workflow
*/
protected function seedFinanceDocuments(): void
{
$this->command->info('Seeding finance documents...');
// Pending documents (Stage 1)
FinanceDocument::factory()
->count(3)
->smallAmount()
->pending()
->create(['submitted_by_id' => $this->requester->id]);
FinanceDocument::factory()
->count(2)
->mediumAmount()
->pending()
->create(['submitted_by_id' => $this->requester->id]);
// Approved by cashier (Stage 1)
FinanceDocument::factory()
->count(2)
->smallAmount()
->approvedByCashier()
->create([
'submitted_by_id' => $this->requester->id,
'cashier_approved_by_id' => $this->cashier->id,
]);
// Approved by accountant - small amounts (Ready for payment)
FinanceDocument::factory()
->count(3)
->smallAmount()
->approvedByAccountant()
->create([
'submitted_by_id' => $this->requester->id,
'cashier_approved_by_id' => $this->cashier->id,
'accountant_approved_by_id' => $this->accountant->id,
]);
// Approved by chair - medium amounts (Ready for payment)
FinanceDocument::factory()
->count(2)
->mediumAmount()
->approvedByChair()
->create([
'submitted_by_id' => $this->requester->id,
'cashier_approved_by_id' => $this->cashier->id,
'accountant_approved_by_id' => $this->accountant->id,
'chair_approved_by_id' => $this->chair->id,
]);
// Large amount with board approval (Ready for payment)
FinanceDocument::factory()
->count(1)
->largeAmount()
->approvedByChair()
->create([
'submitted_by_id' => $this->requester->id,
'cashier_approved_by_id' => $this->cashier->id,
'accountant_approved_by_id' => $this->accountant->id,
'chair_approved_by_id' => $this->chair->id,
'board_meeting_approved_at' => now(),
'board_meeting_approved_by_id' => $this->boardMember->id,
]);
// Completed workflow
FinanceDocument::factory()
->count(5)
->smallAmount()
->approvedByAccountant()
->paymentExecuted()
->create([
'submitted_by_id' => $this->requester->id,
'cashier_approved_by_id' => $this->cashier->id,
'accountant_approved_by_id' => $this->accountant->id,
'cashier_recorded_at' => now(),
]);
// Rejected documents
FinanceDocument::factory()
->count(2)
->rejected()
->create([
'submitted_by_id' => $this->requester->id,
]);
$this->command->info('✓ Finance documents seeded');
}
/**
* Seed payment orders
*/
protected function seedPaymentOrders(): void
{
$this->command->info('Seeding payment orders...');
// Get approved documents without payment orders
$approvedDocs = FinanceDocument::whereIn('status', [
FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
FinanceDocument::STATUS_APPROVED_CHAIR,
])
->whereNull('payment_order_created_at')
->limit(5)
->get();
foreach ($approvedDocs as $doc) {
// Pending verification
PaymentOrder::factory()
->pendingVerification()
->create([
'finance_document_id' => $doc->id,
'payment_amount' => $doc->amount,
'created_by_accountant_id' => $this->accountant->id,
]);
$doc->update([
'payment_order_created_at' => now(),
'payment_order_created_by_id' => $this->accountant->id,
]);
}
// Verified payment orders
PaymentOrder::factory()
->count(3)
->verified()
->create([
'created_by_accountant_id' => $this->accountant->id,
'verified_by_cashier_id' => $this->cashier->id,
]);
// Executed payment orders
PaymentOrder::factory()
->count(5)
->executed()
->create([
'created_by_accountant_id' => $this->accountant->id,
'verified_by_cashier_id' => $this->cashier->id,
'executed_by_cashier_id' => $this->cashier->id,
]);
$this->command->info('✓ Payment orders seeded');
}
/**
* Seed cashier ledger entries with running balances
*/
protected function seedCashierLedgerEntries(): void
{
$this->command->info('Seeding cashier ledger entries...');
$bankAccounts = [
'First Bank - 1234567890',
'Second Bank - 0987654321',
'Petty Cash',
];
foreach ($bankAccounts as $account) {
$currentBalance = 100000; // Starting balance
// Create 10 entries for each account
for ($i = 0; $i < 10; $i++) {
$isReceipt = $i % 3 !== 0; // 2/3 receipts, 1/3 payments
$amount = rand(1000, 10000);
$entry = CashierLedgerEntry::create([
'entry_type' => $isReceipt ? 'receipt' : 'payment',
'entry_date' => now()->subDays(rand(1, 30)),
'amount' => $amount,
'payment_method' => $account === 'Petty Cash' ? 'cash' : 'bank_transfer',
'bank_account' => $account,
'balance_before' => $currentBalance,
'balance_after' => $isReceipt
? $currentBalance + $amount
: $currentBalance - $amount,
'receipt_number' => $isReceipt ? 'RCP' . str_pad($i + 1, 6, '0', STR_PAD_LEFT) : null,
'notes' => $isReceipt ? 'Test receipt entry' : 'Test payment entry',
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now()->subDays(rand(1, 30)),
]);
$currentBalance = $entry->balance_after;
}
}
$this->command->info('✓ Cashier ledger entries seeded');
}
/**
* Seed bank reconciliations
*/
protected function seedBankReconciliations(): void
{
$this->command->info('Seeding bank reconciliations...');
// Pending reconciliation
BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Vendor A payment'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Service fee'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Member dues'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Monthly service charge'],
],
'discrepancy_amount' => 4500,
'notes' => 'Pending review',
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
// Reviewed reconciliation
BankReconciliation::create([
'reconciliation_month' => now()->subMonth()->startOfMonth(),
'bank_statement_date' => now()->subMonth(),
'bank_statement_balance' => 95000,
'system_book_balance' => 93000,
'outstanding_checks' => [
['check_number' => 'CHK003', 'amount' => 1500, 'description' => 'Supplies'],
],
'deposits_in_transit' => [
['date' => now()->subMonth()->format('Y-m-d'), 'amount' => 3000, 'description' => 'Donation'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Service charge'],
],
'discrepancy_amount' => 0,
'notes' => 'All items reconciled',
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now()->subMonth(),
'reviewed_by_accountant_id' => $this->accountant->id,
'reviewed_at' => now()->subMonth()->addDays(2),
'reconciliation_status' => 'pending',
]);
// Completed reconciliation
BankReconciliation::create([
'reconciliation_month' => now()->subMonths(2)->startOfMonth(),
'bank_statement_date' => now()->subMonths(2),
'bank_statement_balance' => 90000,
'system_book_balance' => 90000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [
['amount' => 500, 'description' => 'Service charge'],
],
'discrepancy_amount' => 0,
'notes' => 'Perfect match',
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now()->subMonths(2),
'reviewed_by_accountant_id' => $this->accountant->id,
'reviewed_at' => now()->subMonths(2)->addDays(2),
'approved_by_manager_id' => $this->chair->id,
'approved_at' => now()->subMonths(2)->addDays(3),
'reconciliation_status' => 'completed',
]);
// Reconciliation with discrepancy
BankReconciliation::create([
'reconciliation_month' => now()->subMonths(3)->startOfMonth(),
'bank_statement_date' => now()->subMonths(3),
'bank_statement_balance' => 85000,
'system_book_balance' => 75000,
'outstanding_checks' => [
['check_number' => 'CHK004', 'amount' => 2000, 'description' => 'Payment'],
],
'deposits_in_transit' => [],
'bank_charges' => [
['amount' => 500, 'description' => 'Service charge'],
],
'discrepancy_amount' => 7500,
'notes' => 'Large discrepancy - needs investigation',
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now()->subMonths(3),
'reconciliation_status' => 'discrepancy',
]);
$this->command->info('✓ Bank reconciliations seeded');
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Database\Seeders;
use App\Models\IssueLabel;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class IssueLabelSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$labels = [
[
'name' => 'urgent',
'color' => '#DC2626',
'description' => 'Requires immediate attention',
],
[
'name' => 'bug',
'color' => '#EF4444',
'description' => 'Something is not working correctly',
],
[
'name' => 'enhancement',
'color' => '#3B82F6',
'description' => 'New feature or improvement request',
],
[
'name' => 'documentation',
'color' => '#10B981',
'description' => 'Documentation related task',
],
[
'name' => 'member-facing',
'color' => '#8B5CF6',
'description' => 'Affects members directly',
],
[
'name' => 'internal',
'color' => '#F59E0B',
'description' => 'Internal staff operations',
],
[
'name' => 'event',
'color' => '#EC4899',
'description' => 'Event planning or execution',
],
[
'name' => 'finance',
'color' => '#14B8A6',
'description' => 'Financial or budget related',
],
[
'name' => 'communications',
'color' => '#6366F1',
'description' => 'Marketing, PR, or communications',
],
[
'name' => 'blocked',
'color' => '#64748B',
'description' => 'Blocked by another issue or dependency',
],
[
'name' => 'help-wanted',
'color' => '#22C55E',
'description' => 'Looking for volunteers or assistance',
],
[
'name' => 'question',
'color' => '#A855F7',
'description' => 'Question or clarification needed',
],
];
foreach ($labels as $label) {
IssueLabel::updateOrCreate(
['name' => $label['name']],
$label
);
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class PaymentVerificationRolesSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Create permissions for payment verification workflow
$permissions = [
'verify_payments_cashier' => 'Verify membership payments as cashier (Tier 1)',
'verify_payments_accountant' => 'Verify membership payments as accountant (Tier 2)',
'verify_payments_chair' => 'Verify membership payments as chair (Tier 3)',
'activate_memberships' => 'Activate member accounts after payment approval',
'view_payment_verifications' => 'View payment verification dashboard',
];
foreach ($permissions as $name => $description) {
Permission::firstOrCreate(
['name' => $name],
['guard_name' => 'web']
);
$this->command->info("Permission created: {$name}");
}
// Create roles for payment verification
$roles = [
'payment_cashier' => [
'permissions' => ['verify_payments_cashier', 'view_payment_verifications'],
'description' => 'Cashier - First tier payment verification',
],
'payment_accountant' => [
'permissions' => ['verify_payments_accountant', 'view_payment_verifications'],
'description' => 'Accountant - Second tier payment verification',
],
'payment_chair' => [
'permissions' => ['verify_payments_chair', 'view_payment_verifications'],
'description' => 'Chair - Final tier payment verification',
],
'membership_manager' => [
'permissions' => ['activate_memberships', 'view_payment_verifications'],
'description' => 'Membership Manager - Can activate memberships after approval',
],
];
foreach ($roles as $roleName => $roleData) {
$role = Role::firstOrCreate(
['name' => $roleName],
['guard_name' => 'web']
);
// Assign permissions to role
$role->syncPermissions($roleData['permissions']);
$this->command->info("Role created: {$roleName} with permissions: " . implode(', ', $roleData['permissions']));
}
// Assign all payment verification permissions to admin role (if exists)
$adminRole = Role::where('name', 'admin')->first();
if ($adminRole) {
$adminRole->givePermissionTo([
'verify_payments_cashier',
'verify_payments_accountant',
'verify_payments_chair',
'activate_memberships',
'view_payment_verifications',
]);
$this->command->info("Admin role updated with all payment verification permissions");
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
class RoleSeeder extends Seeder
{
public function run(): void
{
$roles = [
'admin' => 'Full system administrator',
'staff' => 'General staff with access to internal tools',
'cashier' => 'Handles payment recording and finance intake',
'accountant' => 'Reviews finance docs and approvals',
'chair' => 'Board chairperson for final approvals',
];
collect($roles)->each(function ($description, $role) {
Role::updateOrCreate(
['name' => $role, 'guard_name' => 'web'],
['description' => $description]
);
});
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace Database\Seeders;
use App\Models\SystemSetting;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SystemSettingsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$settings = [
// General Settings
[
'key' => 'general.system_name',
'value' => 'Usher Management System',
'type' => 'string',
'group' => 'general',
'description' => 'System name displayed throughout the application'
],
[
'key' => 'general.timezone',
'value' => 'Asia/Taipei',
'type' => 'string',
'group' => 'general',
'description' => 'System timezone'
],
// Document Features
[
'key' => 'features.qr_codes_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'features',
'description' => 'Enable QR code generation for documents'
],
[
'key' => 'features.tagging_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'features',
'description' => 'Enable document tagging system'
],
[
'key' => 'features.expiration_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'features',
'description' => 'Enable document expiration dates and auto-archive'
],
[
'key' => 'features.bulk_import_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'features',
'description' => 'Enable bulk document import feature'
],
[
'key' => 'features.statistics_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'features',
'description' => 'Enable document statistics dashboard'
],
[
'key' => 'features.version_history_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'features',
'description' => 'Enable document version history tracking'
],
// Security & Limits
[
'key' => 'security.rate_limit_authenticated',
'value' => '50',
'type' => 'integer',
'group' => 'security',
'description' => 'Downloads per hour for authenticated users'
],
[
'key' => 'security.rate_limit_guest',
'value' => '10',
'type' => 'integer',
'group' => 'security',
'description' => 'Downloads per hour for guest users'
],
[
'key' => 'security.max_file_size_mb',
'value' => '10',
'type' => 'integer',
'group' => 'security',
'description' => 'Maximum file upload size in MB'
],
[
'key' => 'security.allowed_file_types',
'value' => json_encode(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'jpeg', 'png']),
'type' => 'json',
'group' => 'security',
'description' => 'Allowed file types for uploads'
],
// Document Settings
[
'key' => 'documents.default_access_level',
'value' => 'members',
'type' => 'string',
'group' => 'documents',
'description' => 'Default access level for new documents (public, members, admin, board)'
],
[
'key' => 'documents.default_expiration_days',
'value' => '90',
'type' => 'integer',
'group' => 'documents',
'description' => 'Default expiration period in days (0 = no expiration)'
],
[
'key' => 'documents.expiration_warning_days',
'value' => '30',
'type' => 'integer',
'group' => 'documents',
'description' => 'Days before expiration to show warning'
],
[
'key' => 'documents.auto_archive_enabled',
'value' => '0',
'type' => 'boolean',
'group' => 'documents',
'description' => 'Automatically archive expired documents'
],
[
'key' => 'documents.max_tags_per_document',
'value' => '10',
'type' => 'integer',
'group' => 'documents',
'description' => 'Maximum number of tags per document'
],
// Notifications
[
'key' => 'notifications.enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'notifications',
'description' => 'Enable email notifications'
],
[
'key' => 'notifications.expiration_alerts_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'notifications',
'description' => 'Send email alerts for expiring documents'
],
[
'key' => 'notifications.expiration_recipients',
'value' => json_encode([]),
'type' => 'json',
'group' => 'notifications',
'description' => 'Email recipients for expiration alerts'
],
[
'key' => 'notifications.archive_notifications_enabled',
'value' => '1',
'type' => 'boolean',
'group' => 'notifications',
'description' => 'Send notifications when documents are auto-archived'
],
[
'key' => 'notifications.new_document_alerts_enabled',
'value' => '0',
'type' => 'boolean',
'group' => 'notifications',
'description' => 'Send alerts when new documents are uploaded'
],
// Advanced Settings
[
'key' => 'advanced.qr_code_size',
'value' => '300',
'type' => 'integer',
'group' => 'advanced',
'description' => 'QR code size in pixels'
],
[
'key' => 'advanced.qr_code_format',
'value' => 'png',
'type' => 'string',
'group' => 'advanced',
'description' => 'QR code format (png or svg)'
],
[
'key' => 'advanced.statistics_time_range',
'value' => '30',
'type' => 'integer',
'group' => 'advanced',
'description' => 'Default time range for statistics in days'
],
[
'key' => 'advanced.statistics_top_n',
'value' => '10',
'type' => 'integer',
'group' => 'advanced',
'description' => 'Number of top items to display in statistics'
],
[
'key' => 'advanced.audit_log_retention_days',
'value' => '365',
'type' => 'integer',
'group' => 'advanced',
'description' => 'How long to retain audit logs in days'
],
[
'key' => 'advanced.max_versions_retain',
'value' => '0',
'type' => 'integer',
'group' => 'advanced',
'description' => 'Maximum versions to retain per document (0 = unlimited)'
],
];
foreach ($settings as $settingData) {
SystemSetting::updateOrCreate(
['key' => $settingData['key']],
[
'value' => $settingData['value'],
'type' => $settingData['type'],
'group' => $settingData['group'],
'description' => $settingData['description'],
]
);
}
$this->command->info('System settings seeded successfully (' . count($settings) . ' settings)');
}
}

View File

@@ -0,0 +1,769 @@
<?php
namespace Database\Seeders;
use App\Models\Budget;
use App\Models\BudgetItem;
use App\Models\ChartOfAccount;
use App\Models\FinanceDocument;
use App\Models\Issue;
use App\Models\IssueComment;
use App\Models\IssueLabel;
use App\Models\IssueTimeLog;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
class TestDataSeeder extends Seeder
{
/**
* Run the database seeds.
*
* Creates comprehensive test data for manual and automated testing:
* - 5 test users with different roles
* - 20 members in various states (pending, active, expired, suspended)
* - 30 payments at different approval stages
* - 15 issues with various statuses
* - 5 budgets with items
* - 10 finance documents
* - Sample transactions
*/
public function run(): void
{
$this->command->info('🌱 Starting Test Data Seeding...');
// Ensure required seeders have run
$this->call([
RoleSeeder::class,
PaymentVerificationRolesSeeder::class,
ChartOfAccountSeeder::class,
IssueLabelSeeder::class,
]);
// Create test users with different roles
$users = $this->createTestUsers();
$this->command->info('✅ Created 6 test users with different roles');
// Create members in various states
$members = $this->createTestMembers($users);
$this->command->info('✅ Created 20 members in various membership states');
// Create payments at different approval stages
$payments = $this->createTestPayments($members, $users);
$this->command->info('✅ Created 30 membership payments at different approval stages');
// Create issues with various statuses
$issues = $this->createTestIssues($users, $members);
$this->command->info('✅ Created 15 issues with various statuses and relationships');
// Create budgets with items
$budgets = $this->createTestBudgets($users);
$this->command->info('✅ Created 5 budgets with budget items in different states');
// Create finance documents
$financeDocuments = $this->createTestFinanceDocuments($users);
$this->command->info('✅ Created 10 finance documents');
// Create sample transactions
$transactions = $this->createTestTransactions($users);
$this->command->info('✅ Created sample transactions');
$this->command->info('');
$this->command->info('🎉 Test Data Seeding Complete!');
$this->command->info('');
$this->displayTestAccounts($users);
}
/**
* Create test users with different roles
*/
private function createTestUsers(): array
{
$users = [];
// 1. Super Admin
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$admin->assignRole('admin');
$users['admin'] = $admin;
// 2. Payment Cashier
$cashier = User::create([
'name' => 'Cashier User',
'email' => 'cashier@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$cashier->assignRole('payment_cashier');
$users['cashier'] = $cashier;
// 3. Payment Accountant
$accountant = User::create([
'name' => 'Accountant User',
'email' => 'accountant@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$accountant->assignRole('payment_accountant');
$users['accountant'] = $accountant;
// 4. Payment Chair
$chair = User::create([
'name' => 'Chair User',
'email' => 'chair@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$chair->assignRole('payment_chair');
$users['chair'] = $chair;
// 5. Membership Manager
$manager = User::create([
'name' => 'Membership Manager',
'email' => 'manager@test.com',
'password' => Hash::make('password'),
'is_admin' => true,
]);
$manager->assignRole('membership_manager');
$users['manager'] = $manager;
// 6. Regular Member User
$member = User::create([
'name' => 'Regular Member',
'email' => 'member@test.com',
'password' => Hash::make('password'),
'is_admin' => false,
]);
$users['member'] = $member;
return $users;
}
/**
* Create test members in various states
*/
private function createTestMembers(array $users): array
{
$members = [];
$taiwanCities = ['台北市', '新北市', '台中市', '台南市', '高雄市', '桃園市'];
$counter = 1;
// 5 Pending Members
for ($i = 0; $i < 5; $i++) {
$members[] = Member::create([
'user_id' => $i === 0 ? $users['member']->id : null,
'full_name' => "待審核會員 {$counter}",
'email' => "pending{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$counter++;
}
// 8 Active Members
for ($i = 0; $i < 8; $i++) {
$startDate = now()->subMonths(rand(1, 6));
$members[] = Member::create([
'user_id' => null,
'full_name' => "活躍會員 {$counter}",
'email' => "active{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_ACTIVE,
'membership_type' => $i < 6 ? Member::TYPE_REGULAR : ($i === 6 ? Member::TYPE_HONORARY : Member::TYPE_STUDENT),
'membership_started_at' => $startDate,
'membership_expires_at' => $startDate->copy()->addYear(),
'emergency_contact_name' => "緊急聯絡人 {$counter}",
'emergency_contact_phone' => '02-12345678',
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$counter++;
}
// 3 Expired Members
for ($i = 0; $i < 3; $i++) {
$startDate = now()->subYears(2);
$members[] = Member::create([
'user_id' => null,
'full_name' => "過期會員 {$counter}",
'email' => "expired{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_EXPIRED,
'membership_type' => Member::TYPE_REGULAR,
'membership_started_at' => $startDate,
'membership_expires_at' => $startDate->copy()->addYear(),
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$counter++;
}
// 2 Suspended Members
for ($i = 0; $i < 2; $i++) {
$members[] = Member::create([
'user_id' => null,
'full_name' => "停權會員 {$counter}",
'email' => "suspended{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_SUSPENDED,
'membership_type' => Member::TYPE_REGULAR,
'national_id_encrypted' => Crypt::encryptString('A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
'national_id_hash' => hash('sha256', 'A' . str_pad($counter, 9, '0', STR_PAD_LEFT)),
]);
$counter++;
}
// 2 Additional Pending Members (total 20)
for ($i = 0; $i < 2; $i++) {
$members[] = Member::create([
'user_id' => null,
'full_name' => "新申請會員 {$counter}",
'email' => "newmember{$counter}@test.com",
'phone' => '09' . str_pad($counter, 8, '0', STR_PAD_LEFT),
'address_line_1' => "測試地址 {$counter}",
'city' => $taiwanCities[array_rand($taiwanCities)],
'postal_code' => '100',
'membership_status' => Member::STATUS_PENDING,
'membership_type' => Member::TYPE_REGULAR,
]);
$counter++;
}
return $members;
}
/**
* Create test membership payments at different approval stages
*/
private function createTestPayments(array $members, array $users): array
{
$payments = [];
$paymentMethods = [
MembershipPayment::METHOD_BANK_TRANSFER,
MembershipPayment::METHOD_CASH,
MembershipPayment::METHOD_CHECK,
];
// 10 Pending Payments
for ($i = 0; $i < 10; $i++) {
$payments[] = MembershipPayment::create([
'member_id' => $members[$i]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(1, 10)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_PENDING,
'notes' => '待審核的繳費記錄',
]);
}
// 8 Approved by Cashier
for ($i = 10; $i < 18; $i++) {
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(5, 15)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => now()->subDays(rand(3, 12)),
'cashier_notes' => '收據已核對,金額無誤',
'notes' => '已通過出納審核',
]);
}
// 6 Approved by Accountant
for ($i = 18; $i < 24; $i++) {
$cashierVerifiedAt = now()->subDays(rand(10, 20));
$accountantVerifiedAt = $cashierVerifiedAt->copy()->addDays(rand(1, 3));
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(15, 25)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => $cashierVerifiedAt,
'cashier_notes' => '收據已核對,金額無誤',
'verified_by_accountant_id' => $users['accountant']->id,
'accountant_verified_at' => $accountantVerifiedAt,
'accountant_notes' => '帳務核對完成',
'notes' => '已通過會計審核',
]);
}
// 4 Fully Approved (Chair approved - member activated)
for ($i = 24; $i < 28; $i++) {
$cashierVerifiedAt = now()->subDays(rand(20, 30));
$accountantVerifiedAt = $cashierVerifiedAt->copy()->addDays(rand(1, 3));
$chairVerifiedAt = $accountantVerifiedAt->copy()->addDays(rand(1, 2));
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(25, 35)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'verified_by_cashier_id' => $users['cashier']->id,
'cashier_verified_at' => $cashierVerifiedAt,
'cashier_notes' => '收據已核對,金額無誤',
'verified_by_accountant_id' => $users['accountant']->id,
'accountant_verified_at' => $accountantVerifiedAt,
'accountant_notes' => '帳務核對完成',
'verified_by_chair_id' => $users['chair']->id,
'chair_verified_at' => $chairVerifiedAt,
'chair_notes' => '最終批准',
'notes' => '已完成三階段審核',
]);
}
// 2 Rejected Payments
for ($i = 28; $i < 30; $i++) {
$payments[] = MembershipPayment::create([
'member_id' => $members[$i % count($members)]->id,
'amount' => 1000,
'paid_at' => now()->subDays(rand(5, 10)),
'payment_method' => $paymentMethods[array_rand($paymentMethods)],
'reference' => 'REF-' . str_pad($i + 1, 5, '0', STR_PAD_LEFT),
'receipt_path' => 'receipts/test-receipt-' . ($i + 1) . '.pdf',
'status' => MembershipPayment::STATUS_REJECTED,
'rejected_by_user_id' => $users['cashier']->id,
'rejected_at' => now()->subDays(rand(3, 8)),
'rejection_reason' => $i === 28 ? '收據影像不清晰,無法辨識' : '金額與收據不符',
'notes' => '已退回',
]);
}
return $payments;
}
/**
* Create test issues with various statuses
*/
private function createTestIssues(array $users, array $members): array
{
$issues = [];
$labels = IssueLabel::all();
// 5 New/Open Issues
for ($i = 0; $i < 5; $i++) {
$issue = Issue::create([
'title' => "新任務:測試項目 " . ($i + 1),
'description' => "這是一個新的測試任務,用於系統測試。\n\n## 任務說明\n- 測試項目 A\n- 測試項目 B\n- 測試項目 C",
'issue_type' => [Issue::TYPE_WORK_ITEM, Issue::TYPE_PROJECT_TASK][array_rand([0, 1])],
'status' => Issue::STATUS_NEW,
'priority' => [Issue::PRIORITY_LOW, Issue::PRIORITY_MEDIUM, Issue::PRIORITY_HIGH][array_rand([0, 1, 2])],
'created_by_user_id' => $users['admin']->id,
'due_date' => now()->addDays(rand(7, 30)),
'estimated_hours' => rand(2, 16),
]);
// Add labels
if ($labels->count() > 0) {
$issue->labels()->attach($labels->random(rand(1, min(3, $labels->count()))));
}
$issues[] = $issue;
}
// 4 In Progress Issues
for ($i = 5; $i < 9; $i++) {
$issue = Issue::create([
'title' => "進行中:開發任務 " . ($i + 1),
'description' => "這是一個進行中的開發任務。\n\n## 進度\n- [x] 需求分析\n- [x] 設計\n- [ ] 實作\n- [ ] 測試",
'issue_type' => Issue::TYPE_WORK_ITEM,
'status' => Issue::STATUS_IN_PROGRESS,
'priority' => [Issue::PRIORITY_MEDIUM, Issue::PRIORITY_HIGH][array_rand([0, 1])],
'created_by_user_id' => $users['admin']->id,
'assigned_to_user_id' => $users['member']->id,
'due_date' => now()->addDays(rand(3, 14)),
'estimated_hours' => rand(4, 20),
]);
// Add time logs
IssueTimeLog::create([
'issue_id' => $issue->id,
'user_id' => $users['member']->id,
'hours' => rand(1, 5),
'description' => '開發進度更新',
'logged_at' => now()->subDays(rand(1, 3)),
]);
// Add comments
IssueComment::create([
'issue_id' => $issue->id,
'user_id' => $users['admin']->id,
'comment' => '請加快進度,謝謝!',
'created_at' => now()->subDays(rand(1, 2)),
]);
if ($labels->count() > 0) {
$issue->labels()->attach($labels->random(rand(1, min(2, $labels->count()))));
}
$issues[] = $issue;
}
// 3 Resolved Issues (in review)
for ($i = 9; $i < 12; $i++) {
$issue = Issue::create([
'title' => "已完成:維護項目 " . ($i + 1),
'description' => "維護任務已完成,等待審核。",
'issue_type' => Issue::TYPE_MAINTENANCE,
'status' => Issue::STATUS_REVIEW,
'priority' => Issue::PRIORITY_MEDIUM,
'created_by_user_id' => $users['admin']->id,
'assigned_to_user_id' => $users['member']->id,
'reviewer_id' => $users['manager']->id,
'due_date' => now()->subDays(rand(1, 5)),
'estimated_hours' => rand(2, 8),
]);
// Add completed time logs
IssueTimeLog::create([
'issue_id' => $issue->id,
'user_id' => $users['member']->id,
'hours' => rand(2, 6),
'description' => '任務完成',
'logged_at' => now()->subDays(rand(1, 3)),
]);
IssueComment::create([
'issue_id' => $issue->id,
'user_id' => $users['member']->id,
'comment' => '任務已完成,請審核。',
'created_at' => now()->subDays(1),
]);
if ($labels->count() > 0) {
$issue->labels()->attach($labels->random(1));
}
$issues[] = $issue;
}
// 2 Closed Issues
for ($i = 12; $i < 14; $i++) {
$closedAt = now()->subDays(rand(7, 30));
$issue = Issue::create([
'title' => "已結案:專案 " . ($i + 1),
'description' => "專案已完成並結案。",
'issue_type' => Issue::TYPE_PROJECT_TASK,
'status' => Issue::STATUS_CLOSED,
'priority' => Issue::PRIORITY_HIGH,
'created_by_user_id' => $users['admin']->id,
'assigned_to_user_id' => $users['member']->id,
'due_date' => $closedAt->copy()->subDays(rand(1, 5)),
'closed_at' => $closedAt,
'estimated_hours' => rand(8, 24),
]);
IssueTimeLog::create([
'issue_id' => $issue->id,
'user_id' => $users['member']->id,
'hours' => rand(8, 20),
'description' => '專案完成',
'logged_at' => $closedAt->copy()->subDays(1),
]);
IssueComment::create([
'issue_id' => $issue->id,
'user_id' => $users['admin']->id,
'comment' => '專案驗收通過,結案。',
'created_at' => $closedAt,
]);
if ($labels->count() > 0) {
$issue->labels()->attach($labels->random(rand(1, min(2, $labels->count()))));
}
$issues[] = $issue;
}
// 1 Member Request
$issue = Issue::create([
'title' => "會員需求:更新會員資料",
'description' => "會員要求更新個人資料。",
'issue_type' => Issue::TYPE_MEMBER_REQUEST,
'status' => Issue::STATUS_ASSIGNED,
'priority' => Issue::PRIORITY_MEDIUM,
'created_by_user_id' => $users['admin']->id,
'assigned_to_user_id' => $users['manager']->id,
'member_id' => $members[0]->id,
'due_date' => now()->addDays(3),
'estimated_hours' => 1,
]);
if ($labels->count() > 0) {
$issue->labels()->attach($labels->random(1));
}
$issues[] = $issue;
return $issues;
}
/**
* Create test budgets with items
*/
private function createTestBudgets(array $users): array
{
$budgets = [];
$accounts = ChartOfAccount::all();
if ($accounts->isEmpty()) {
$this->command->warn('⚠️ No Chart of Accounts found. Skipping budget creation.');
return $budgets;
}
$incomeAccounts = $accounts->where('account_type', ChartOfAccount::TYPE_INCOME);
$expenseAccounts = $accounts->where('account_type', ChartOfAccount::TYPE_EXPENSE);
// 1. Draft Budget
$budget = Budget::create([
'name' => '2025年度預算草案',
'fiscal_year' => 2025,
'status' => Budget::STATUS_DRAFT,
'created_by_user_id' => $users['admin']->id,
'notes' => '年度預算初稿,待提交',
]);
$this->createBudgetItems($budget, $incomeAccounts, $expenseAccounts, 50000, 40000);
$budgets[] = $budget;
// 2. Submitted Budget
$budget = Budget::create([
'name' => '2024下半年預算',
'fiscal_year' => 2024,
'status' => Budget::STATUS_SUBMITTED,
'created_by_user_id' => $users['admin']->id,
'submitted_at' => now()->subDays(5),
'notes' => '已提交,等待審核',
]);
$this->createBudgetItems($budget, $incomeAccounts, $expenseAccounts, 60000, 50000);
$budgets[] = $budget;
// 3. Approved Budget
$budget = Budget::create([
'name' => '2024上半年預算',
'fiscal_year' => 2024,
'status' => Budget::STATUS_APPROVED,
'created_by_user_id' => $users['admin']->id,
'submitted_at' => now()->subDays(60),
'approved_by_user_id' => $users['chair']->id,
'approved_at' => now()->subDays(55),
'notes' => '已核准',
]);
$this->createBudgetItems($budget, $incomeAccounts, $expenseAccounts, 70000, 60000);
$budgets[] = $budget;
// 4. Active Budget
$budget = Budget::create([
'name' => '2024年度預算',
'fiscal_year' => 2024,
'status' => Budget::STATUS_ACTIVE,
'created_by_user_id' => $users['admin']->id,
'submitted_at' => now()->subMonths(11),
'approved_by_user_id' => $users['chair']->id,
'approved_at' => now()->subMonths(10),
'activated_at' => now()->subMonths(10),
'notes' => '執行中的年度預算',
]);
$this->createBudgetItems($budget, $incomeAccounts, $expenseAccounts, 100000, 80000, true);
$budgets[] = $budget;
// 5. Closed Budget
$budget = Budget::create([
'name' => '2023年度預算',
'fiscal_year' => 2023,
'status' => Budget::STATUS_CLOSED,
'created_by_user_id' => $users['admin']->id,
'submitted_at' => now()->subMonths(23),
'approved_by_user_id' => $users['chair']->id,
'approved_at' => now()->subMonths(22),
'activated_at' => now()->subMonths(22),
'closed_at' => now()->subMonths(10),
'notes' => '已結案的年度預算',
]);
$this->createBudgetItems($budget, $incomeAccounts, $expenseAccounts, 90000, 75000, true);
$budgets[] = $budget;
return $budgets;
}
/**
* Create budget items for a budget
*/
private function createBudgetItems(
Budget $budget,
$incomeAccounts,
$expenseAccounts,
int $totalIncome,
int $totalExpense,
bool $withActuals = false
): void {
// Create income items
if ($incomeAccounts->count() > 0) {
$itemCount = min(3, $incomeAccounts->count());
$accounts = $incomeAccounts->random($itemCount);
foreach ($accounts as $index => $account) {
$budgetedAmount = (int)($totalIncome / $itemCount);
$actualAmount = $withActuals ? (int)($budgetedAmount * rand(80, 120) / 100) : 0;
BudgetItem::create([
'budget_id' => $budget->id,
'chart_of_account_id' => $account->id,
'budgeted_amount' => $budgetedAmount,
'actual_amount' => $actualAmount,
'notes' => '預算項目',
]);
}
}
// Create expense items
if ($expenseAccounts->count() > 0) {
$itemCount = min(5, $expenseAccounts->count());
$accounts = $expenseAccounts->random($itemCount);
foreach ($accounts as $index => $account) {
$budgetedAmount = (int)($totalExpense / $itemCount);
$actualAmount = $withActuals ? (int)($budgetedAmount * rand(70, 110) / 100) : 0;
BudgetItem::create([
'budget_id' => $budget->id,
'chart_of_account_id' => $account->id,
'budgeted_amount' => $budgetedAmount,
'actual_amount' => $actualAmount,
'notes' => '支出預算項目',
]);
}
}
}
/**
* Create test finance documents
*/
private function createTestFinanceDocuments(array $users): array
{
$documents = [];
$documentTypes = ['invoice', 'receipt', 'contract', 'report'];
$statuses = ['pending', 'approved', 'rejected'];
for ($i = 1; $i <= 10; $i++) {
$documents[] = FinanceDocument::create([
'document_number' => 'FIN-2024-' . str_pad($i, 4, '0', STR_PAD_LEFT),
'title' => "財務文件 {$i}",
'document_type' => $documentTypes[array_rand($documentTypes)],
'amount' => rand(1000, 50000),
'document_date' => now()->subDays(rand(1, 90)),
'status' => $statuses[array_rand($statuses)],
'uploaded_by_user_id' => $users['admin']->id,
'file_path' => "finance-documents/test-doc-{$i}.pdf",
'notes' => '測試財務文件',
]);
}
return $documents;
}
/**
* Create sample transactions
*/
private function createTestTransactions(array $users): array
{
$transactions = [];
$accounts = ChartOfAccount::all();
if ($accounts->isEmpty()) {
$this->command->warn('⚠️ No Chart of Accounts found. Skipping transaction creation.');
return $transactions;
}
// Create 20 sample transactions
for ($i = 1; $i <= 20; $i++) {
$account = $accounts->random();
$isDebit = $account->account_type === ChartOfAccount::TYPE_EXPENSE ||
$account->account_type === ChartOfAccount::TYPE_ASSET;
$transactions[] = Transaction::create([
'transaction_date' => now()->subDays(rand(1, 60)),
'chart_of_account_id' => $account->id,
'description' => "測試交易 {$i}" . $account->account_name,
'debit_amount' => $isDebit ? rand(500, 10000) : 0,
'credit_amount' => !$isDebit ? rand(500, 10000) : 0,
'created_by_user_id' => $users['accountant']->id,
'reference' => 'TXN-' . str_pad($i, 5, '0', STR_PAD_LEFT),
'notes' => '系統測試交易',
]);
}
return $transactions;
}
/**
* Display test account information
*/
private function displayTestAccounts(array $users): void
{
$this->command->info('📋 Test User Accounts:');
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->command->table(
['Role', 'Email', 'Password', 'Permissions'],
[
['Admin', 'admin@test.com', 'password', 'All permissions'],
['Cashier', 'cashier@test.com', 'password', 'Tier 1 payment verification'],
['Accountant', 'accountant@test.com', 'password', 'Tier 2 payment verification'],
['Chair', 'chair@test.com', 'password', 'Tier 3 payment verification'],
['Manager', 'manager@test.com', 'password', 'Membership activation'],
['Member', 'member@test.com', 'password', 'Member dashboard access'],
]
);
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->command->info('');
$this->command->info('🎯 Test Data Summary:');
$this->command->info(' • 20 Members (5 pending, 8 active, 3 expired, 2 suspended, 2 new)');
$this->command->info(' • 30 Payments (10 pending, 8 cashier-approved, 6 accountant-approved, 4 fully-approved, 2 rejected)');
$this->command->info(' • 15 Issues (5 new, 4 in-progress, 3 in-review, 2 closed, 1 member-request)');
$this->command->info(' • 5 Budgets (draft, submitted, approved, active, closed)');
$this->command->info(' • 10 Finance Documents');
$this->command->info(' • 20 Sample Transactions');
$this->command->info('');
}
}