Features: - Implement two fee types: entrance fee and annual fee (both NT$1,000) - Add 50% discount for disability certificate holders - Add disability certificate upload in member profile - Integrate disability verification into cashier approval workflow - Add membership fee settings in system admin Document permissions: - Fix hard-coded role logic in Document model - Use permission-based authorization instead of role checks Additional features: - Add announcements, general ledger, and trial balance modules - Add income management and accounting entries - Add comprehensive test suite with factories - Update UI translations to Traditional Chinese 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
467 lines
12 KiB
PHP
467 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Support\Str;
|
|
|
|
class Document extends Model
|
|
{
|
|
use HasFactory, SoftDeletes;
|
|
|
|
protected $fillable = [
|
|
'document_category_id',
|
|
'title',
|
|
'document_number',
|
|
'description',
|
|
'public_uuid',
|
|
'access_level',
|
|
'current_version_id',
|
|
'status',
|
|
'archived_at',
|
|
'created_by_user_id',
|
|
'last_updated_by_user_id',
|
|
'view_count',
|
|
'download_count',
|
|
'version_count',
|
|
'expires_at',
|
|
'auto_archive_on_expiry',
|
|
'expiry_notice',
|
|
];
|
|
|
|
protected $casts = [
|
|
'archived_at' => 'datetime',
|
|
'expires_at' => 'date',
|
|
'auto_archive_on_expiry' => 'boolean',
|
|
];
|
|
|
|
protected static function boot()
|
|
{
|
|
parent::boot();
|
|
|
|
// Auto-generate UUID for public sharing
|
|
static::creating(function ($document) {
|
|
if (empty($document->public_uuid)) {
|
|
$document->public_uuid = (string) Str::uuid();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==================== Relationships ====================
|
|
|
|
/**
|
|
* Get the category this document belongs to
|
|
*/
|
|
public function category()
|
|
{
|
|
return $this->belongsTo(DocumentCategory::class, 'document_category_id');
|
|
}
|
|
|
|
/**
|
|
* Get all versions of this document
|
|
*/
|
|
public function versions()
|
|
{
|
|
return $this->hasMany(DocumentVersion::class)->orderBy('uploaded_at', 'desc');
|
|
}
|
|
|
|
/**
|
|
* Get the current published version
|
|
*/
|
|
public function currentVersion()
|
|
{
|
|
return $this->belongsTo(DocumentVersion::class, 'current_version_id');
|
|
}
|
|
|
|
/**
|
|
* Get the user who created this document
|
|
*/
|
|
public function createdBy()
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by_user_id');
|
|
}
|
|
|
|
/**
|
|
* Get the user who last updated this document
|
|
*/
|
|
public function lastUpdatedBy()
|
|
{
|
|
return $this->belongsTo(User::class, 'last_updated_by_user_id');
|
|
}
|
|
|
|
/**
|
|
* Get the tags for this document
|
|
*/
|
|
public function tags()
|
|
{
|
|
return $this->belongsToMany(DocumentTag::class, 'document_document_tag')
|
|
->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* Get access logs for this document
|
|
*/
|
|
public function accessLogs()
|
|
{
|
|
return $this->hasMany(DocumentAccessLog::class)->orderBy('accessed_at', 'desc');
|
|
}
|
|
|
|
// ==================== Status Check Methods ====================
|
|
|
|
/**
|
|
* Check if document is active
|
|
*/
|
|
public function isActive(): bool
|
|
{
|
|
return $this->status === 'active';
|
|
}
|
|
|
|
/**
|
|
* Check if document is archived
|
|
*/
|
|
public function isArchived(): bool
|
|
{
|
|
return $this->status === 'archived';
|
|
}
|
|
|
|
/**
|
|
* Check if document is publicly accessible
|
|
*/
|
|
public function isPublic(): bool
|
|
{
|
|
return $this->access_level === 'public';
|
|
}
|
|
|
|
/**
|
|
* Check if document requires membership
|
|
*/
|
|
public function requiresMembership(): bool
|
|
{
|
|
return $this->access_level === 'members';
|
|
}
|
|
|
|
/**
|
|
* Check if document is admin-only
|
|
*/
|
|
public function isAdminOnly(): bool
|
|
{
|
|
return in_array($this->access_level, ['admin', 'board']);
|
|
}
|
|
|
|
// ==================== Version Control Methods ====================
|
|
|
|
/**
|
|
* Add a new version to this document
|
|
*/
|
|
public function addVersion(
|
|
string $filePath,
|
|
string $originalFilename,
|
|
string $mimeType,
|
|
int $fileSize,
|
|
User $uploadedBy,
|
|
?string $versionNotes = null
|
|
): DocumentVersion {
|
|
// Calculate next version number
|
|
$nextVersionNumber = $this->calculateNextVersionNumber();
|
|
|
|
// Unset current version flag on existing versions
|
|
$this->versions()->update(['is_current' => false]);
|
|
|
|
// Create new version
|
|
$version = $this->versions()->create([
|
|
'version_number' => $nextVersionNumber,
|
|
'version_notes' => $versionNotes,
|
|
'is_current' => true,
|
|
'file_path' => $filePath,
|
|
'original_filename' => $originalFilename,
|
|
'mime_type' => $mimeType,
|
|
'file_size' => $fileSize,
|
|
'file_hash' => hash_file('sha256', \Illuminate\Support\Facades\Storage::disk('private')->path($filePath)),
|
|
'uploaded_by_user_id' => $uploadedBy->id,
|
|
'uploaded_at' => now(),
|
|
]);
|
|
|
|
// Update document's current_version_id and increment version count
|
|
$this->update([
|
|
'current_version_id' => $version->id,
|
|
'version_count' => $this->version_count + 1,
|
|
'last_updated_by_user_id' => $uploadedBy->id,
|
|
]);
|
|
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Calculate the next version number
|
|
*/
|
|
private function calculateNextVersionNumber(): string
|
|
{
|
|
$latestVersion = $this->versions()->orderBy('id', 'desc')->first();
|
|
|
|
if (!$latestVersion) {
|
|
return '1.0';
|
|
}
|
|
|
|
// Parse current version (e.g., "1.5" -> major: 1, minor: 5)
|
|
$parts = explode('.', $latestVersion->version_number);
|
|
$major = (int) ($parts[0] ?? 1);
|
|
$minor = (int) ($parts[1] ?? 0);
|
|
|
|
// Increment minor version
|
|
$minor++;
|
|
|
|
return "{$major}.{$minor}";
|
|
}
|
|
|
|
/**
|
|
* Promote an old version to be the current version
|
|
*/
|
|
public function promoteVersion(DocumentVersion $version, User $user): void
|
|
{
|
|
if ($version->document_id !== $this->id) {
|
|
throw new \Exception('Version does not belong to this document');
|
|
}
|
|
|
|
// Unset current flag on all versions
|
|
$this->versions()->update(['is_current' => false]);
|
|
|
|
// Set this version as current
|
|
$version->update(['is_current' => true]);
|
|
|
|
// Update document's current_version_id
|
|
$this->update([
|
|
'current_version_id' => $version->id,
|
|
'last_updated_by_user_id' => $user->id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get version history with comparison data
|
|
*/
|
|
public function getVersionHistory(): array
|
|
{
|
|
$versions = $this->versions()->with('uploadedBy')->get();
|
|
$history = [];
|
|
|
|
foreach ($versions as $index => $version) {
|
|
$previousVersion = $versions->get($index + 1);
|
|
|
|
$history[] = [
|
|
'version' => $version,
|
|
'size_change' => $previousVersion ? $version->file_size - $previousVersion->file_size : 0,
|
|
'days_since_previous' => $previousVersion ? $version->uploaded_at->diffInDays($previousVersion->uploaded_at) : null,
|
|
];
|
|
}
|
|
|
|
return $history;
|
|
}
|
|
|
|
// ==================== Access Control Methods ====================
|
|
|
|
/**
|
|
* Check if a user can view this document
|
|
*/
|
|
public function canBeViewedBy(?User $user): bool
|
|
{
|
|
// 公開文件:任何人可看
|
|
if ($this->isPublic()) {
|
|
return true;
|
|
}
|
|
|
|
// 非公開文件需要登入
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
// 有文件管理權限者可看所有文件
|
|
if ($user->can('manage_documents')) {
|
|
return true;
|
|
}
|
|
|
|
// 會員等級:已繳費會員可看
|
|
if ($this->access_level === 'members') {
|
|
return $user->member && $user->member->hasPaidMembership();
|
|
}
|
|
|
|
// 管理員等級:有任何管理權限者可看
|
|
if ($this->access_level === 'admin') {
|
|
return $user->hasAnyPermission([
|
|
'manage_documents',
|
|
'manage_members',
|
|
'manage_finance',
|
|
'manage_system_settings',
|
|
]);
|
|
}
|
|
|
|
// 理事會等級:有理事會相關權限者可看
|
|
if ($this->access_level === 'board') {
|
|
return $user->hasAnyPermission([
|
|
'manage_documents',
|
|
'approve_finance_documents',
|
|
'verify_payments_chair',
|
|
'activate_memberships',
|
|
]);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Log access to this document
|
|
*/
|
|
public function logAccess(string $action, ?User $user = null): void
|
|
{
|
|
$this->accessLogs()->create([
|
|
'document_version_id' => $this->current_version_id,
|
|
'action' => $action,
|
|
'user_id' => $user?->id,
|
|
'ip_address' => request()->ip(),
|
|
'user_agent' => request()->userAgent(),
|
|
'accessed_at' => now(),
|
|
]);
|
|
|
|
// Increment counters
|
|
if ($action === 'view') {
|
|
$this->increment('view_count');
|
|
} elseif ($action === 'download') {
|
|
$this->increment('download_count');
|
|
}
|
|
}
|
|
|
|
// ==================== Helper Methods ====================
|
|
|
|
/**
|
|
* Get the public URL for this document
|
|
*/
|
|
public function getPublicUrl(): string
|
|
{
|
|
return route('documents.public.show', $this->public_uuid);
|
|
}
|
|
|
|
/**
|
|
* Get the access level label in Chinese
|
|
*/
|
|
public function getAccessLevelLabel(): string
|
|
{
|
|
return match($this->access_level) {
|
|
'public' => '公開',
|
|
'members' => '會員',
|
|
'admin' => '管理員',
|
|
'board' => '理事會',
|
|
default => '未知',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get status label in Chinese
|
|
*/
|
|
public function getStatusLabel(): string
|
|
{
|
|
return match($this->status) {
|
|
'active' => '啟用',
|
|
'archived' => '封存',
|
|
default => '未知',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Archive this document
|
|
*/
|
|
public function archive(): void
|
|
{
|
|
$this->update([
|
|
'status' => 'archived',
|
|
'archived_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Restore archived document
|
|
*/
|
|
public function unarchive(): void
|
|
{
|
|
$this->update([
|
|
'status' => 'active',
|
|
'archived_at' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check if document is expired
|
|
*/
|
|
public function isExpired(): bool
|
|
{
|
|
if (!$this->expires_at) {
|
|
return false;
|
|
}
|
|
|
|
return $this->expires_at->isPast();
|
|
}
|
|
|
|
/**
|
|
* Check if document is expiring soon (within 30 days)
|
|
*/
|
|
public function isExpiringSoon(int $days = 30): bool
|
|
{
|
|
if (!$this->expires_at) {
|
|
return false;
|
|
}
|
|
|
|
return $this->expires_at->isFuture() &&
|
|
$this->expires_at->diffInDays(now()) <= $days;
|
|
}
|
|
|
|
/**
|
|
* Get expiration status label
|
|
*/
|
|
public function getExpirationStatusLabel(): ?string
|
|
{
|
|
if (!$this->expires_at) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->isExpired()) {
|
|
return '已過期';
|
|
}
|
|
|
|
if ($this->isExpiringSoon(7)) {
|
|
return '即將過期';
|
|
}
|
|
|
|
if ($this->isExpiringSoon(30)) {
|
|
return '接近過期';
|
|
}
|
|
|
|
return '有效';
|
|
}
|
|
|
|
/**
|
|
* Generate QR code for this document
|
|
*/
|
|
public function generateQRCode(?int $size = null, ?string $format = null): string
|
|
{
|
|
$settings = app(\App\Services\SettingsService::class);
|
|
$size = $size ?? $settings->getQRCodeSize();
|
|
$format = $format ?? $settings->getQRCodeFormat();
|
|
|
|
return \SimpleSoftwareIO\QrCode\Facades\QrCode::size($size)
|
|
->format($format)
|
|
->generate($this->getPublicUrl());
|
|
}
|
|
|
|
/**
|
|
* Generate QR code as PNG
|
|
*/
|
|
public function generateQRCodePNG(?int $size = null): string
|
|
{
|
|
$settings = app(\App\Services\SettingsService::class);
|
|
$size = $size ?? $settings->getQRCodeSize();
|
|
|
|
return \SimpleSoftwareIO\QrCode\Facades\QrCode::size($size)
|
|
->format('png')
|
|
->generate($this->getPublicUrl());
|
|
}
|
|
}
|