Files
sam-manage/app/Services/ApprovalService.php
김보곤 12c9ad620a feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:41 +09:00

501 lines
17 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\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()
->whereColumn('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'])
->whereHas('steps', function ($q) use ($userId) {
$q->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) {
$q->where('approver_id', $userId)
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
});
$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']);
}
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']);
}
return $approval->load(['form', 'drafter', 'steps.approver']);
});
}
/**
* 삭제 (draft만)
*/
public function deleteApproval(int $id): bool
{
$approval = Approval::findOrFail($id);
if (! $approval->isDeletable()) {
throw new \InvalidArgumentException('삭제할 수 없는 상태입니다.');
}
$approval->steps()->delete();
$approval->update(['deleted_by' => auth()->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 초기화
if ($approval->status === Approval::STATUS_REJECTED) {
$approval->steps()->update([
'status' => ApprovalStep::STATUS_PENDING,
'comment' => null,
'acted_at' => null,
]);
}
$approval->update([
'status' => Approval::STATUS_PENDING,
'drafted_at' => now(),
'current_step' => 1,
'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(),
'updated_by' => auth()->id(),
]);
}
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(),
'updated_by' => auth()->id(),
]);
return $approval->fresh(['form', 'drafter', 'steps.approver']);
});
}
/**
* 회수 (기안자만, pending → cancelled)
*/
public function cancel(int $id): Approval
{
return DB::transaction(function () use ($id) {
$approval = Approval::with('steps')->findOrFail($id);
if (! $approval->isCancellable()) {
throw new \InvalidArgumentException('회수할 수 없는 상태입니다.');
}
if ($approval->drafter_id !== auth()->id()) {
throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.');
}
// 모든 pending steps → skipped
$approval->steps()
->where('status', ApprovalStep::STATUS_PENDING)
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
$approval->update([
'status' => Approval::STATUS_CANCELLED,
'completed_at' => now(),
'updated_by' => auth()->id(),
]);
return $approval->fresh(['form', 'drafter', 'steps.approver']);
});
}
// =========================================================================
// 결재선
// =========================================================================
/**
* 결재선 템플릿 목록
*/
public function getApprovalLines(): Collection
{
return ApprovalLine::orderBy('name')->get();
}
/**
* 양식 목록
*/
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;
}
}
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 getBadgeCounts(int $userId): array
{
$pendingCount = Approval::pending()
->whereHas('steps', function ($q) use ($userId) {
$q->where('approver_id', $userId)
->where('status', ApprovalStep::STATUS_PENDING)
->approvalOnly()
->whereColumn('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::draft()->byDrafter($userId)->count();
return [
'pending' => $pendingCount,
'draft' => $draftCount,
];
}
// =========================================================================
// Private 헬퍼
// =========================================================================
/**
* 문서번호 채번 (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');
}
}
}