948 lines
33 KiB
PHP
948 lines
33 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Approvals\Approval;
|
|
use App\Models\Approvals\ApprovalForm;
|
|
use App\Models\Approvals\ApprovalLine;
|
|
use App\Models\Approvals\ApprovalStep;
|
|
use App\Models\Boards\File;
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ApprovalService
|
|
{
|
|
// =========================================================================
|
|
// 목록 조회
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 기안함 (내가 기안한 문서)
|
|
*/
|
|
public function getMyDrafts(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
|
{
|
|
$userId = auth()->id();
|
|
|
|
$query = Approval::with(['form', 'steps.approver'])
|
|
->byDrafter($userId);
|
|
|
|
$this->applyFilters($query, $filters);
|
|
|
|
return $query->orderByDesc('created_at')->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 결재 대기함 (내가 현재 결재자인 문서)
|
|
*/
|
|
public function getPendingForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
|
{
|
|
$query = Approval::with(['form', 'drafter', 'steps.approver'])
|
|
->pending()
|
|
->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->approvalOnly()
|
|
->where('step_order', function ($sub) {
|
|
$sub->selectRaw('MIN(step_order)')
|
|
->from('approval_steps as inner_steps')
|
|
->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id')
|
|
->where('inner_steps.status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('inner_steps.step_type', [
|
|
ApprovalLine::STEP_TYPE_APPROVAL,
|
|
ApprovalLine::STEP_TYPE_AGREEMENT,
|
|
]);
|
|
});
|
|
});
|
|
|
|
$this->applyFilters($query, $filters);
|
|
|
|
return $query->orderByDesc('drafted_at')->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 처리 완료함 (내가 기안한 완료 문서 + 내가 결재 처리한 문서)
|
|
*/
|
|
public function getCompletedByMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
|
{
|
|
$query = Approval::with(['form', 'drafter', 'steps.approver'])
|
|
->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]);
|
|
});
|
|
});
|
|
|
|
$this->applyFilters($query, $filters);
|
|
|
|
return $query->orderByDesc('updated_at')->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 참조함 (내가 참조자인 문서)
|
|
*/
|
|
public function getReferencesForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
|
{
|
|
$query = Approval::with(['form', 'drafter', 'steps.approver'])
|
|
->whereHas('steps', function ($q) use ($userId, $filters) {
|
|
$q->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
|
|
if (isset($filters['is_read'])) {
|
|
$q->where('is_read', $filters['is_read'] === 'true' || $filters['is_read'] === '1');
|
|
}
|
|
});
|
|
|
|
$this->applyFilters($query, $filters);
|
|
|
|
return $query->orderByDesc('created_at')->paginate($perPage);
|
|
}
|
|
|
|
// =========================================================================
|
|
// CRUD
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 상세 조회
|
|
*/
|
|
public function getApproval(int $id): Approval
|
|
{
|
|
return Approval::with(['form', 'drafter', 'line', 'steps.approver'])
|
|
->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* 생성 (임시저장)
|
|
*/
|
|
public function createApproval(array $data): Approval
|
|
{
|
|
return DB::transaction(function () use ($data) {
|
|
$tenantId = session('selected_tenant_id');
|
|
$userId = auth()->id();
|
|
|
|
$approval = Approval::create([
|
|
'tenant_id' => $tenantId,
|
|
'document_number' => $this->generateDocumentNumber($tenantId),
|
|
'form_id' => $data['form_id'],
|
|
'line_id' => $data['line_id'] ?? null,
|
|
'title' => $data['title'],
|
|
'content' => $data['content'] ?? [],
|
|
'body' => $data['body'] ?? null,
|
|
'status' => Approval::STATUS_DRAFT,
|
|
'is_urgent' => $data['is_urgent'] ?? false,
|
|
'drafter_id' => $userId,
|
|
'department_id' => $data['department_id'] ?? null,
|
|
'current_step' => 0,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
if (! empty($data['steps'])) {
|
|
$this->saveApprovalSteps($approval, $data['steps']);
|
|
}
|
|
|
|
// 첨부파일 연결
|
|
if (! empty($data['attachment_file_ids'])) {
|
|
$this->linkAttachments($approval, $data['attachment_file_ids']);
|
|
}
|
|
|
|
return $approval->load(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 수정 (draft/rejected만)
|
|
*/
|
|
public function updateApproval(int $id, array $data): Approval
|
|
{
|
|
return DB::transaction(function () use ($id, $data) {
|
|
$approval = Approval::findOrFail($id);
|
|
|
|
if (! $approval->isEditable()) {
|
|
throw new \InvalidArgumentException('수정할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$approval->update([
|
|
'form_id' => $data['form_id'] ?? $approval->form_id,
|
|
'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,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
if (isset($data['steps'])) {
|
|
$approval->steps()->delete();
|
|
$this->saveApprovalSteps($approval, $data['steps']);
|
|
}
|
|
|
|
// 첨부파일 갱신
|
|
if (array_key_exists('attachment_file_ids', $data)) {
|
|
$this->linkAttachments($approval, $data['attachment_file_ids'] ?? []);
|
|
}
|
|
|
|
return $approval->load(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 삭제 (일반: draft만 / 관리자: 모든 상태)
|
|
*/
|
|
public function deleteApproval(int $id, ?User $user = null): bool
|
|
{
|
|
$approval = Approval::with('form')->findOrFail($id);
|
|
$user = $user ?? auth()->user();
|
|
|
|
if (! $approval->isDeletableBy($user)) {
|
|
throw new \InvalidArgumentException('삭제 권한이 없습니다.');
|
|
}
|
|
|
|
// 진행 중/보류 문서 삭제 시 연동 후처리 (휴가 등)
|
|
if (in_array($approval->status, [Approval::STATUS_PENDING, Approval::STATUS_ON_HOLD])) {
|
|
$this->handleApprovalDeleted($approval);
|
|
}
|
|
|
|
$approval->steps()->delete();
|
|
$approval->update(['deleted_by' => $user->id]);
|
|
|
|
return $approval->delete();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 워크플로우
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 상신 (draft/rejected → pending)
|
|
*/
|
|
public function submit(int $id): Approval
|
|
{
|
|
return DB::transaction(function () use ($id) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isSubmittable()) {
|
|
throw new \InvalidArgumentException('상신할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$approverSteps = $approval->steps
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
|
|
|
if ($approverSteps->isEmpty()) {
|
|
throw new \InvalidArgumentException('결재선을 설정해주세요.');
|
|
}
|
|
|
|
// 반려 후 재상신이면 모든 step 초기화 + 재상신 카운트 증가
|
|
$isResubmit = $approval->status === Approval::STATUS_REJECTED;
|
|
if ($isResubmit) {
|
|
$approval->steps()->update([
|
|
'status' => ApprovalStep::STATUS_PENDING,
|
|
'comment' => null,
|
|
'acted_at' => null,
|
|
]);
|
|
}
|
|
|
|
$approval->update([
|
|
'status' => Approval::STATUS_PENDING,
|
|
'drafted_at' => now(),
|
|
'current_step' => 1,
|
|
'resubmit_count' => $isResubmit ? $approval->resubmit_count + 1 : $approval->resubmit_count,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 승인
|
|
*/
|
|
public function approve(int $id, ?string $comment = null): Approval
|
|
{
|
|
return DB::transaction(function () use ($id, $comment) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isActionable()) {
|
|
throw new \InvalidArgumentException('승인할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$currentStep = $approval->getCurrentApproverStep();
|
|
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
|
|
}
|
|
|
|
$currentStep->update([
|
|
'status' => ApprovalStep::STATUS_APPROVED,
|
|
'comment' => $comment,
|
|
'acted_at' => now(),
|
|
]);
|
|
|
|
// 다음 결재자 확인
|
|
$nextStep = $approval->steps()
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->orderBy('step_order')
|
|
->first();
|
|
|
|
if ($nextStep) {
|
|
$approval->update([
|
|
'current_step' => $nextStep->step_order,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
} else {
|
|
// 마지막 결재자 → 문서 승인 완료
|
|
$approval->update([
|
|
'status' => Approval::STATUS_APPROVED,
|
|
'completed_at' => now(),
|
|
'drafter_read_at' => null,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// 연동 후처리 (휴가 등)
|
|
$this->handleApprovalCompleted($approval);
|
|
}
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 반려
|
|
*/
|
|
public function reject(int $id, string $comment): Approval
|
|
{
|
|
return DB::transaction(function () use ($id, $comment) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isActionable()) {
|
|
throw new \InvalidArgumentException('반려할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$currentStep = $approval->getCurrentApproverStep();
|
|
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
|
|
}
|
|
|
|
if (empty($comment)) {
|
|
throw new \InvalidArgumentException('반려 사유를 입력해주세요.');
|
|
}
|
|
|
|
$currentStep->update([
|
|
'status' => ApprovalStep::STATUS_REJECTED,
|
|
'comment' => $comment,
|
|
'acted_at' => now(),
|
|
]);
|
|
|
|
$approval->update([
|
|
'status' => Approval::STATUS_REJECTED,
|
|
'completed_at' => now(),
|
|
'drafter_read_at' => null,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// 연동 후처리 (휴가 등)
|
|
$this->handleApprovalRejected($approval, $comment);
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 회수 (기안자만, pending/on_hold → cancelled)
|
|
*/
|
|
public function cancel(int $id, ?string $recallReason = null): Approval
|
|
{
|
|
return DB::transaction(function () use ($id, $recallReason) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isCancellable()) {
|
|
throw new \InvalidArgumentException('회수할 수 없는 상태입니다.');
|
|
}
|
|
|
|
if ($approval->drafter_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.');
|
|
}
|
|
|
|
// 첫 번째 결재자가 이미 처리했으면 회수 불가
|
|
$firstApproverStep = $approval->steps()
|
|
->approvalOnly()
|
|
->orderBy('step_order')
|
|
->first();
|
|
|
|
if ($firstApproverStep && $firstApproverStep->status !== ApprovalStep::STATUS_PENDING
|
|
&& $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) {
|
|
throw new \InvalidArgumentException('첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.');
|
|
}
|
|
|
|
// 모든 pending/on_hold steps → skipped
|
|
$approval->steps()
|
|
->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD])
|
|
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
|
|
|
|
$approval->update([
|
|
'status' => Approval::STATUS_CANCELLED,
|
|
'completed_at' => now(),
|
|
'recall_reason' => $recallReason,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// 연동 후처리 (휴가 회수)
|
|
$this->handleApprovalCancelled($approval);
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 보류 (현재 결재자만, pending → on_hold)
|
|
*/
|
|
public function hold(int $id, string $comment): Approval
|
|
{
|
|
return DB::transaction(function () use ($id, $comment) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isHoldable()) {
|
|
throw new \InvalidArgumentException('보류할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$currentStep = $approval->getCurrentApproverStep();
|
|
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
|
|
}
|
|
|
|
if (empty($comment)) {
|
|
throw new \InvalidArgumentException('보류 사유를 입력해주세요.');
|
|
}
|
|
|
|
$currentStep->update([
|
|
'status' => ApprovalStep::STATUS_ON_HOLD,
|
|
'comment' => $comment,
|
|
'acted_at' => now(),
|
|
]);
|
|
|
|
$approval->update([
|
|
'status' => Approval::STATUS_ON_HOLD,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 보류 해제 (보류한 결재자만, on_hold → pending)
|
|
*/
|
|
public function releaseHold(int $id): Approval
|
|
{
|
|
return DB::transaction(function () use ($id) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isHoldReleasable()) {
|
|
throw new \InvalidArgumentException('보류 해제할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$holdStep = $approval->steps()
|
|
->where('status', ApprovalStep::STATUS_ON_HOLD)
|
|
->first();
|
|
|
|
if (! $holdStep || $holdStep->approver_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('보류한 결재자만 해제할 수 있습니다.');
|
|
}
|
|
|
|
$holdStep->update([
|
|
'status' => ApprovalStep::STATUS_PENDING,
|
|
'comment' => null,
|
|
'acted_at' => null,
|
|
]);
|
|
|
|
$approval->update([
|
|
'status' => Approval::STATUS_PENDING,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인)
|
|
*/
|
|
public function preDecide(int $id, ?string $comment = null): Approval
|
|
{
|
|
return DB::transaction(function () use ($id, $comment) {
|
|
$approval = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $approval->isActionable()) {
|
|
throw new \InvalidArgumentException('전결할 수 없는 상태입니다.');
|
|
}
|
|
|
|
$currentStep = $approval->getCurrentApproverStep();
|
|
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
|
|
}
|
|
|
|
// 현재 step → approved + pre_decided
|
|
$currentStep->update([
|
|
'status' => ApprovalStep::STATUS_APPROVED,
|
|
'approval_type' => 'pre_decided',
|
|
'comment' => $comment,
|
|
'acted_at' => now(),
|
|
]);
|
|
|
|
// 이후 모든 pending approval/agreement steps → skipped
|
|
$approval->steps()
|
|
->where('step_order', '>', $currentStep->step_order)
|
|
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
|
|
|
|
// 문서 최종 승인
|
|
$approval->update([
|
|
'status' => Approval::STATUS_APPROVED,
|
|
'completed_at' => now(),
|
|
'drafter_read_at' => null,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// 연동 후처리 (휴가 등)
|
|
$this->handleApprovalCompleted($approval);
|
|
|
|
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성)
|
|
*/
|
|
public function copyForRedraft(int $id): Approval
|
|
{
|
|
return DB::transaction(function () use ($id) {
|
|
$original = Approval::with('steps')->findOrFail($id);
|
|
|
|
if (! $original->isCopyable()) {
|
|
throw new \InvalidArgumentException('복사할 수 없는 상태입니다.');
|
|
}
|
|
|
|
if ($original->drafter_id !== auth()->id()) {
|
|
throw new \InvalidArgumentException('기안자만 복사할 수 있습니다.');
|
|
}
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
// 새 문서 생성
|
|
$newApproval = Approval::create([
|
|
'tenant_id' => $tenantId,
|
|
'document_number' => $this->generateDocumentNumber($tenantId),
|
|
'form_id' => $original->form_id,
|
|
'line_id' => $original->line_id,
|
|
'title' => $original->title,
|
|
'content' => $original->content,
|
|
'body' => $original->body,
|
|
'status' => Approval::STATUS_DRAFT,
|
|
'is_urgent' => $original->is_urgent,
|
|
'drafter_id' => auth()->id(),
|
|
'department_id' => $original->department_id,
|
|
'current_step' => 0,
|
|
'parent_doc_id' => $original->id,
|
|
'created_by' => auth()->id(),
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// 결재선 복사 (모두 pending 상태로)
|
|
foreach ($original->steps as $step) {
|
|
ApprovalStep::create([
|
|
'approval_id' => $newApproval->id,
|
|
'step_order' => $step->step_order,
|
|
'step_type' => $step->step_type,
|
|
'approver_id' => $step->approver_id,
|
|
'approver_name' => $step->approver_name,
|
|
'approver_department' => $step->approver_department,
|
|
'approver_position' => $step->approver_position,
|
|
'status' => ApprovalStep::STATUS_PENDING,
|
|
]);
|
|
}
|
|
|
|
return $newApproval->load(['form', 'drafter', 'steps.approver']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 참조 열람 추적
|
|
*/
|
|
public function markAsRead(int $approvalId): void
|
|
{
|
|
$userId = auth()->id();
|
|
|
|
ApprovalStep::where('approval_id', $approvalId)
|
|
->where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
|
->where('is_read', false)
|
|
->update([
|
|
'is_read' => true,
|
|
'read_at' => now(),
|
|
]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 결재선
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 결재선 템플릿 목록
|
|
*/
|
|
public function getApprovalLines(): Collection
|
|
{
|
|
return ApprovalLine::orderBy('name')->get();
|
|
}
|
|
|
|
/**
|
|
* 결재선 템플릿 생성
|
|
*/
|
|
public function createLine(array $data): ApprovalLine
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
$steps = $this->enrichLineSteps($data['steps']);
|
|
|
|
return ApprovalLine::create([
|
|
'tenant_id' => $tenantId,
|
|
'name' => $data['name'],
|
|
'steps' => $steps,
|
|
'is_default' => $data['is_default'] ?? false,
|
|
'created_by' => auth()->id(),
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 결재선 템플릿 수정
|
|
*/
|
|
public function updateLine(int $id, array $data): ApprovalLine
|
|
{
|
|
$line = ApprovalLine::findOrFail($id);
|
|
$steps = $this->enrichLineSteps($data['steps']);
|
|
|
|
$line->update([
|
|
'name' => $data['name'],
|
|
'steps' => $steps,
|
|
'is_default' => $data['is_default'] ?? false,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return $line->fresh();
|
|
}
|
|
|
|
/**
|
|
* 결재선 템플릿 삭제
|
|
*/
|
|
public function deleteLine(int $id): bool
|
|
{
|
|
$line = ApprovalLine::findOrFail($id);
|
|
$line->update(['deleted_by' => auth()->id()]);
|
|
|
|
return $line->delete();
|
|
}
|
|
|
|
/**
|
|
* 양식 목록
|
|
*/
|
|
public function getApprovalForms(): Collection
|
|
{
|
|
return ApprovalForm::active()->orderBy('name')->get();
|
|
}
|
|
|
|
/**
|
|
* 결재 단계 저장 + 스냅샷
|
|
*/
|
|
public function saveApprovalSteps(Approval $approval, array $steps): void
|
|
{
|
|
foreach ($steps as $index => $step) {
|
|
$user = User::find($step['user_id']);
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$departmentName = null;
|
|
$positionName = null;
|
|
|
|
if ($user) {
|
|
$employee = $user->tenantProfiles()
|
|
->where('tenant_id', $tenantId)
|
|
->first();
|
|
|
|
if ($employee) {
|
|
$departmentName = $employee->department?->name;
|
|
$positionName = $employee->position_label ?? $employee->job_title_label;
|
|
}
|
|
}
|
|
|
|
ApprovalStep::create([
|
|
'approval_id' => $approval->id,
|
|
'step_order' => $index + 1,
|
|
'step_type' => $step['step_type'] ?? ApprovalLine::STEP_TYPE_APPROVAL,
|
|
'approver_id' => $step['user_id'],
|
|
'approver_name' => $user?->name ?? '',
|
|
'approver_department' => $departmentName,
|
|
'approver_position' => $positionName,
|
|
'status' => ApprovalStep::STATUS_PENDING,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 연결
|
|
*/
|
|
public function linkAttachments(Approval $approval, array $fileIds): void
|
|
{
|
|
$attachments = [];
|
|
|
|
if (! empty($fileIds)) {
|
|
$files = File::whereIn('id', $fileIds)
|
|
->where('uploaded_by', auth()->id())
|
|
->get();
|
|
|
|
foreach ($files as $file) {
|
|
$attachments[] = [
|
|
'id' => $file->id,
|
|
'name' => $file->original_name,
|
|
'size' => $file->file_size,
|
|
'mime_type' => $file->mime_type,
|
|
];
|
|
|
|
$file->update([
|
|
'is_temp' => false,
|
|
'document_id' => $approval->id,
|
|
'document_type' => 'approval',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$approval->update(['attachments' => $attachments]);
|
|
}
|
|
|
|
/**
|
|
* 미처리 건수 (뱃지용)
|
|
*/
|
|
public function getBadgeCounts(int $userId): array
|
|
{
|
|
$pendingCount = Approval::pending()
|
|
->whereHas('steps', function ($q) use ($userId) {
|
|
$q->where('approver_id', $userId)
|
|
->where('status', ApprovalStep::STATUS_PENDING)
|
|
->approvalOnly()
|
|
->where('step_order', function ($sub) {
|
|
$sub->selectRaw('MIN(step_order)')
|
|
->from('approval_steps as inner_steps')
|
|
->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id')
|
|
->where('inner_steps.status', ApprovalStep::STATUS_PENDING)
|
|
->whereIn('inner_steps.step_type', [
|
|
ApprovalLine::STEP_TYPE_APPROVAL,
|
|
ApprovalLine::STEP_TYPE_AGREEMENT,
|
|
]);
|
|
});
|
|
})
|
|
->count();
|
|
|
|
$draftCount = Approval::pending()->byDrafter($userId)->count();
|
|
|
|
$referenceUnreadCount = ApprovalStep::where('approver_id', $userId)
|
|
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
|
->where('is_read', false)
|
|
->count();
|
|
|
|
$completedUnreadCount = Approval::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 $userId): int
|
|
{
|
|
return Approval::where('drafter_id', $userId)
|
|
->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED])
|
|
->whereNull('drafter_read_at')
|
|
->update(['drafter_read_at' => now()]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private 헬퍼
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 휴가/근태신청/사유서 관련 결재 양식인지 확인
|
|
*/
|
|
private function isLeaveRelatedForm(?string $code): bool
|
|
{
|
|
return in_array($code, ['leave', 'attendance_request', 'reason_report']);
|
|
}
|
|
|
|
/**
|
|
* 결재 최종 승인 시 연동 처리 (휴가/근태신청/사유서)
|
|
*/
|
|
private function handleApprovalCompleted(Approval $approval): void
|
|
{
|
|
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
|
return;
|
|
}
|
|
|
|
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
|
if ($leave && $leave->status === 'pending') {
|
|
app(\App\Services\HR\LeaveService::class)->approveByApproval($leave, $approval);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 결재 반려 시 연동 처리 (휴가/근태신청/사유서)
|
|
*/
|
|
private function handleApprovalRejected(Approval $approval, string $comment): void
|
|
{
|
|
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
|
return;
|
|
}
|
|
|
|
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
|
if ($leave && $leave->status === 'pending') {
|
|
app(\App\Services\HR\LeaveService::class)->rejectByApproval(
|
|
$leave,
|
|
$comment,
|
|
auth()->id()
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 결재 삭제 시 연동 처리 (휴가/근태신청/사유서)
|
|
*/
|
|
private function handleApprovalDeleted(Approval $approval): void
|
|
{
|
|
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
|
return;
|
|
}
|
|
|
|
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
|
if ($leave && in_array($leave->status, ['pending', 'approved'])) {
|
|
$leave->update([
|
|
'status' => 'cancelled',
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 결재 회수 시 연동 처리 (휴가/근태신청/사유서)
|
|
*/
|
|
private function handleApprovalCancelled(Approval $approval): void
|
|
{
|
|
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
|
return;
|
|
}
|
|
|
|
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
|
if ($leave && $leave->status === 'pending') {
|
|
$leave->update([
|
|
'status' => 'cancelled',
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 결재선 steps에 user_name, department, position 스냅샷 보강
|
|
*/
|
|
private function enrichLineSteps(array $steps): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
return collect($steps)->map(function ($step) use ($tenantId) {
|
|
$user = User::find($step['user_id']);
|
|
$profile = $user?->tenantProfiles()->where('tenant_id', $tenantId)->first();
|
|
|
|
return [
|
|
'user_id' => $step['user_id'],
|
|
'user_name' => $user?->name ?? '',
|
|
'department' => $profile?->department?->name ?? $step['department'] ?? '',
|
|
'position' => $profile?->position_label ?? $profile?->job_title_label ?? $step['position'] ?? '',
|
|
'step_type' => $step['step_type'] ?? 'approval',
|
|
];
|
|
})->toArray();
|
|
}
|
|
|
|
/**
|
|
* 문서번호 채번 (APR-YYMMDD-001)
|
|
*/
|
|
private function generateDocumentNumber(int $tenantId): string
|
|
{
|
|
$prefix = 'APR';
|
|
$dateKey = now()->format('ymd');
|
|
$documentType = 'approval';
|
|
$periodKey = $dateKey;
|
|
|
|
DB::statement(
|
|
'INSERT INTO numbering_sequences
|
|
(tenant_id, document_type, scope_key, period_key, last_sequence, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 1, NOW(), NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
last_sequence = last_sequence + 1,
|
|
updated_at = NOW()',
|
|
[$tenantId, $documentType, '', $periodKey]
|
|
);
|
|
|
|
$sequence = (int) DB::table('numbering_sequences')
|
|
->where('tenant_id', $tenantId)
|
|
->where('document_type', $documentType)
|
|
->where('scope_key', '')
|
|
->where('period_key', $periodKey)
|
|
->value('last_sequence');
|
|
|
|
return $prefix.'-'.$dateKey.'-'.str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
/**
|
|
* 공통 필터 적용
|
|
*/
|
|
private function applyFilters($query, array $filters): void
|
|
{
|
|
if (! empty($filters['search'])) {
|
|
$search = $filters['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('title', 'like', "%{$search}%")
|
|
->orWhere('document_number', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
if (! empty($filters['status'])) {
|
|
$query->where('status', $filters['status']);
|
|
}
|
|
|
|
if (! empty($filters['is_urgent'])) {
|
|
$query->where('is_urgent', true);
|
|
}
|
|
|
|
if (! empty($filters['date_from'])) {
|
|
$query->where('created_at', '>=', $filters['date_from']);
|
|
}
|
|
|
|
if (! empty($filters['date_to'])) {
|
|
$query->where('created_at', '<=', $filters['date_to'].' 23:59:59');
|
|
}
|
|
}
|
|
}
|