feat: [approval] 전자결재 모듈 API 구현

- 마이그레이션 4개 (approval_forms, approval_lines, approvals, approval_steps)
- 모델 4개 (ApprovalForm, ApprovalLine, Approval, ApprovalStep)
- ApprovalService 비즈니스 로직 (양식/결재선 CRUD, 기안함/결재함/참조함, 결재 액션)
- 컨트롤러 3개 (ApprovalFormController, ApprovalLineController, ApprovalController)
- FormRequest 13개 (양식/결재선/문서 검증)
- Swagger 문서 3개 (26개 엔드포인트)
- i18n 메시지/에러 키 추가
- 라우트 26개 등록
This commit is contained in:
2025-12-17 23:23:20 +09:00
parent 77914da7b7
commit b43796a558
33 changed files with 4067 additions and 2 deletions

View File

@@ -0,0 +1,320 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 결재 문서 모델
*
* @property int $id
* @property int $tenant_id
* @property string $document_number
* @property int $form_id
* @property string $title
* @property array $content
* @property string $status
* @property int $drafter_id
* @property \Carbon\Carbon|null $drafted_at
* @property \Carbon\Carbon|null $completed_at
* @property int $current_step
* @property array|null $attachments
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Approval extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approvals';
protected $casts = [
'content' => 'array',
'attachments' => 'array',
'drafted_at' => 'datetime',
'completed_at' => 'datetime',
'current_step' => 'integer',
];
protected $fillable = [
'tenant_id',
'document_number',
'form_id',
'title',
'content',
'status',
'drafter_id',
'drafted_at',
'completed_at',
'current_step',
'attachments',
'created_by',
'updated_by',
'deleted_by',
];
protected $attributes = [
'status' => 'draft',
'current_step' => 0,
];
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_DRAFT = 'draft'; // 임시저장
public const STATUS_PENDING = 'pending'; // 결재 진행중
public const STATUS_APPROVED = 'approved'; // 승인 완료
public const STATUS_REJECTED = 'rejected'; // 반려
public const STATUS_CANCELLED = 'cancelled'; // 회수/취소
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_CANCELLED,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 결재 양식
*/
public function form(): BelongsTo
{
return $this->belongsTo(ApprovalForm::class, 'form_id');
}
/**
* 기안자
*/
public function drafter(): BelongsTo
{
return $this->belongsTo(User::class, 'drafter_id');
}
/**
* 결재 단계들
*/
public function steps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order');
}
/**
* 결재자 단계들 (참조 제외)
*/
public function approverSteps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'approval_id')
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->orderBy('step_order');
}
/**
* 참조자 단계들
*/
public function referenceSteps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'approval_id')
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
->orderBy('step_order');
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// 스코프
// =========================================================================
/**
* 특정 상태
*/
public function scopeWithStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 임시저장
*/
public function scopeDraft($query)
{
return $query->where('status', self::STATUS_DRAFT);
}
/**
* 결재 진행중
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 승인 완료
*/
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
/**
* 반려
*/
public function scopeRejected($query)
{
return $query->where('status', self::STATUS_REJECTED);
}
/**
* 완료됨 (승인 또는 반려)
*/
public function scopeCompleted($query)
{
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED]);
}
/**
* 특정 기안자
*/
public function scopeByDrafter($query, int $userId)
{
return $query->where('drafter_id', $userId);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 수정 가능 여부 (임시저장 상태만)
*/
public function isEditable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 상신 가능 여부
*/
public function isSubmittable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 승인/반려 가능 여부
*/
public function isActionable(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 회수 가능 여부 (기안자만, 진행중 상태)
*/
public function isCancellable(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 삭제 가능 여부 (임시저장만)
*/
public function isDeletable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_DRAFT => '임시저장',
self::STATUS_PENDING => '진행',
self::STATUS_APPROVED => '완료',
self::STATUS_REJECTED => '반려',
self::STATUS_CANCELLED => '회수',
default => $this->status,
};
}
/**
* 현재 결재자 확인
*/
public function getCurrentApproverStep(): ?ApprovalStep
{
return $this->steps()
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->where('status', ApprovalStep::STATUS_PENDING)
->orderBy('step_order')
->first();
}
/**
* 특정 사용자가 현재 결재자인지 확인
*/
public function isCurrentApprover(int $userId): bool
{
$currentStep = $this->getCurrentApproverStep();
return $currentStep && $currentStep->approver_id === $userId;
}
/**
* 특정 사용자가 참조자인지 확인
*/
public function isReferee(int $userId): bool
{
return $this->referenceSteps()
->where('approver_id', $userId)
->exists();
}
/**
* 결재 진행률
*/
public function getProgressAttribute(): array
{
$totalSteps = $this->approverSteps()->count();
$completedSteps = $this->approverSteps()
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED])
->count();
return [
'total' => $totalSteps,
'completed' => $completedSteps,
'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0,
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 결재 양식 모델
*
* @property int $id
* @property int $tenant_id
* @property string $name
* @property string $code
* @property string|null $category
* @property array $template
* @property bool $is_active
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class ApprovalForm extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_forms';
protected $casts = [
'template' => 'array',
'is_active' => 'boolean',
];
protected $fillable = [
'tenant_id',
'name',
'code',
'category',
'template',
'is_active',
'created_by',
'updated_by',
'deleted_by',
];
protected $attributes = [
'is_active' => true,
];
// =========================================================================
// 카테고리 상수
// =========================================================================
public const CATEGORY_REQUEST = 'request'; // 품의서
public const CATEGORY_EXPENSE = 'expense'; // 지출결의서
public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate'; // 지출 예상 내역서
public const CATEGORIES = [
self::CATEGORY_REQUEST,
self::CATEGORY_EXPENSE,
self::CATEGORY_EXPENSE_ESTIMATE,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 이 양식으로 생성된 결재 문서들
*/
public function approvals(): HasMany
{
return $this->hasMany(Approval::class, 'form_id');
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// 스코프
// =========================================================================
/**
* 활성 양식만
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* 특정 카테고리
*/
public function scopeCategory($query, string $category)
{
return $query->where('category', $category);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 카테고리 라벨
*/
public function getCategoryLabelAttribute(): string
{
return match ($this->category) {
self::CATEGORY_REQUEST => '품의서',
self::CATEGORY_EXPENSE => '지출결의서',
self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서',
default => $this->category ?? '',
};
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 결재선 템플릿 모델
*
* @property int $id
* @property int $tenant_id
* @property string $name
* @property array $steps
* @property bool $is_default
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class ApprovalLine extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_lines';
protected $casts = [
'steps' => 'array',
'is_default' => 'boolean',
];
protected $fillable = [
'tenant_id',
'name',
'steps',
'is_default',
'created_by',
'updated_by',
'deleted_by',
];
protected $attributes = [
'is_default' => false,
];
// =========================================================================
// 단계 유형 상수
// =========================================================================
public const STEP_TYPE_APPROVAL = 'approval'; // 결재
public const STEP_TYPE_AGREEMENT = 'agreement'; // 합의
public const STEP_TYPE_REFERENCE = 'reference'; // 참조
public const STEP_TYPES = [
self::STEP_TYPE_APPROVAL,
self::STEP_TYPE_AGREEMENT,
self::STEP_TYPE_REFERENCE,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// 스코프
// =========================================================================
/**
* 기본 결재선만
*/
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 결재선 단계 수
*/
public function getStepCountAttribute(): int
{
return count($this->steps ?? []);
}
/**
* 결재자 목록 (user_id만)
*/
public function getApproverIdsAttribute(): array
{
return collect($this->steps ?? [])
->pluck('user_id')
->filter()
->values()
->toArray();
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 결재 단계 모델
*
* @property int $id
* @property int $approval_id
* @property int $step_order
* @property string $step_type
* @property int $approver_id
* @property string $status
* @property string|null $comment
* @property \Carbon\Carbon|null $acted_at
* @property bool $is_read
* @property \Carbon\Carbon|null $read_at
*/
class ApprovalStep extends Model
{
protected $table = 'approval_steps';
protected $casts = [
'step_order' => 'integer',
'acted_at' => 'datetime',
'is_read' => 'boolean',
'read_at' => 'datetime',
];
protected $fillable = [
'approval_id',
'step_order',
'step_type',
'approver_id',
'status',
'comment',
'acted_at',
'is_read',
'read_at',
];
protected $attributes = [
'status' => 'pending',
'is_read' => false,
];
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_PENDING = 'pending'; // 대기
public const STATUS_APPROVED = 'approved'; // 승인
public const STATUS_REJECTED = 'rejected'; // 반려
public const STATUS_SKIPPED = 'skipped'; // 건너뜀
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_SKIPPED,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 결재 문서
*/
public function approval(): BelongsTo
{
return $this->belongsTo(Approval::class, 'approval_id');
}
/**
* 결재자/참조자
*/
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approver_id');
}
// =========================================================================
// 스코프
// =========================================================================
/**
* 대기 중
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 승인됨
*/
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
/**
* 특정 결재자
*/
public function scopeByApprover($query, int $userId)
{
return $query->where('approver_id', $userId);
}
/**
* 결재 단계만 (참조 제외)
*/
public function scopeApprovalOnly($query)
{
return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
}
/**
* 참조만
*/
public function scopeReferenceOnly($query)
{
return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 결재 가능 여부
*/
public function isActionable(): bool
{
return $this->status === self::STATUS_PENDING
&& in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
}
/**
* 참조인지 확인
*/
public function isReference(): bool
{
return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE;
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => '대기',
self::STATUS_APPROVED => '승인',
self::STATUS_REJECTED => '반려',
self::STATUS_SKIPPED => '건너뜀',
default => $this->status,
};
}
/**
* 단계 유형 라벨
*/
public function getStepTypeLabelAttribute(): string
{
return match ($this->step_type) {
ApprovalLine::STEP_TYPE_APPROVAL => '결재',
ApprovalLine::STEP_TYPE_AGREEMENT => '합의',
ApprovalLine::STEP_TYPE_REFERENCE => '참조',
default => $this->step_type,
};
}
}