Files
sam-api/app/Services/ApprovalService.php

1074 lines
37 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalForm;
use App\Models\Tenants\ApprovalLine;
use App\Models\Tenants\ApprovalStep;
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);
});
}
}
// 정렬
$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();
return 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);
}
/**
* 결재 문서 생성 (임시저장 또는 상신)
*/
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,
'title' => $data['title'],
'content' => $data['content'],
'status' => $status,
'drafter_id' => $userId,
'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,
'title' => $data['title'] ?? $approval->title,
'content' => $data['content'] ?? $approval->content,
'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;
});
}
/**
* 결재 상신
*/
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)
->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'));
}
}
// 먼저 approval을 pending으로 변경 (Observer가 올바른 상태로 트리거되도록)
$approval->status = Approval::STATUS_PENDING;
$approval->drafted_at = now();
$approval->current_step = 1;
$approval->updated_by = $userId;
$approval->save();
// steps가 있으면 새로 생성 (approval이 pending 상태일 때 생성해야 알림 발송)
if (! empty($data['steps'])) {
// 기존 결재선 삭제 후 새로 생성
$approval->steps()->delete();
$this->createApprovalSteps($approval, $data['steps']);
} else {
// 기존 결재선 사용 시, Observer가 트리거되지 않으므로 수동으로 알림 발송
$firstPendingStep = $approval->steps()
->where('status', ApprovalStep::STATUS_PENDING)
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->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->current_step = $myStep->step_order + 1;
$approval->updated_by = $userId;
$approval->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 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->updated_by = $userId;
$approval->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 cancel(int $id): Approval
{
$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->isCancellable()) {
throw new BadRequestHttpException(__('error.approval.not_cancellable'));
}
// 기안자만 회수 가능
if ($approval->drafter_id !== $userId) {
throw new BadRequestHttpException(__('error.approval.only_drafter_can_cancel'));
}
$approval->status = Approval::STATUS_CANCELLED;
$approval->completed_at = now();
$approval->updated_by = $userId;
$approval->save();
// 결재 단계들 삭제
$approval->steps()->delete();
return $approval->fresh([
'form:id,name,code,category',
'drafter:id,name',
]);
});
}
/**
* 참조 열람 처리
*/
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
{
$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;
ApprovalStep::create([
'approval_id' => $approval->id,
'step_order' => $stepOrder,
'step_type' => $stepType,
'approver_id' => $approverId,
'status' => ApprovalStep::STATUS_PENDING,
]);
}
}
}
}