feat: [approval] 결재관리 시스템 MNG 스타일로 전면 개선
- 보류/보류해제 기능 추가 (hold, releaseHold) - 전결 기능 추가 (preDecide - 이후 결재 건너뛰고 최종 승인) - 복사 재기안 기능 추가 (copyForRedraft) - 반려 후 재상신 로직 (rejection_history 저장, resubmit_count 증가) - 결재자 스냅샷 저장 (approver_name, department, position) - 완료함 목록/현황 API 추가 (completed, completedSummary) - 뱃지 카운트 API 추가 (badgeCounts) - 완료함 일괄 읽음 처리 (markCompletedAsRead) - 위임 관리 CRUD API 추가 (delegations) - Leave 연동 (승인/반려/회수/삭제 시 휴가 상태 동기화) - ApprovalDelegation 모델 신규 생성 - STATUS_ON_HOLD 상수 추가 (Approval, ApprovalStep) - isEditable/isSubmittable 반려 상태 허용으로 확장 - isCancellable 보류 상태 포함 - 회수 시 첫 번째 결재자 처리 여부 검증 추가 - i18n 에러/메시지 키 추가
This commit is contained in:
@@ -155,11 +155,104 @@ public function reject(int $id, RejectRequest $request): JsonResponse
|
||||
* 결재 회수 (기안자만)
|
||||
* POST /v1/approvals/{id}/cancel
|
||||
*/
|
||||
public function cancel(int $id): JsonResponse
|
||||
public function cancel(int $id, Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->cancel($id, $request->input('recall_reason'));
|
||||
}, __('message.approval.cancelled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 (현재 결재자만)
|
||||
* POST /v1/approvals/{id}/hold
|
||||
*/
|
||||
public function hold(int $id, Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$comment = $request->input('comment');
|
||||
if (empty($comment)) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.approval.comment_required'));
|
||||
}
|
||||
|
||||
return $this->service->hold($id, $comment);
|
||||
}, __('message.approval.held'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 해제 (보류한 결재자만)
|
||||
* POST /v1/approvals/{id}/release-hold
|
||||
*/
|
||||
public function releaseHold(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->cancel($id);
|
||||
}, __('message.approval.cancelled'));
|
||||
return $this->service->releaseHold($id);
|
||||
}, __('message.approval.hold_released'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인)
|
||||
* POST /v1/approvals/{id}/pre-decide
|
||||
*/
|
||||
public function preDecide(int $id, Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->preDecide($id, $request->input('comment'));
|
||||
}, __('message.approval.pre_decided'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 재기안
|
||||
* POST /v1/approvals/{id}/copy
|
||||
*/
|
||||
public function copyForRedraft(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->copyForRedraft($id);
|
||||
}, __('message.approval.copied'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 목록
|
||||
* GET /v1/approvals/completed
|
||||
*/
|
||||
public function completed(IndexRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->completed($request->validated());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 현황 카드
|
||||
* GET /v1/approvals/completed/summary
|
||||
*/
|
||||
public function completedSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->completedSummary();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 미처리 건수 (뱃지용)
|
||||
* GET /v1/approvals/badge-counts
|
||||
*/
|
||||
public function badgeCounts(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->badgeCounts();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 미읽음 일괄 읽음 처리
|
||||
* POST /v1/approvals/completed/mark-read
|
||||
*/
|
||||
public function markCompletedAsRead(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->markCompletedAsRead();
|
||||
}, __('message.approval.marked_read'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,4 +276,52 @@ public function markUnread(int $id): JsonResponse
|
||||
return $this->service->markUnread($id);
|
||||
}, __('message.approval.marked_unread'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 위임 관리
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 위임 목록
|
||||
* GET /v1/approvals/delegations
|
||||
*/
|
||||
public function delegationIndex(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->delegationIndex($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 위임 생성
|
||||
* POST /v1/approvals/delegations
|
||||
*/
|
||||
public function delegationStore(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->delegationStore($request->all());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 위임 수정
|
||||
* PATCH /v1/approvals/delegations/{id}
|
||||
*/
|
||||
public function delegationUpdate(int $id, Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->delegationUpdate($id, $request->all());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 위임 삭제
|
||||
* DELETE /v1/approvals/delegations/{id}
|
||||
*/
|
||||
public function delegationDestroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->delegationDestroy($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,22 +39,35 @@ class Approval extends Model
|
||||
protected $casts = [
|
||||
'content' => 'array',
|
||||
'attachments' => 'array',
|
||||
'rejection_history' => 'array',
|
||||
'is_urgent' => 'boolean',
|
||||
'drafted_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'drafter_read_at' => 'datetime',
|
||||
'current_step' => 'integer',
|
||||
'resubmit_count' => 'integer',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_number',
|
||||
'form_id',
|
||||
'line_id',
|
||||
'title',
|
||||
'content',
|
||||
'body',
|
||||
'status',
|
||||
'is_urgent',
|
||||
'drafter_id',
|
||||
'department_id',
|
||||
'drafted_at',
|
||||
'completed_at',
|
||||
'drafter_read_at',
|
||||
'current_step',
|
||||
'resubmit_count',
|
||||
'rejection_history',
|
||||
'recall_reason',
|
||||
'parent_doc_id',
|
||||
'attachments',
|
||||
'linkable_type',
|
||||
'linkable_id',
|
||||
@@ -66,6 +79,7 @@ class Approval extends Model
|
||||
protected $attributes = [
|
||||
'status' => 'draft',
|
||||
'current_step' => 0,
|
||||
'resubmit_count' => 0,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
@@ -82,12 +96,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,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
@@ -102,6 +119,14 @@ public function form(): BelongsTo
|
||||
return $this->belongsTo(ApprovalForm::class, 'form_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 템플릿
|
||||
*/
|
||||
public function line(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApprovalLine::class, 'line_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안자
|
||||
*/
|
||||
@@ -110,6 +135,30 @@ public function drafter(): BelongsTo
|
||||
return $this->belongsTo(User::class, 'drafter_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안 부서
|
||||
*/
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class, 'department_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 문서 (복사 재기안 시)
|
||||
*/
|
||||
public function parentDocument(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_doc_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 문서들 (이 문서에서 복사된 문서들)
|
||||
*/
|
||||
public function childDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_doc_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 단계들
|
||||
*/
|
||||
@@ -207,11 +256,19 @@ public function scopeRejected($query)
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료됨 (승인 또는 반려)
|
||||
* 완료됨 (승인, 반려, 회수)
|
||||
*/
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED]);
|
||||
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 상태
|
||||
*/
|
||||
public function scopeOnHold($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ON_HOLD);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,19 +284,19 @@ public function scopeByDrafter($query, int $userId)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 (임시저장 상태만)
|
||||
* 수정 가능 여부 (임시저장 또는 반려 상태)
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상신 가능 여부
|
||||
* 상신 가능 여부 (임시저장 또는 반려 상태 = 재상신)
|
||||
*/
|
||||
public function isSubmittable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,21 +308,60 @@ public function isActionable(): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* 회수 가능 여부 (기안자만, 진행중 상태)
|
||||
* 회수 가능 여부 (기안자만, 진행중 또는 보류 상태)
|
||||
*/
|
||||
public function isCancellable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 가능 여부 (진행중 상태만)
|
||||
*/
|
||||
public function isHoldable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 가능 여부 (임시저장만)
|
||||
* 보류 해제 가능 여부 (보류 상태만)
|
||||
*/
|
||||
public function isHoldReleasable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ON_HOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 재기안 가능 여부 (완료/반려/회수 상태)
|
||||
*/
|
||||
public function isCopyable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 가능 여부
|
||||
* 일반 사용자: 임시저장만
|
||||
* 관리자: 별도 isDeletableBy 사용
|
||||
*/
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 기준 삭제 가능 여부
|
||||
* 기안자: 임시저장/반려만 삭제 가능
|
||||
*/
|
||||
public function isDeletableBy(int $userId): bool
|
||||
{
|
||||
if ($this->drafter_id !== $userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
@@ -277,10 +373,27 @@ public function getStatusLabelAttribute(): string
|
||||
self::STATUS_APPROVED => '완료',
|
||||
self::STATUS_REJECTED => '반려',
|
||||
self::STATUS_CANCELLED => '회수',
|
||||
self::STATUS_ON_HOLD => '보류',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 색상 (UI 배지용)
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => 'gray',
|
||||
self::STATUS_PENDING => 'blue',
|
||||
self::STATUS_APPROVED => 'green',
|
||||
self::STATUS_REJECTED => 'red',
|
||||
self::STATUS_CANCELLED => 'yellow',
|
||||
self::STATUS_ON_HOLD => 'orange',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 결재자 확인
|
||||
*/
|
||||
|
||||
80
app/Models/Tenants/ApprovalDelegation.php
Normal file
80
app/Models/Tenants/ApprovalDelegation.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ class ApprovalForm extends Model
|
||||
'code',
|
||||
'category',
|
||||
'template',
|
||||
'body_template',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
class ApprovalStep extends Model
|
||||
{
|
||||
use Auditable;
|
||||
|
||||
protected $table = 'approval_steps';
|
||||
|
||||
protected $casts = [
|
||||
'step_order' => 'integer',
|
||||
'parallel_group' => 'integer',
|
||||
'acted_at' => 'datetime',
|
||||
'is_read' => 'boolean',
|
||||
'read_at' => 'datetime',
|
||||
@@ -41,8 +43,14 @@ class ApprovalStep extends Model
|
||||
'status',
|
||||
'comment',
|
||||
'acted_at',
|
||||
'acted_by',
|
||||
'is_read',
|
||||
'read_at',
|
||||
'parallel_group',
|
||||
'approval_type',
|
||||
'approver_name',
|
||||
'approver_department',
|
||||
'approver_position',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
@@ -62,11 +70,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,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
@@ -89,6 +100,14 @@ public function approver(): BelongsTo
|
||||
return $this->belongsTo(User::class, 'approver_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 처리자 (위임 결재 시 대리자)
|
||||
*/
|
||||
public function actedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acted_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
@@ -164,6 +183,7 @@ public function getStatusLabelAttribute(): string
|
||||
self::STATUS_APPROVED => '승인',
|
||||
self::STATUS_REJECTED => '반려',
|
||||
self::STATUS_SKIPPED => '건너뜀',
|
||||
self::STATUS_ON_HOLD => '보류',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Tenants\ApprovalDelegation;
|
||||
use App\Models\Tenants\ApprovalForm;
|
||||
use App\Models\Tenants\ApprovalLine;
|
||||
use App\Models\Tenants\ApprovalStep;
|
||||
use App\Models\Tenants\Leave;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -626,10 +629,14 @@ public function store(array $data): Approval
|
||||
'tenant_id' => $tenantId,
|
||||
'document_number' => $documentNumber,
|
||||
'form_id' => $form->id,
|
||||
'line_id' => $data['line_id'] ?? null,
|
||||
'title' => $data['title'],
|
||||
'content' => $data['content'],
|
||||
'body' => $data['body'] ?? null,
|
||||
'status' => $status,
|
||||
'is_urgent' => $data['is_urgent'] ?? false,
|
||||
'drafter_id' => $userId,
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'drafted_at' => $status === Approval::STATUS_PENDING ? now() : null,
|
||||
'attachments' => $data['attachments'] ?? null,
|
||||
'created_by' => $userId,
|
||||
@@ -687,8 +694,12 @@ public function update(int $id, array $data): Approval
|
||||
|
||||
$approval->fill([
|
||||
'form_id' => $formId,
|
||||
'line_id' => $data['line_id'] ?? $approval->line_id,
|
||||
'title' => $data['title'] ?? $approval->title,
|
||||
'content' => $data['content'] ?? $approval->content,
|
||||
'body' => $data['body'] ?? $approval->body,
|
||||
'is_urgent' => $data['is_urgent'] ?? $approval->is_urgent,
|
||||
'department_id' => $data['department_id'] ?? $approval->department_id,
|
||||
'attachments' => $data['attachments'] ?? $approval->attachments,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
@@ -740,7 +751,7 @@ public function destroy(int $id): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 상신
|
||||
* 결재 상신 (draft → pending, rejected → pending 재상신 포함)
|
||||
*/
|
||||
public function submit(int $id, array $data): Approval
|
||||
{
|
||||
@@ -750,6 +761,7 @@ public function submit(int $id, array $data): Approval
|
||||
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
||||
$approval = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('steps')
|
||||
->findOrFail($id);
|
||||
|
||||
if (! $approval->isSubmittable()) {
|
||||
@@ -767,20 +779,48 @@ public function submit(int $id, array $data): Approval
|
||||
}
|
||||
}
|
||||
|
||||
// 먼저 approval을 pending으로 변경 (Observer가 올바른 상태로 트리거되도록)
|
||||
// 반려 후 재상신 처리
|
||||
$isResubmit = $approval->status === Approval::STATUS_REJECTED;
|
||||
if ($isResubmit) {
|
||||
// 반려 이력 저장
|
||||
$rejectedStep = $approval->steps
|
||||
->firstWhere('status', ApprovalStep::STATUS_REJECTED);
|
||||
if ($rejectedStep) {
|
||||
$history = $approval->rejection_history ?? [];
|
||||
$history[] = [
|
||||
'round' => $approval->resubmit_count + 1,
|
||||
'approver_name' => $rejectedStep->approver_name ?? '',
|
||||
'approver_position' => $rejectedStep->approver_position ?? '',
|
||||
'comment' => $rejectedStep->comment,
|
||||
'rejected_at' => $rejectedStep->acted_at?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
$approval->rejection_history = $history;
|
||||
}
|
||||
|
||||
// 기존 steps를 모두 pending으로 초기화
|
||||
$approval->steps()->update([
|
||||
'status' => ApprovalStep::STATUS_PENDING,
|
||||
'comment' => null,
|
||||
'acted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// approval을 pending으로 변경
|
||||
$approval->status = Approval::STATUS_PENDING;
|
||||
$approval->drafted_at = now();
|
||||
$approval->current_step = 1;
|
||||
$approval->resubmit_count = $isResubmit
|
||||
? ($approval->resubmit_count ?? 0) + 1
|
||||
: ($approval->resubmit_count ?? 0);
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// steps가 있으면 새로 생성 (approval이 pending 상태일 때 생성해야 알림 발송)
|
||||
// steps가 있으면 새로 생성
|
||||
if (! empty($data['steps'])) {
|
||||
// 기존 결재선 삭제 후 새로 생성
|
||||
$approval->steps()->delete();
|
||||
$this->createApprovalSteps($approval, $data['steps']);
|
||||
} else {
|
||||
// 기존 결재선 사용 시, Observer가 트리거되지 않으므로 수동으로 알림 발송
|
||||
// 기존 결재선 사용 시 알림 발송
|
||||
$firstPendingStep = $approval->steps()
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
@@ -850,6 +890,7 @@ public function approve(int $id, ?string $comment = null): Approval
|
||||
// 모든 결재 완료
|
||||
$approval->status = Approval::STATUS_APPROVED;
|
||||
$approval->completed_at = now();
|
||||
$approval->drafter_read_at = null;
|
||||
}
|
||||
|
||||
$approval->current_step = $myStep->step_order + 1;
|
||||
@@ -859,6 +900,11 @@ public function approve(int $id, ?string $comment = null): Approval
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
// Leave 연동 (승인 완료 시)
|
||||
if ($approval->status === Approval::STATUS_APPROVED) {
|
||||
$this->handleApprovalCompleted($approval);
|
||||
}
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
@@ -909,12 +955,16 @@ public function reject(int $id, string $comment): Approval
|
||||
// 문서 반려 상태로 변경
|
||||
$approval->status = Approval::STATUS_REJECTED;
|
||||
$approval->completed_at = now();
|
||||
$approval->drafter_read_at = null;
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
// Leave 연동 (반려 시)
|
||||
$this->handleApprovalRejected($approval, $comment);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
@@ -928,14 +978,14 @@ public function reject(int $id, string $comment): Approval
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 회수 (기안자만)
|
||||
* 결재 회수 (기안자만, pending/on_hold → cancelled)
|
||||
*/
|
||||
public function cancel(int $id): Approval
|
||||
public function cancel(int $id, ?string $recallReason = null): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
||||
return DB::transaction(function () use ($id, $recallReason, $tenantId, $userId) {
|
||||
$approval = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
@@ -949,24 +999,407 @@ public function cancel(int $id): Approval
|
||||
throw new BadRequestHttpException(__('error.approval.only_drafter_can_cancel'));
|
||||
}
|
||||
|
||||
// 첫 번째 결재자가 이미 처리했으면 회수 불가
|
||||
$firstApproverStep = $approval->steps()
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->orderBy('step_order')
|
||||
->first();
|
||||
|
||||
if ($firstApproverStep
|
||||
&& $firstApproverStep->status !== ApprovalStep::STATUS_PENDING
|
||||
&& $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) {
|
||||
throw new BadRequestHttpException(__('error.approval.first_approver_already_acted'));
|
||||
}
|
||||
|
||||
// 모든 pending/on_hold steps → skipped
|
||||
$approval->steps()
|
||||
->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD])
|
||||
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
|
||||
|
||||
$approval->status = Approval::STATUS_CANCELLED;
|
||||
$approval->completed_at = now();
|
||||
$approval->recall_reason = $recallReason;
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화 (steps 삭제 전에 실행)
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
// 결재 단계들 삭제
|
||||
$approval->steps()->delete();
|
||||
// Leave 연동 (회수 시)
|
||||
$this->handleApprovalCancelled($approval);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
'steps.approver:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 (현재 결재자만, pending → on_hold)
|
||||
*/
|
||||
public function hold(int $id, string $comment): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $comment, $tenantId, $userId) {
|
||||
$approval = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('steps')
|
||||
->findOrFail($id);
|
||||
|
||||
if (! $approval->isHoldable()) {
|
||||
throw new BadRequestHttpException(__('error.approval.not_holdable'));
|
||||
}
|
||||
|
||||
$currentStep = $approval->getCurrentApproverStep();
|
||||
if (! $currentStep || $currentStep->approver_id !== $userId) {
|
||||
throw new BadRequestHttpException(__('error.approval.not_your_turn'));
|
||||
}
|
||||
|
||||
$currentStep->status = ApprovalStep::STATUS_ON_HOLD;
|
||||
$currentStep->comment = $comment;
|
||||
$currentStep->acted_at = now();
|
||||
$currentStep->save();
|
||||
|
||||
$approval->status = Approval::STATUS_ON_HOLD;
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
'steps.approver:id,name',
|
||||
'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
||||
'steps.approver.tenantProfile.department:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 해제 (보류한 결재자만, on_hold → pending)
|
||||
*/
|
||||
public function releaseHold(int $id): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
||||
$approval = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('steps')
|
||||
->findOrFail($id);
|
||||
|
||||
if (! $approval->isHoldReleasable()) {
|
||||
throw new BadRequestHttpException(__('error.approval.not_hold_releasable'));
|
||||
}
|
||||
|
||||
$holdStep = $approval->steps()
|
||||
->where('status', ApprovalStep::STATUS_ON_HOLD)
|
||||
->first();
|
||||
|
||||
if (! $holdStep || $holdStep->approver_id !== $userId) {
|
||||
throw new BadRequestHttpException(__('error.approval.only_holder_can_release'));
|
||||
}
|
||||
|
||||
$holdStep->status = ApprovalStep::STATUS_PENDING;
|
||||
$holdStep->comment = null;
|
||||
$holdStep->acted_at = null;
|
||||
$holdStep->save();
|
||||
|
||||
$approval->status = Approval::STATUS_PENDING;
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
'steps.approver:id,name',
|
||||
'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
||||
'steps.approver.tenantProfile.department:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인)
|
||||
*/
|
||||
public function preDecide(int $id, ?string $comment = null): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $comment, $tenantId, $userId) {
|
||||
$approval = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('steps')
|
||||
->findOrFail($id);
|
||||
|
||||
if (! $approval->isActionable()) {
|
||||
throw new BadRequestHttpException(__('error.approval.not_actionable'));
|
||||
}
|
||||
|
||||
$currentStep = $approval->getCurrentApproverStep();
|
||||
if (! $currentStep || $currentStep->approver_id !== $userId) {
|
||||
throw new BadRequestHttpException(__('error.approval.not_your_turn'));
|
||||
}
|
||||
|
||||
// 현재 step → approved + pre_decided
|
||||
$currentStep->status = ApprovalStep::STATUS_APPROVED;
|
||||
$currentStep->approval_type = 'pre_decided';
|
||||
$currentStep->comment = $comment;
|
||||
$currentStep->acted_at = now();
|
||||
$currentStep->save();
|
||||
|
||||
// 이후 모든 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->status = Approval::STATUS_APPROVED;
|
||||
$approval->completed_at = now();
|
||||
$approval->drafter_read_at = null;
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
// Leave 연동 (승인 완료)
|
||||
$this->handleApprovalCompleted($approval);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
'drafter.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
||||
'drafter.tenantProfile.department:id,name',
|
||||
'steps.approver:id,name',
|
||||
'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
||||
'steps.approver.tenantProfile.department:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성)
|
||||
*/
|
||||
public function copyForRedraft(int $id): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
||||
$original = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('steps')
|
||||
->findOrFail($id);
|
||||
|
||||
if (! $original->isCopyable()) {
|
||||
throw new BadRequestHttpException(__('error.approval.not_copyable'));
|
||||
}
|
||||
|
||||
if ($original->drafter_id !== $userId) {
|
||||
throw new BadRequestHttpException(__('error.approval.only_drafter_can_copy'));
|
||||
}
|
||||
|
||||
$documentNumber = $this->generateDocumentNumber($tenantId);
|
||||
|
||||
$newApproval = Approval::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_number' => $documentNumber,
|
||||
'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' => $userId,
|
||||
'department_id' => $original->department_id,
|
||||
'current_step' => 0,
|
||||
'parent_doc_id' => $original->id,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 결재선 복사 (모두 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->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
'steps.approver:id,name',
|
||||
'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
||||
'steps.approver.tenantProfile.department:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 - 내가 기안한 완료 문서 + 내가 결재 처리한 문서
|
||||
*/
|
||||
public function completed(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$query = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where(function ($q) use ($userId) {
|
||||
$q->where(function ($sub) use ($userId) {
|
||||
$sub->where('drafter_id', $userId)
|
||||
->whereIn('status', [
|
||||
Approval::STATUS_APPROVED,
|
||||
Approval::STATUS_REJECTED,
|
||||
Approval::STATUS_CANCELLED,
|
||||
]);
|
||||
})
|
||||
->orWhereHas('steps', function ($sub) use ($userId) {
|
||||
$sub->where('approver_id', $userId)
|
||||
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]);
|
||||
});
|
||||
})
|
||||
->with([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
'drafter.tenantProfile:id,user_id,position_key,department_id',
|
||||
'drafter.tenantProfile.department:id,name',
|
||||
'steps.approver:id,name',
|
||||
]);
|
||||
|
||||
if (! empty($params['search'])) {
|
||||
$query->where(function ($q) use ($params) {
|
||||
$q->where('title', 'like', "%{$params['search']}%")
|
||||
->orWhere('document_number', 'like', "%{$params['search']}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
$sortBy = $params['sort_by'] ?? 'updated_at';
|
||||
$sortDir = $params['sort_dir'] ?? 'desc';
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
$perPage = $params['per_page'] ?? 20;
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 현황 카드
|
||||
*/
|
||||
public function completedSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$myCompleted = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('drafter_id', $userId)
|
||||
->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED, Approval::STATUS_CANCELLED])
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
$unreadCount = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('drafter_id', $userId)
|
||||
->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED])
|
||||
->whereNull('drafter_read_at')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'approved' => $myCompleted[Approval::STATUS_APPROVED] ?? 0,
|
||||
'rejected' => $myCompleted[Approval::STATUS_REJECTED] ?? 0,
|
||||
'cancelled' => $myCompleted[Approval::STATUS_CANCELLED] ?? 0,
|
||||
'unread' => $unreadCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 미처리 건수 (뱃지용)
|
||||
*/
|
||||
public function badgeCounts(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$pendingCount = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Approval::STATUS_PENDING)
|
||||
->whereHas('steps', function ($q) use ($userId) {
|
||||
$q->where('approver_id', $userId)
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->whereRaw('step_order = (SELECT MIN(s2.step_order) FROM approval_steps s2 WHERE s2.approval_id = approval_steps.approval_id AND s2.status = ?)', [ApprovalStep::STATUS_PENDING]);
|
||||
})
|
||||
->count();
|
||||
|
||||
$draftCount = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Approval::STATUS_PENDING)
|
||||
->where('drafter_id', $userId)
|
||||
->count();
|
||||
|
||||
$referenceUnreadCount = ApprovalStep::query()
|
||||
->where('approver_id', $userId)
|
||||
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
||||
->where('is_read', false)
|
||||
->whereHas('approval', function ($q) use ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId);
|
||||
})
|
||||
->count();
|
||||
|
||||
$completedUnreadCount = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('drafter_id', $userId)
|
||||
->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED])
|
||||
->whereNull('drafter_read_at')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'pending' => $pendingCount,
|
||||
'draft' => $draftCount,
|
||||
'reference_unread' => $referenceUnreadCount,
|
||||
'completed_unread' => $completedUnreadCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 미읽음 일괄 읽음 처리
|
||||
*/
|
||||
public function markCompletedAsRead(): int
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('drafter_id', $userId)
|
||||
->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED])
|
||||
->whereNull('drafter_read_at')
|
||||
->update(['drafter_read_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval → Document 브릿지 동기화
|
||||
* 결재 승인/반려/회수 시 연결된 Document의 상태와 결재란을 동기화
|
||||
@@ -1118,12 +1551,13 @@ private function generateDocumentNumber(int $tenantId): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 단계 생성
|
||||
* 결재 단계 생성 + 결재자 스냅샷 저장
|
||||
* 프론트엔드 호환성: step_type/approver_id 또는 type/user_id 지원
|
||||
* 중복 결재자 자동 제거
|
||||
*/
|
||||
private function createApprovalSteps(Approval $approval, array $steps): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$order = 1;
|
||||
$processedApprovers = []; // 중복 체크용
|
||||
|
||||
@@ -1142,14 +1576,248 @@ private function createApprovalSteps(Approval $approval, array $steps): void
|
||||
}
|
||||
$processedApprovers[] = $approverId;
|
||||
|
||||
// 결재자 스냅샷 (이름/부서/직위)
|
||||
$approverName = $step['approver_name'] ?? '';
|
||||
$approverDepartment = $step['approver_department'] ?? null;
|
||||
$approverPosition = $step['approver_position'] ?? null;
|
||||
|
||||
// 스냅샷이 비어있으면 DB에서 조회
|
||||
if (empty($approverName)) {
|
||||
$user = User::find($approverId);
|
||||
if ($user) {
|
||||
$approverName = $user->name;
|
||||
$profile = $user->tenantProfile ?? $user->tenantProfiles()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
if ($profile) {
|
||||
$approverDepartment = $approverDepartment ?: ($profile->department?->name ?? null);
|
||||
$approverPosition = $approverPosition ?: ($profile->position_label ?? $profile->job_title_label ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApprovalStep::create([
|
||||
'approval_id' => $approval->id,
|
||||
'step_order' => $stepOrder,
|
||||
'step_type' => $stepType,
|
||||
'approver_id' => $approverId,
|
||||
'approver_name' => $approverName,
|
||||
'approver_department' => $approverDepartment,
|
||||
'approver_position' => $approverPosition,
|
||||
'status' => ApprovalStep::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Leave 연동 (휴가/근태신청/사유서)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 휴가/근태신청/사유서 관련 결재 양식인지 확인
|
||||
*/
|
||||
private function isLeaveRelatedForm(?string $code): bool
|
||||
{
|
||||
return in_array($code, ['leave', 'attendance_request', 'reason_report']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 최종 승인 시 연동 처리
|
||||
*/
|
||||
private function handleApprovalCompleted(Approval $approval): void
|
||||
{
|
||||
$approval->loadMissing('form');
|
||||
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = Leave::where('approval_id', $approval->id)->first();
|
||||
|
||||
// 기안함에서 직접 올린 경우: Leave 레코드 자동 생성
|
||||
if (! $leave && ! empty($approval->content['leave_type'])) {
|
||||
$leave = $this->createLeaveFromApproval($approval);
|
||||
}
|
||||
|
||||
if ($leave && $leave->status === 'pending') {
|
||||
$leave->update([
|
||||
'status' => 'approved',
|
||||
'updated_by' => $approval->updated_by,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려 시 연동 처리
|
||||
*/
|
||||
private function handleApprovalRejected(Approval $approval, string $comment): void
|
||||
{
|
||||
$approval->loadMissing('form');
|
||||
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = Leave::where('approval_id', $approval->id)->first();
|
||||
if ($leave && $leave->status === 'pending') {
|
||||
$leave->update([
|
||||
'status' => 'rejected',
|
||||
'updated_by' => $approval->updated_by,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 회수 시 연동 처리
|
||||
*/
|
||||
private function handleApprovalCancelled(Approval $approval): void
|
||||
{
|
||||
$approval->loadMissing('form');
|
||||
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = Leave::where('approval_id', $approval->id)->first();
|
||||
if ($leave && in_array($leave->status, ['pending', 'approved'])) {
|
||||
$leave->update([
|
||||
'status' => 'cancelled',
|
||||
'updated_by' => $approval->updated_by,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 삭제 시 연동 처리
|
||||
*/
|
||||
private function handleApprovalDeleted(Approval $approval): void
|
||||
{
|
||||
$approval->loadMissing('form');
|
||||
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = Leave::where('approval_id', $approval->id)->first();
|
||||
if ($leave && in_array($leave->status, ['pending', 'approved'])) {
|
||||
$leave->update([
|
||||
'status' => 'cancelled',
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안함에서 직접 올린 결재 → Leave 레코드 자동 생성
|
||||
*/
|
||||
private function createLeaveFromApproval(Approval $approval): Leave
|
||||
{
|
||||
$content = $approval->content;
|
||||
|
||||
return Leave::create([
|
||||
'tenant_id' => $approval->tenant_id,
|
||||
'user_id' => $content['user_id'] ?? $approval->drafter_id,
|
||||
'leave_type' => $content['leave_type'],
|
||||
'start_date' => $content['start_date'],
|
||||
'end_date' => $content['end_date'],
|
||||
'days' => $content['days'] ?? 0,
|
||||
'reason' => $content['reason'] ?? null,
|
||||
'status' => 'pending',
|
||||
'approval_id' => $approval->id,
|
||||
'created_by' => $approval->drafter_id,
|
||||
'updated_by' => $approval->drafter_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 위임 관리
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 위임 목록
|
||||
*/
|
||||
public function delegationIndex(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = ApprovalDelegation::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['delegator:id,name', 'delegate:id,name']);
|
||||
|
||||
if (! empty($params['delegator_id'])) {
|
||||
$query->where('delegator_id', $params['delegator_id']);
|
||||
}
|
||||
|
||||
if (isset($params['is_active'])) {
|
||||
$query->where('is_active', $params['is_active']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('created_at');
|
||||
$perPage = $params['per_page'] ?? 20;
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위임 생성
|
||||
*/
|
||||
public function delegationStore(array $data): ApprovalDelegation
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return ApprovalDelegation::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'delegator_id' => $data['delegator_id'],
|
||||
'delegate_id' => $data['delegate_id'],
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'form_ids' => $data['form_ids'] ?? null,
|
||||
'notify_delegator' => $data['notify_delegator'] ?? true,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위임 수정
|
||||
*/
|
||||
public function delegationUpdate(int $id, array $data): ApprovalDelegation
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$delegation = ApprovalDelegation::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
$delegation->fill([
|
||||
'delegate_id' => $data['delegate_id'] ?? $delegation->delegate_id,
|
||||
'start_date' => $data['start_date'] ?? $delegation->start_date,
|
||||
'end_date' => $data['end_date'] ?? $delegation->end_date,
|
||||
'form_ids' => array_key_exists('form_ids', $data) ? $data['form_ids'] : $delegation->form_ids,
|
||||
'notify_delegator' => $data['notify_delegator'] ?? $delegation->notify_delegator,
|
||||
'is_active' => $data['is_active'] ?? $delegation->is_active,
|
||||
'reason' => $data['reason'] ?? $delegation->reason,
|
||||
]);
|
||||
|
||||
$delegation->save();
|
||||
|
||||
return $delegation->fresh(['delegator:id,name', 'delegate:id,name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위임 삭제
|
||||
*/
|
||||
public function delegationDestroy(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$delegation = ApprovalDelegation::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
return $delegation->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,15 +232,22 @@
|
||||
'form_code_exists' => '중복된 양식 코드입니다.',
|
||||
'form_in_use' => '사용 중인 양식은 삭제할 수 없습니다.',
|
||||
'line_not_found' => '결재선을 찾을 수 없습니다.',
|
||||
'not_editable' => '임시저장 상태의 문서만 수정할 수 있습니다.',
|
||||
'not_deletable' => '임시저장 상태의 문서만 삭제할 수 있습니다.',
|
||||
'not_submittable' => '임시저장 상태의 문서만 상신할 수 있습니다.',
|
||||
'not_editable' => '수정할 수 없는 상태입니다.',
|
||||
'not_deletable' => '삭제할 수 없는 상태입니다.',
|
||||
'not_submittable' => '상신할 수 없는 상태입니다.',
|
||||
'not_actionable' => '진행중인 문서에서만 결재 가능합니다.',
|
||||
'not_cancellable' => '진행중인 문서만 회수할 수 있습니다.',
|
||||
'not_cancellable' => '회수할 수 없는 상태입니다.',
|
||||
'not_holdable' => '보류할 수 없는 상태입니다.',
|
||||
'not_hold_releasable' => '보류 해제할 수 없는 상태입니다.',
|
||||
'not_copyable' => '복사할 수 없는 상태입니다.',
|
||||
'not_your_turn' => '현재 결재 순서가 아닙니다.',
|
||||
'only_drafter_can_cancel' => '기안자만 회수할 수 있습니다.',
|
||||
'only_drafter_can_copy' => '기안자만 복사할 수 있습니다.',
|
||||
'only_holder_can_release' => '보류한 결재자만 해제할 수 있습니다.',
|
||||
'first_approver_already_acted' => '첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.',
|
||||
'steps_required' => '결재선은 필수입니다.',
|
||||
'reject_comment_required' => '반려 사유는 필수입니다.',
|
||||
'comment_required' => '사유를 입력해주세요.',
|
||||
'not_referee' => '참조자가 아닙니다.',
|
||||
],
|
||||
|
||||
|
||||
@@ -244,6 +244,10 @@
|
||||
'approved' => '결재가 승인되었습니다.',
|
||||
'rejected' => '결재가 반려되었습니다.',
|
||||
'cancelled' => '결재가 회수되었습니다.',
|
||||
'held' => '결재가 보류되었습니다.',
|
||||
'hold_released' => '보류가 해제되었습니다.',
|
||||
'pre_decided' => '전결 처리되었습니다.',
|
||||
'copied' => '문서가 복사되었습니다.',
|
||||
'marked_read' => '열람 처리되었습니다.',
|
||||
'marked_unread' => '미열람 처리되었습니다.',
|
||||
'form_created' => '결재 양식이 등록되었습니다.',
|
||||
|
||||
@@ -139,6 +139,17 @@
|
||||
Route::get('/inbox/summary', [ApprovalController::class, 'inboxSummary'])->name('v1.approvals.inbox.summary');
|
||||
// 참조함
|
||||
Route::get('/reference', [ApprovalController::class, 'reference'])->name('v1.approvals.reference');
|
||||
// 완료함
|
||||
Route::get('/completed', [ApprovalController::class, 'completed'])->name('v1.approvals.completed');
|
||||
Route::get('/completed/summary', [ApprovalController::class, 'completedSummary'])->name('v1.approvals.completed.summary');
|
||||
Route::post('/completed/mark-read', [ApprovalController::class, 'markCompletedAsRead'])->name('v1.approvals.completed.mark-read');
|
||||
// 뱃지 카운트
|
||||
Route::get('/badge-counts', [ApprovalController::class, 'badgeCounts'])->name('v1.approvals.badge-counts');
|
||||
// 위임 관리
|
||||
Route::get('/delegations', [ApprovalController::class, 'delegationIndex'])->name('v1.approvals.delegations.index');
|
||||
Route::post('/delegations', [ApprovalController::class, 'delegationStore'])->name('v1.approvals.delegations.store');
|
||||
Route::patch('/delegations/{id}', [ApprovalController::class, 'delegationUpdate'])->whereNumber('id')->name('v1.approvals.delegations.update');
|
||||
Route::delete('/delegations/{id}', [ApprovalController::class, 'delegationDestroy'])->whereNumber('id')->name('v1.approvals.delegations.destroy');
|
||||
// CRUD
|
||||
Route::post('', [ApprovalController::class, 'store'])->name('v1.approvals.store');
|
||||
Route::get('/{id}', [ApprovalController::class, 'show'])->whereNumber('id')->name('v1.approvals.show');
|
||||
@@ -149,6 +160,10 @@
|
||||
Route::post('/{id}/approve', [ApprovalController::class, 'approve'])->whereNumber('id')->name('v1.approvals.approve');
|
||||
Route::post('/{id}/reject', [ApprovalController::class, 'reject'])->whereNumber('id')->name('v1.approvals.reject');
|
||||
Route::post('/{id}/cancel', [ApprovalController::class, 'cancel'])->whereNumber('id')->name('v1.approvals.cancel');
|
||||
Route::post('/{id}/hold', [ApprovalController::class, 'hold'])->whereNumber('id')->name('v1.approvals.hold');
|
||||
Route::post('/{id}/release-hold', [ApprovalController::class, 'releaseHold'])->whereNumber('id')->name('v1.approvals.release-hold');
|
||||
Route::post('/{id}/pre-decide', [ApprovalController::class, 'preDecide'])->whereNumber('id')->name('v1.approvals.pre-decide');
|
||||
Route::post('/{id}/copy', [ApprovalController::class, 'copyForRedraft'])->whereNumber('id')->name('v1.approvals.copy');
|
||||
// 참조 열람
|
||||
Route::post('/{id}/read', [ApprovalController::class, 'markRead'])->whereNumber('id')->name('v1.approvals.read');
|
||||
Route::post('/{id}/unread', [ApprovalController::class, 'markUnread'])->whereNumber('id')->name('v1.approvals.unread');
|
||||
|
||||
Reference in New Issue
Block a user