Files
usher-manage-stack/app/Models/Issue.php
2025-11-20 23:21:05 +08:00

364 lines
9.6 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Issue extends Model
{
use HasFactory, SoftDeletes;
// Status constants
public const STATUS_NEW = 'new';
public const STATUS_ASSIGNED = 'assigned';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_REVIEW = 'review';
public const STATUS_CLOSED = 'closed';
// Issue type constants
public const TYPE_WORK_ITEM = 'work_item';
public const TYPE_PROJECT_TASK = 'project_task';
public const TYPE_MAINTENANCE = 'maintenance';
public const TYPE_MEMBER_REQUEST = 'member_request';
// Priority constants
public const PRIORITY_LOW = 'low';
public const PRIORITY_MEDIUM = 'medium';
public const PRIORITY_HIGH = 'high';
public const PRIORITY_URGENT = 'urgent';
protected $fillable = [
'issue_number',
'title',
'description',
'issue_type',
'status',
'priority',
'created_by_user_id',
'assigned_to_user_id',
'reviewer_id',
'member_id',
'parent_issue_id',
'due_date',
'closed_at',
'estimated_hours',
'actual_hours',
];
protected $casts = [
'due_date' => 'date',
'closed_at' => 'datetime',
'estimated_hours' => 'decimal:2',
'actual_hours' => 'decimal:2',
];
protected static function boot()
{
parent::boot();
// Auto-generate issue number on create
static::creating(function ($issue) {
if (!$issue->issue_number) {
$year = now()->year;
$lastIssue = static::whereYear('created_at', $year)
->orderBy('id', 'desc')
->first();
$nextNumber = $lastIssue ? ((int) substr($lastIssue->issue_number, -3)) + 1 : 1;
$issue->issue_number = sprintf('ISS-%d-%03d', $year, $nextNumber);
}
});
}
// ==================== Relationships ====================
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to_user_id');
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
public function parentIssue(): BelongsTo
{
return $this->belongsTo(Issue::class, 'parent_issue_id');
}
public function subTasks(): HasMany
{
return $this->hasMany(Issue::class, 'parent_issue_id');
}
public function comments(): HasMany
{
return $this->hasMany(IssueComment::class);
}
public function attachments(): HasMany
{
return $this->hasMany(IssueAttachment::class);
}
public function labels(): BelongsToMany
{
return $this->belongsToMany(IssueLabel::class, 'issue_label_pivot');
}
public function watchers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'issue_watchers');
}
public function timeLogs(): HasMany
{
return $this->hasMany(IssueTimeLog::class);
}
public function relationships(): HasMany
{
return $this->hasMany(IssueRelationship::class);
}
public function relatedIssues()
{
return $this->belongsToMany(Issue::class, 'issue_relationships', 'issue_id', 'related_issue_id')
->withPivot('relationship_type')
->withTimestamps();
}
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'customizable');
}
// ==================== Status Helpers ====================
public function isNew(): bool
{
return $this->status === self::STATUS_NEW;
}
public function isAssigned(): bool
{
return $this->status === self::STATUS_ASSIGNED;
}
public function isInProgress(): bool
{
return $this->status === self::STATUS_IN_PROGRESS;
}
public function inReview(): bool
{
return $this->status === self::STATUS_REVIEW;
}
public function isClosed(): bool
{
return $this->status === self::STATUS_CLOSED;
}
public function isOpen(): bool
{
return !$this->isClosed();
}
// ==================== Workflow Methods ====================
public function canBeAssigned(): bool
{
return $this->isNew() || $this->isAssigned();
}
public function canMoveToInProgress(): bool
{
return $this->isAssigned() && $this->assigned_to_user_id !== null;
}
public function canMoveToReview(): bool
{
return $this->isInProgress();
}
public function canBeClosed(): bool
{
return in_array($this->status, [
self::STATUS_REVIEW,
self::STATUS_IN_PROGRESS,
self::STATUS_ASSIGNED,
]);
}
public function canBeReopened(): bool
{
return $this->isClosed();
}
// ==================== Accessors ====================
public function getStatusLabelAttribute(): string
{
return match($this->status) {
self::STATUS_NEW => __('New'),
self::STATUS_ASSIGNED => __('Assigned'),
self::STATUS_IN_PROGRESS => __('In Progress'),
self::STATUS_REVIEW => __('Review'),
self::STATUS_CLOSED => __('Closed'),
default => $this->status,
};
}
public function getIssueTypeLabelAttribute(): string
{
return match($this->issue_type) {
self::TYPE_WORK_ITEM => __('Work Item'),
self::TYPE_PROJECT_TASK => __('Project Task'),
self::TYPE_MAINTENANCE => __('Maintenance'),
self::TYPE_MEMBER_REQUEST => __('Member Request'),
default => $this->issue_type,
};
}
public function getPriorityLabelAttribute(): string
{
return match($this->priority) {
self::PRIORITY_LOW => __('Low'),
self::PRIORITY_MEDIUM => __('Medium'),
self::PRIORITY_HIGH => __('High'),
self::PRIORITY_URGENT => __('Urgent'),
default => $this->priority,
};
}
public function getPriorityBadgeColorAttribute(): string
{
return match($this->priority) {
self::PRIORITY_LOW => 'gray',
self::PRIORITY_MEDIUM => 'blue',
self::PRIORITY_HIGH => 'orange',
self::PRIORITY_URGENT => 'red',
default => 'gray',
};
}
public function getStatusBadgeColorAttribute(): string
{
return match($this->status) {
self::STATUS_NEW => 'blue',
self::STATUS_ASSIGNED => 'purple',
self::STATUS_IN_PROGRESS => 'yellow',
self::STATUS_REVIEW => 'orange',
self::STATUS_CLOSED => 'green',
default => 'gray',
};
}
public function getProgressPercentageAttribute(): int
{
return match($this->status) {
self::STATUS_NEW => 0,
self::STATUS_ASSIGNED => 20,
self::STATUS_IN_PROGRESS => 50,
self::STATUS_REVIEW => 80,
self::STATUS_CLOSED => 100,
default => 0,
};
}
public function getIsOverdueAttribute(): bool
{
return $this->due_date &&
$this->due_date->isPast() &&
!$this->isClosed();
}
public function getDaysUntilDueAttribute(): ?int
{
if (!$this->due_date) {
return null;
}
return now()->startOfDay()->diffInDays($this->due_date->startOfDay(), false);
}
public function getTotalTimeLoggedAttribute(): float
{
return (float) $this->timeLogs()->sum('hours');
}
// ==================== Scopes ====================
public function scopeOpen($query)
{
return $query->where('status', '!=', self::STATUS_CLOSED);
}
public function scopeClosed($query)
{
return $query->where('status', self::STATUS_CLOSED);
}
public function scopeByType($query, string $type)
{
return $query->where('issue_type', $type);
}
public function scopeByPriority($query, string $priority)
{
return $query->where('priority', $priority);
}
public function scopeByStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeOverdue($query)
{
return $query->where('due_date', '<', now())
->where('status', '!=', self::STATUS_CLOSED);
}
public function scopeAssignedTo($query, int $userId)
{
return $query->where('assigned_to_user_id', $userId);
}
public function scopeCreatedBy($query, int $userId)
{
return $query->where('created_by_user_id', $userId);
}
public function scopeDueWithin($query, int $days)
{
return $query->whereBetween('due_date', [now(), now()->addDays($days)])
->where('status', '!=', self::STATUS_CLOSED);
}
public function scopeWithLabel($query, int $labelId)
{
return $query->whereHas('labels', function ($q) use ($labelId) {
$q->where('issue_labels.id', $labelId);
});
}
}