- 보류/보류해제 기능 추가 (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 에러/메시지 키 추가
1824 lines
65 KiB
PHP
1824 lines
65 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class ApprovalService extends Service
|
|
{
|
|
public function __construct(
|
|
protected TodayIssueObserverService $todayIssueService
|
|
) {}
|
|
|
|
// =========================================================================
|
|
// 결재 양식 관리
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 결재 양식 목록
|
|
*/
|
|
public function formIndex(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with('creator:id,name');
|
|
|
|
// 카테고리 필터
|
|
if (! empty($params['category'])) {
|
|
$query->where('category', $params['category']);
|
|
}
|
|
|
|
// 활성 상태 필터
|
|
if (isset($params['is_active'])) {
|
|
$query->where('is_active', $params['is_active']);
|
|
}
|
|
|
|
// 검색
|
|
if (! empty($params['search'])) {
|
|
$query->where(function ($q) use ($params) {
|
|
$q->where('name', 'like', "%{$params['search']}%")
|
|
->orWhere('code', 'like', "%{$params['search']}%");
|
|
});
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 활성 결재 양식 목록 (셀렉트박스용)
|
|
*/
|
|
public function formActive(): Collection
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->active()
|
|
->orderBy('name')
|
|
->get(['id', 'name', 'code', 'category']);
|
|
}
|
|
|
|
/**
|
|
* 결재 양식 상세
|
|
*/
|
|
public function formShow(int $id): ApprovalForm
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with('creator:id,name')
|
|
->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* 결재 양식 생성
|
|
*/
|
|
public function formStore(array $data): ApprovalForm
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
// 코드 중복 확인
|
|
$exists = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $data['code'])
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
throw new BadRequestHttpException(__('error.approval.form_code_exists'));
|
|
}
|
|
|
|
return ApprovalForm::create([
|
|
'tenant_id' => $tenantId,
|
|
'name' => $data['name'],
|
|
'code' => $data['code'],
|
|
'category' => $data['category'] ?? null,
|
|
'template' => $data['template'],
|
|
'is_active' => $data['is_active'] ?? true,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 결재 양식 수정
|
|
*/
|
|
public function formUpdate(int $id, array $data): ApprovalForm
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$form = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 코드 중복 확인 (자기 자신 제외)
|
|
if (isset($data['code']) && $data['code'] !== $form->code) {
|
|
$exists = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $data['code'])
|
|
->where('id', '!=', $id)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
throw new BadRequestHttpException(__('error.approval.form_code_exists'));
|
|
}
|
|
}
|
|
|
|
$form->fill([
|
|
'name' => $data['name'] ?? $form->name,
|
|
'code' => $data['code'] ?? $form->code,
|
|
'category' => $data['category'] ?? $form->category,
|
|
'template' => $data['template'] ?? $form->template,
|
|
'is_active' => $data['is_active'] ?? $form->is_active,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
$form->save();
|
|
|
|
return $form->fresh('creator:id,name');
|
|
}
|
|
|
|
/**
|
|
* 결재 양식 삭제
|
|
*/
|
|
public function formDestroy(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$form = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 사용 중인 양식인지 확인
|
|
$inUse = Approval::query()
|
|
->where('form_id', $id)
|
|
->exists();
|
|
|
|
if ($inUse) {
|
|
throw new BadRequestHttpException(__('error.approval.form_in_use'));
|
|
}
|
|
|
|
$form->deleted_by = $userId;
|
|
$form->save();
|
|
$form->delete();
|
|
|
|
return true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 결재선 템플릿 관리
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 결재선 목록
|
|
*/
|
|
public function lineIndex(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = ApprovalLine::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with('creator:id,name');
|
|
|
|
// 검색
|
|
if (! empty($params['search'])) {
|
|
$query->where('name', 'like', "%{$params['search']}%");
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 결재선 상세
|
|
*/
|
|
public function lineShow(int $id): ApprovalLine
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return ApprovalLine::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with('creator:id,name')
|
|
->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* 결재선 생성
|
|
*/
|
|
public function lineStore(array $data): ApprovalLine
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
|
// 기본 결재선으로 설정 시 기존 기본값 해제
|
|
if (! empty($data['is_default'])) {
|
|
ApprovalLine::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_default', true)
|
|
->update(['is_default' => false]);
|
|
}
|
|
|
|
return ApprovalLine::create([
|
|
'tenant_id' => $tenantId,
|
|
'name' => $data['name'],
|
|
'steps' => $data['steps'],
|
|
'is_default' => $data['is_default'] ?? false,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재선 수정
|
|
*/
|
|
public function lineUpdate(int $id, array $data): ApprovalLine
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
|
$line = ApprovalLine::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 기본 결재선으로 설정 시 기존 기본값 해제
|
|
if (! empty($data['is_default']) && ! $line->is_default) {
|
|
ApprovalLine::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_default', true)
|
|
->update(['is_default' => false]);
|
|
}
|
|
|
|
$line->fill([
|
|
'name' => $data['name'] ?? $line->name,
|
|
'steps' => $data['steps'] ?? $line->steps,
|
|
'is_default' => $data['is_default'] ?? $line->is_default,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
$line->save();
|
|
|
|
return $line->fresh('creator:id,name');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재선 삭제
|
|
*/
|
|
public function lineDestroy(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$line = ApprovalLine::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
$line->deleted_by = $userId;
|
|
$line->save();
|
|
$line->delete();
|
|
|
|
return true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 결재 문서 관리
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 기안함 - 내가 기안한 문서
|
|
*/
|
|
public function drafts(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$query = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('drafter_id', $userId)
|
|
->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',
|
|
'steps.approver.tenantProfile:id,user_id,position_key,department_id',
|
|
'steps.approver.tenantProfile.department:id,name',
|
|
]);
|
|
|
|
// 상태 필터
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
|
|
// 검색
|
|
if (! empty($params['search'])) {
|
|
$query->where(function ($q) use ($params) {
|
|
$q->where('title', 'like', "%{$params['search']}%")
|
|
->orWhere('document_number', 'like', "%{$params['search']}%");
|
|
});
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 기안함 현황 카드
|
|
*/
|
|
public function draftsSummary(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$counts = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('drafter_id', $userId)
|
|
->selectRaw('status, COUNT(*) as count')
|
|
->groupBy('status')
|
|
->pluck('count', 'status')
|
|
->toArray();
|
|
|
|
return [
|
|
'total' => array_sum($counts),
|
|
'draft' => $counts[Approval::STATUS_DRAFT] ?? 0,
|
|
'pending' => $counts[Approval::STATUS_PENDING] ?? 0,
|
|
'approved' => $counts[Approval::STATUS_APPROVED] ?? 0,
|
|
'rejected' => $counts[Approval::STATUS_REJECTED] ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 결재함 - 내가 결재해야 할 문서
|
|
*/
|
|
public function inbox(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$query = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
|
})
|
|
->with([
|
|
'form:id,name,code,category',
|
|
'drafter:id,name',
|
|
'drafter.tenantProfile:id,user_id,position_key,department_id',
|
|
'drafter.tenantProfile.department:id,name',
|
|
'steps' => function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId);
|
|
},
|
|
'steps.approver:id,name',
|
|
'steps.approver.tenantProfile:id,user_id,position_key,department_id',
|
|
'steps.approver.tenantProfile.department:id,name',
|
|
]);
|
|
|
|
// 상태 필터
|
|
if (! empty($params['status'])) {
|
|
if ($params['status'] === 'requested') {
|
|
// 결재 요청 (현재 내 차례)
|
|
$query->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]);
|
|
});
|
|
} elseif ($params['status'] === 'scheduled') {
|
|
// 예정 (아직 내 차례 아님)
|
|
$query->where('status', Approval::STATUS_PENDING)
|
|
->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING);
|
|
})
|
|
->whereDoesntHave('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->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]);
|
|
});
|
|
} elseif ($params['status'] === 'completed') {
|
|
// 내가 처리 완료한 문서
|
|
$query->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]);
|
|
});
|
|
} elseif ($params['status'] === 'rejected') {
|
|
// 내가 반려한 문서
|
|
$query->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_REJECTED);
|
|
});
|
|
}
|
|
}
|
|
|
|
// 날짜 범위 필터
|
|
if (! empty($params['start_date'])) {
|
|
$query->whereDate('created_at', '>=', $params['start_date']);
|
|
}
|
|
if (! empty($params['end_date'])) {
|
|
$query->whereDate('created_at', '<=', $params['end_date']);
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 결재함 현황 카드
|
|
*/
|
|
public function inboxSummary(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
// 결재 요청 (현재 내 차례)
|
|
$requested = 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();
|
|
|
|
// 예정 (내 차례 대기중)
|
|
$scheduled = ApprovalStep::query()
|
|
->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->whereHas('approval', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->where('status', Approval::STATUS_PENDING);
|
|
})
|
|
->count() - $requested;
|
|
|
|
// 완료 (내가 처리한 문서)
|
|
$completed = ApprovalStep::query()
|
|
->where('approver_id', $userId)
|
|
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED])
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->whereHas('approval', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId);
|
|
})
|
|
->count();
|
|
|
|
// 반려 (내가 반려한 문서)
|
|
$rejected = ApprovalStep::query()
|
|
->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_REJECTED)
|
|
->whereHas('approval', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId);
|
|
})
|
|
->count();
|
|
|
|
return [
|
|
'requested' => max(0, $requested),
|
|
'scheduled' => max(0, $scheduled),
|
|
'completed' => $completed,
|
|
'rejected' => $rejected,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 참조함 - 내가 참조된 문서
|
|
*/
|
|
public function reference(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$query = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
|
|
})
|
|
->with(['form:id,name,code,category', 'drafter:id,name', 'steps' => function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
|
|
}]);
|
|
|
|
// 열람 상태 필터
|
|
if (isset($params['is_read'])) {
|
|
$query->whereHas('steps', function ($q) use ($userId, $params) {
|
|
$q->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
|
->where('is_read', $params['is_read']);
|
|
});
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 결재 문서 상세
|
|
*/
|
|
public function show(int $id): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with([
|
|
'form:id,name,code,category,template',
|
|
'drafter:id,name,email',
|
|
'drafter.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
|
'drafter.tenantProfile.department:id,name',
|
|
'steps.approver:id,name,email',
|
|
'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name',
|
|
'steps.approver.tenantProfile.department:id,name',
|
|
])
|
|
->findOrFail($id);
|
|
|
|
// Document 브릿지: 연결된 문서 데이터 로딩
|
|
if ($approval->linkable_type === Document::class) {
|
|
$approval->load([
|
|
'linkable.template',
|
|
'linkable.template.approvalLines',
|
|
'linkable.data',
|
|
'linkable.approvals.user:id,name',
|
|
'linkable.attachments',
|
|
]);
|
|
}
|
|
|
|
return $approval;
|
|
}
|
|
|
|
/**
|
|
* 결재 문서 생성 (임시저장 또는 상신)
|
|
*/
|
|
public function store(array $data): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
|
// 양식 확인 (form_id 또는 form_code 지원)
|
|
$formQuery = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->active();
|
|
|
|
if (! empty($data['form_id'])) {
|
|
$form = $formQuery->where('id', $data['form_id'])->firstOrFail();
|
|
} elseif (! empty($data['form_code'])) {
|
|
$form = $formQuery->where('code', $data['form_code'])->firstOrFail();
|
|
} else {
|
|
throw new BadRequestHttpException(__('error.approval.form_required'));
|
|
}
|
|
|
|
// 문서번호 생성
|
|
$documentNumber = $this->generateDocumentNumber($tenantId);
|
|
|
|
$status = ! empty($data['submit']) ? Approval::STATUS_PENDING : Approval::STATUS_DRAFT;
|
|
|
|
$approval = Approval::create([
|
|
'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,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
// 결재선 생성 (steps가 있으면 항상 저장)
|
|
if (! empty($data['steps'])) {
|
|
$this->createApprovalSteps($approval, $data['steps']);
|
|
}
|
|
|
|
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',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 문서 수정 (임시저장 상태만)
|
|
*/
|
|
public function update(int $id, array $data): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $approval->isEditable()) {
|
|
throw new BadRequestHttpException(__('error.approval.not_editable'));
|
|
}
|
|
|
|
// form_id 또는 form_code로 양식 ID 결정
|
|
$formId = $approval->form_id;
|
|
if (! empty($data['form_id'])) {
|
|
$formId = $data['form_id'];
|
|
} elseif (! empty($data['form_code'])) {
|
|
$form = ApprovalForm::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $data['form_code'])
|
|
->active()
|
|
->first();
|
|
if ($form) {
|
|
$formId = $form->id;
|
|
}
|
|
}
|
|
|
|
$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,
|
|
]);
|
|
|
|
$approval->save();
|
|
|
|
// 결재선 수정 (steps가 전달된 경우)
|
|
if (! empty($data['steps'])) {
|
|
// 기존 결재선 삭제 후 새로 생성
|
|
$approval->steps()->delete();
|
|
$this->createApprovalSteps($approval, $data['steps']);
|
|
}
|
|
|
|
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',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 문서 삭제 (임시저장 상태만)
|
|
*/
|
|
public function destroy(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $approval->isDeletable()) {
|
|
throw new BadRequestHttpException(__('error.approval.not_deletable'));
|
|
}
|
|
|
|
$approval->deleted_by = $userId;
|
|
$approval->save();
|
|
$approval->delete();
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 상신 (draft → pending, rejected → pending 재상신 포함)
|
|
*/
|
|
public function submit(int $id, array $data): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with('steps')
|
|
->findOrFail($id);
|
|
|
|
if (! $approval->isSubmittable()) {
|
|
throw new BadRequestHttpException(__('error.approval.not_submittable'));
|
|
}
|
|
|
|
// 기존 결재선 확인 (steps 없이 상신하는 경우)
|
|
if (empty($data['steps'])) {
|
|
$existingSteps = $approval->steps()
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->count();
|
|
|
|
if ($existingSteps === 0) {
|
|
throw new BadRequestHttpException(__('error.approval.steps_required'));
|
|
}
|
|
}
|
|
|
|
// 반려 후 재상신 처리
|
|
$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가 있으면 새로 생성
|
|
if (! empty($data['steps'])) {
|
|
$approval->steps()->delete();
|
|
$this->createApprovalSteps($approval, $data['steps']);
|
|
} else {
|
|
// 기존 결재선 사용 시 알림 발송
|
|
$firstPendingStep = $approval->steps()
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->orderBy('step_order')
|
|
->first();
|
|
|
|
if ($firstPendingStep) {
|
|
$this->todayIssueService->handleApprovalStepChange($firstPendingStep);
|
|
}
|
|
}
|
|
|
|
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',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 승인
|
|
*/
|
|
public function approve(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)
|
|
->findOrFail($id);
|
|
|
|
if (! $approval->isActionable()) {
|
|
throw new BadRequestHttpException(__('error.approval.not_actionable'));
|
|
}
|
|
|
|
// 현재 내 결재 단계 찾기
|
|
$myStep = $approval->steps()
|
|
->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->orderBy('step_order')
|
|
->first();
|
|
|
|
if (! $myStep) {
|
|
throw new BadRequestHttpException(__('error.approval.not_your_turn'));
|
|
}
|
|
|
|
// 내 단계 승인
|
|
$myStep->status = ApprovalStep::STATUS_APPROVED;
|
|
$myStep->comment = $comment;
|
|
$myStep->acted_at = now();
|
|
$myStep->save();
|
|
|
|
// 다음 결재자 확인
|
|
$nextStep = $approval->steps()
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->orderBy('step_order')
|
|
->first();
|
|
|
|
if (! $nextStep) {
|
|
// 모든 결재 완료
|
|
$approval->status = Approval::STATUS_APPROVED;
|
|
$approval->completed_at = now();
|
|
$approval->drafter_read_at = null;
|
|
}
|
|
|
|
$approval->current_step = $myStep->step_order + 1;
|
|
$approval->updated_by = $userId;
|
|
$approval->save();
|
|
|
|
// 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',
|
|
'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',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 반려
|
|
*/
|
|
public function reject(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)
|
|
->findOrFail($id);
|
|
|
|
if (! $approval->isActionable()) {
|
|
throw new BadRequestHttpException(__('error.approval.not_actionable'));
|
|
}
|
|
|
|
// 현재 내 결재 단계 찾기
|
|
$myStep = $approval->steps()
|
|
->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->orderBy('step_order')
|
|
->first();
|
|
|
|
if (! $myStep) {
|
|
throw new BadRequestHttpException(__('error.approval.not_your_turn'));
|
|
}
|
|
|
|
// 반려 처리
|
|
$myStep->status = ApprovalStep::STATUS_REJECTED;
|
|
$myStep->comment = $comment;
|
|
$myStep->acted_at = now();
|
|
$myStep->save();
|
|
|
|
// 문서 반려 상태로 변경
|
|
$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',
|
|
'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',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 회수 (기안자만, pending/on_hold → cancelled)
|
|
*/
|
|
public function cancel(int $id, ?string $recallReason = null): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $recallReason, $tenantId, $userId) {
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $approval->isCancellable()) {
|
|
throw new BadRequestHttpException(__('error.approval.not_cancellable'));
|
|
}
|
|
|
|
// 기안자만 회수 가능
|
|
if ($approval->drafter_id !== $userId) {
|
|
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 브릿지 동기화
|
|
$this->syncToLinkedDocument($approval);
|
|
|
|
// 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의 상태와 결재란을 동기화
|
|
*/
|
|
private function syncToLinkedDocument(Approval $approval): void
|
|
{
|
|
if ($approval->linkable_type !== Document::class) {
|
|
return;
|
|
}
|
|
|
|
$document = Document::find($approval->linkable_id);
|
|
if (! $document) {
|
|
return;
|
|
}
|
|
|
|
// approval_steps → document_approvals 동기화 (승인자 이름/시각 반영)
|
|
foreach ($approval->steps as $step) {
|
|
if ($step->status === ApprovalStep::STATUS_PENDING) {
|
|
continue;
|
|
}
|
|
|
|
$docApproval = $document->approvals()
|
|
->where('step', $step->step_order)
|
|
->first();
|
|
|
|
if ($docApproval) {
|
|
$docApproval->update([
|
|
'status' => strtoupper($step->status),
|
|
'acted_at' => $step->acted_at,
|
|
'comment' => $step->comment,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Document 전체 상태 동기화
|
|
$documentStatus = match ($approval->status) {
|
|
Approval::STATUS_APPROVED => Document::STATUS_APPROVED,
|
|
Approval::STATUS_REJECTED => Document::STATUS_REJECTED,
|
|
Approval::STATUS_CANCELLED => Document::STATUS_CANCELLED,
|
|
default => Document::STATUS_PENDING,
|
|
};
|
|
|
|
$document->update([
|
|
'status' => $documentStatus,
|
|
'completed_at' => in_array($approval->status, [
|
|
Approval::STATUS_APPROVED,
|
|
Approval::STATUS_REJECTED,
|
|
]) ? now() : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 참조 열람 처리
|
|
*/
|
|
public function markRead(int $id): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
$step = $approval->steps()
|
|
->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
|
->first();
|
|
|
|
if (! $step) {
|
|
throw new NotFoundHttpException(__('error.approval.not_referee'));
|
|
}
|
|
|
|
$step->is_read = true;
|
|
$step->read_at = now();
|
|
$step->save();
|
|
|
|
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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 참조 미열람 처리
|
|
*/
|
|
public function markUnread(int $id): Approval
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$approval = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
$step = $approval->steps()
|
|
->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
|
->first();
|
|
|
|
if (! $step) {
|
|
throw new NotFoundHttpException(__('error.approval.not_referee'));
|
|
}
|
|
|
|
$step->is_read = false;
|
|
$step->read_at = null;
|
|
$step->save();
|
|
|
|
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',
|
|
]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 헬퍼 메서드
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 문서번호 생성
|
|
*/
|
|
private function generateDocumentNumber(int $tenantId): string
|
|
{
|
|
$prefix = 'AP';
|
|
$date = now()->format('Ymd');
|
|
|
|
$lastNumber = Approval::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('document_number', 'like', "{$prefix}-{$date}-%")
|
|
->orderByDesc('document_number')
|
|
->value('document_number');
|
|
|
|
if ($lastNumber) {
|
|
$sequence = (int) substr($lastNumber, -4) + 1;
|
|
} else {
|
|
$sequence = 1;
|
|
}
|
|
|
|
return sprintf('%s-%s-%04d', $prefix, $date, $sequence);
|
|
}
|
|
|
|
/**
|
|
* 결재 단계 생성 + 결재자 스냅샷 저장
|
|
* 프론트엔드 호환성: step_type/approver_id 또는 type/user_id 지원
|
|
* 중복 결재자 자동 제거
|
|
*/
|
|
private function createApprovalSteps(Approval $approval, array $steps): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$order = 1;
|
|
$processedApprovers = []; // 중복 체크용
|
|
|
|
foreach ($steps as $step) {
|
|
// 필드명 호환성: step_type 또는 type
|
|
$stepType = $step['step_type'] ?? $step['type'] ?? null;
|
|
// 필드명 호환성: approver_id 또는 user_id
|
|
$approverId = $step['approver_id'] ?? $step['user_id'] ?? null;
|
|
// step_order가 있으면 사용, 없으면 자동 증가
|
|
$stepOrder = $step['step_order'] ?? $order++;
|
|
|
|
if ($stepType && $approverId) {
|
|
// 동일 결재자 중복 건너뛰기
|
|
if (in_array($approverId, $processedApprovers, true)) {
|
|
continue;
|
|
}
|
|
$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();
|
|
}
|
|
}
|