feat: [approval] Phase 2 결재관리 고급 기능 구현

- 보류/해제: 현재 결재자가 문서를 보류하고 해제
- 전결: 이후 모든 결재를 건너뛰고 최종 승인
- 회수 강화: 회수 사유 입력, 첫 결재자 미처리 시에만 허용
- 복사 재기안: 완료/반려/회수 문서를 복사하여 새 draft 생성
- 참조 열람 추적: 미열람/열람 필터, mark-read API
- ApprovalDelegation 모델 생성 (Phase 3 위임 대결 준비)
- 뱃지 카운트에 reference_unread 추가
This commit is contained in:
김보곤
2026-02-27 23:41:49 +09:00
parent 12c9ad620a
commit 9b96a3cad1
10 changed files with 757 additions and 24 deletions

View File

@@ -65,7 +65,7 @@ public function references(Request $request): JsonResponse
{
$result = $this->service->getReferencesForMe(
auth()->id(),
$request->only(['search', 'date_from', 'date_to']),
$request->only(['search', 'date_from', 'date_to', 'is_read']),
(int) $request->get('per_page', 15)
);
@@ -234,10 +234,10 @@ public function reject(Request $request, int $id): JsonResponse
/**
* 회수
*/
public function cancel(int $id): JsonResponse
public function cancel(Request $request, int $id): JsonResponse
{
try {
$approval = $this->service->cancel($id);
$approval = $this->service->cancel($id, $request->get('recall_reason'));
return response()->json([
'success' => true,
@@ -252,6 +252,107 @@ public function cancel(int $id): JsonResponse
}
}
/**
* 보류
*/
public function hold(Request $request, int $id): JsonResponse
{
$request->validate([
'comment' => 'required|string|max:1000',
]);
try {
$approval = $this->service->hold($id, $request->get('comment'));
return response()->json([
'success' => true,
'message' => '보류되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 보류 해제
*/
public function releaseHold(int $id): JsonResponse
{
try {
$approval = $this->service->releaseHold($id);
return response()->json([
'success' => true,
'message' => '보류가 해제되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 전결
*/
public function preDecide(Request $request, int $id): JsonResponse
{
try {
$approval = $this->service->preDecide($id, $request->get('comment'));
return response()->json([
'success' => true,
'message' => '전결 처리되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 복사 재기안
*/
public function copyForRedraft(int $id): JsonResponse
{
try {
$approval = $this->service->copyForRedraft($id);
return response()->json([
'success' => true,
'message' => '문서가 복사되었습니다.',
'data' => $approval,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 참조 열람 추적
*/
public function markAsRead(int $id): JsonResponse
{
$this->service->markAsRead($id);
return response()->json([
'success' => true,
'message' => '열람 처리되었습니다.',
]);
}
// =========================================================================
// 유틸
// =========================================================================

View File

@@ -40,6 +40,8 @@ class Approval extends Model
'completed_at',
'current_step',
'attachments',
'recall_reason',
'parent_doc_id',
'created_by',
'updated_by',
'deleted_by',
@@ -65,12 +67,15 @@ class Approval extends Model
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_ON_HOLD = 'on_hold';
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_CANCELLED,
self::STATUS_ON_HOLD,
];
// =========================================================================
@@ -111,6 +116,16 @@ public function referenceSteps(): HasMany
->orderBy('step_order');
}
public function parentDocument(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_doc_id');
}
public function childDocuments(): HasMany
{
return $this->hasMany(self::class, 'parent_doc_id');
}
// =========================================================================
// 스코프
// =========================================================================
@@ -140,6 +155,11 @@ public function scopeRejected($query)
return $query->where('status', self::STATUS_REJECTED);
}
public function scopeOnHold($query)
{
return $query->where('status', self::STATUS_ON_HOLD);
}
public function scopeCompleted($query)
{
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
@@ -169,11 +189,26 @@ public function isActionable(): bool
return $this->status === self::STATUS_PENDING;
}
public function isCancellable(): bool
public function isHoldable(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isHoldReleasable(): bool
{
return $this->status === self::STATUS_ON_HOLD;
}
public function isCancellable(): bool
{
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]);
}
public function isCopyable(): bool
{
return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
}
public function isDeletable(): bool
{
return $this->status === self::STATUS_DRAFT;
@@ -187,6 +222,7 @@ public function getStatusLabelAttribute(): string
self::STATUS_APPROVED => '완료',
self::STATUS_REJECTED => '반려',
self::STATUS_CANCELLED => '회수',
self::STATUS_ON_HOLD => '보류',
default => $this->status,
};
}
@@ -199,6 +235,7 @@ public function getStatusColorAttribute(): string
self::STATUS_APPROVED => 'green',
self::STATUS_REJECTED => 'red',
self::STATUS_CANCELLED => 'yellow',
self::STATUS_ON_HOLD => 'amber',
default => 'gray',
};
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Approvals;
use App\Models\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ApprovalDelegation extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_delegations';
protected $casts = [
'form_ids' => 'array',
'start_date' => 'date',
'end_date' => 'date',
'notify_delegator' => 'boolean',
'is_active' => 'boolean',
];
protected $fillable = [
'tenant_id',
'delegator_id',
'delegate_id',
'start_date',
'end_date',
'form_ids',
'notify_delegator',
'is_active',
'reason',
'created_by',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function delegator(): BelongsTo
{
return $this->belongsTo(User::class, 'delegator_id');
}
public function delegate(): BelongsTo
{
return $this->belongsTo(User::class, 'delegate_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForDelegator($query, int $userId)
{
return $query->where('delegator_id', $userId);
}
public function scopeCurrentlyActive($query)
{
$today = now()->toDateString();
return $query->active()
->where('start_date', '<=', $today)
->where('end_date', '>=', $today);
}
}

View File

@@ -12,6 +12,7 @@ class ApprovalStep extends Model
protected $casts = [
'step_order' => 'integer',
'parallel_group' => 'integer',
'acted_at' => 'datetime',
'is_read' => 'boolean',
'read_at' => 'datetime',
@@ -21,11 +22,14 @@ class ApprovalStep extends Model
'approval_id',
'step_order',
'step_type',
'parallel_group',
'approver_id',
'acted_by',
'approver_name',
'approver_department',
'approver_position',
'status',
'approval_type',
'comment',
'acted_at',
'is_read',
@@ -49,11 +53,14 @@ class ApprovalStep extends Model
public const STATUS_SKIPPED = 'skipped';
public const STATUS_ON_HOLD = 'on_hold';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_SKIPPED,
self::STATUS_ON_HOLD,
];
// =========================================================================
@@ -70,6 +77,11 @@ public function approver(): BelongsTo
return $this->belongsTo(User::class, 'approver_id');
}
public function actedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'acted_by');
}
// =========================================================================
// 스코프
// =========================================================================
@@ -121,6 +133,7 @@ public function getStatusLabelAttribute(): string
self::STATUS_APPROVED => '승인',
self::STATUS_REJECTED => '반려',
self::STATUS_SKIPPED => '건너뜀',
self::STATUS_ON_HOLD => '보류',
default => $this->status,
};
}

View File

@@ -82,9 +82,12 @@ public function getCompletedByMe(int $userId, array $filters = [], int $perPage
public function getReferencesForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = Approval::with(['form', 'drafter', 'steps.approver'])
->whereHas('steps', function ($q) use ($userId) {
->whereHas('steps', function ($q) use ($userId, $filters) {
$q->where('approver_id', $userId)
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
if (isset($filters['is_read'])) {
$q->where('is_read', $filters['is_read'] === 'true' || $filters['is_read'] === '1');
}
});
$this->applyFilters($query, $filters);
@@ -317,11 +320,11 @@ public function reject(int $id, string $comment): Approval
}
/**
* 회수 (기안자만, pending → cancelled)
* 회수 (기안자만, pending/on_hold → cancelled)
*/
public function cancel(int $id): Approval
public function cancel(int $id, ?string $recallReason = null): Approval
{
return DB::transaction(function () use ($id) {
return DB::transaction(function () use ($id, $recallReason) {
$approval = Approval::with('steps')->findOrFail($id);
if (! $approval->isCancellable()) {
@@ -332,14 +335,140 @@ public function cancel(int $id): Approval
throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.');
}
// 모든 pending steps → skipped
// 첫 번째 결재자가 이미 처리했으면 회수 불가
$firstApproverStep = $approval->steps()
->approvalOnly()
->orderBy('step_order')
->first();
if ($firstApproverStep && $firstApproverStep->status !== ApprovalStep::STATUS_PENDING
&& $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) {
throw new \InvalidArgumentException('첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.');
}
// 모든 pending/on_hold steps → skipped
$approval->steps()
->where('status', ApprovalStep::STATUS_PENDING)
->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD])
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
$approval->update([
'status' => Approval::STATUS_CANCELLED,
'completed_at' => now(),
'recall_reason' => $recallReason,
'updated_by' => auth()->id(),
]);
return $approval->fresh(['form', 'drafter', 'steps.approver']);
});
}
/**
* 보류 (현재 결재자만, pending → on_hold)
*/
public function hold(int $id, string $comment): Approval
{
return DB::transaction(function () use ($id, $comment) {
$approval = Approval::with('steps')->findOrFail($id);
if (! $approval->isHoldable()) {
throw new \InvalidArgumentException('보류할 수 없는 상태입니다.');
}
$currentStep = $approval->getCurrentApproverStep();
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
}
if (empty($comment)) {
throw new \InvalidArgumentException('보류 사유를 입력해주세요.');
}
$currentStep->update([
'status' => ApprovalStep::STATUS_ON_HOLD,
'comment' => $comment,
'acted_at' => now(),
]);
$approval->update([
'status' => Approval::STATUS_ON_HOLD,
'updated_by' => auth()->id(),
]);
return $approval->fresh(['form', 'drafter', 'steps.approver']);
});
}
/**
* 보류 해제 (보류한 결재자만, on_hold → pending)
*/
public function releaseHold(int $id): Approval
{
return DB::transaction(function () use ($id) {
$approval = Approval::with('steps')->findOrFail($id);
if (! $approval->isHoldReleasable()) {
throw new \InvalidArgumentException('보류 해제할 수 없는 상태입니다.');
}
$holdStep = $approval->steps()
->where('status', ApprovalStep::STATUS_ON_HOLD)
->first();
if (! $holdStep || $holdStep->approver_id !== auth()->id()) {
throw new \InvalidArgumentException('보류한 결재자만 해제할 수 있습니다.');
}
$holdStep->update([
'status' => ApprovalStep::STATUS_PENDING,
'comment' => null,
'acted_at' => null,
]);
$approval->update([
'status' => Approval::STATUS_PENDING,
'updated_by' => auth()->id(),
]);
return $approval->fresh(['form', 'drafter', 'steps.approver']);
});
}
/**
* 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인)
*/
public function preDecide(int $id, ?string $comment = null): Approval
{
return DB::transaction(function () use ($id, $comment) {
$approval = Approval::with('steps')->findOrFail($id);
if (! $approval->isActionable()) {
throw new \InvalidArgumentException('전결할 수 없는 상태입니다.');
}
$currentStep = $approval->getCurrentApproverStep();
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
}
// 현재 step → approved + pre_decided
$currentStep->update([
'status' => ApprovalStep::STATUS_APPROVED,
'approval_type' => 'pre_decided',
'comment' => $comment,
'acted_at' => now(),
]);
// 이후 모든 pending approval/agreement steps → skipped
$approval->steps()
->where('step_order', '>', $currentStep->step_order)
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->where('status', ApprovalStep::STATUS_PENDING)
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
// 문서 최종 승인
$approval->update([
'status' => Approval::STATUS_APPROVED,
'completed_at' => now(),
'updated_by' => auth()->id(),
]);
@@ -347,6 +476,78 @@ public function cancel(int $id): Approval
});
}
/**
* 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성)
*/
public function copyForRedraft(int $id): Approval
{
return DB::transaction(function () use ($id) {
$original = Approval::with('steps')->findOrFail($id);
if (! $original->isCopyable()) {
throw new \InvalidArgumentException('복사할 수 없는 상태입니다.');
}
if ($original->drafter_id !== auth()->id()) {
throw new \InvalidArgumentException('기안자만 복사할 수 있습니다.');
}
$tenantId = session('selected_tenant_id');
// 새 문서 생성
$newApproval = Approval::create([
'tenant_id' => $tenantId,
'document_number' => $this->generateDocumentNumber($tenantId),
'form_id' => $original->form_id,
'line_id' => $original->line_id,
'title' => $original->title,
'content' => $original->content,
'body' => $original->body,
'status' => Approval::STATUS_DRAFT,
'is_urgent' => $original->is_urgent,
'drafter_id' => auth()->id(),
'department_id' => $original->department_id,
'current_step' => 0,
'parent_doc_id' => $original->id,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
// 결재선 복사 (모두 pending 상태로)
foreach ($original->steps as $step) {
ApprovalStep::create([
'approval_id' => $newApproval->id,
'step_order' => $step->step_order,
'step_type' => $step->step_type,
'approver_id' => $step->approver_id,
'approver_name' => $step->approver_name,
'approver_department' => $step->approver_department,
'approver_position' => $step->approver_position,
'status' => ApprovalStep::STATUS_PENDING,
]);
}
return $newApproval->load(['form', 'drafter', 'steps.approver']);
});
}
/**
* 참조 열람 추적
*/
public function markAsRead(int $approvalId): void
{
$userId = auth()->id();
ApprovalStep::where('approval_id', $approvalId)
->where('approver_id', $userId)
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
->where('is_read', false)
->update([
'is_read' => true,
'read_at' => now(),
]);
}
// =========================================================================
// 결재선
// =========================================================================
@@ -428,9 +629,15 @@ public function getBadgeCounts(int $userId): array
$draftCount = Approval::draft()->byDrafter($userId)->count();
$referenceUnreadCount = ApprovalStep::where('approver_id', $userId)
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
->where('is_read', false)
->count();
return [
'pending' => $pendingCount,
'draft' => $draftCount,
'reference_unread' => $referenceUnreadCount,
];
}