feat: 문서 관리 시스템 Service 구현 (Phase 1.5)
- DocumentService 생성 (CRUD + 결재 워크플로우) - 순차 결재 로직 구현 (submit/approve/reject/cancel) - Multi-tenancy 및 DB 트랜잭션 지원 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
524
app/Services/DocumentService.php
Normal file
524
app/Services/DocumentService.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Documents\DocumentApproval;
|
||||
use App\Models\Documents\DocumentAttachment;
|
||||
use App\Models\Documents\DocumentData;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class DocumentService extends Service
|
||||
{
|
||||
// =========================================================================
|
||||
// 문서 목록/상세
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 문서 목록 조회
|
||||
*/
|
||||
public function list(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with([
|
||||
'template:id,name,category',
|
||||
'creator:id,name',
|
||||
]);
|
||||
|
||||
// 상태 필터
|
||||
if (! empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
// 템플릿 필터
|
||||
if (! empty($params['template_id'])) {
|
||||
$query->where('template_id', $params['template_id']);
|
||||
}
|
||||
|
||||
// 검색 (문서번호, 제목)
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('document_no', 'like', "%{$search}%")
|
||||
->orWhere('title', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (! empty($params['from_date'])) {
|
||||
$query->whereDate('created_at', '>=', $params['from_date']);
|
||||
}
|
||||
if (! empty($params['to_date'])) {
|
||||
$query->whereDate('created_at', '<=', $params['to_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 show(int $id): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with([
|
||||
'template:id,name,category,title',
|
||||
'template.approvalLines',
|
||||
'template.basicFields',
|
||||
'template.sections.items',
|
||||
'template.columns',
|
||||
'approvals.user:id,name',
|
||||
'data',
|
||||
'attachments.file',
|
||||
'creator:id,name',
|
||||
'updater:id,name',
|
||||
])
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 문서 생성/수정/삭제
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 문서 생성
|
||||
*/
|
||||
public function create(array $data): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||
// 문서번호 생성
|
||||
$documentNo = $this->generateDocumentNo($tenantId, $data['template_id']);
|
||||
|
||||
// 문서 생성
|
||||
$document = Document::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'template_id' => $data['template_id'],
|
||||
'document_no' => $documentNo,
|
||||
'title' => $data['title'],
|
||||
'status' => Document::STATUS_DRAFT,
|
||||
'linkable_type' => $data['linkable_type'] ?? null,
|
||||
'linkable_id' => $data['linkable_id'] ?? null,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 결재선 생성
|
||||
if (! empty($data['approvers'])) {
|
||||
$this->createApprovals($document, $data['approvers'], $userId);
|
||||
}
|
||||
|
||||
// 문서 데이터 저장
|
||||
if (! empty($data['data'])) {
|
||||
$this->saveDocumentData($document, $data['data']);
|
||||
}
|
||||
|
||||
// 첨부파일 연결
|
||||
if (! empty($data['attachments'])) {
|
||||
$this->attachFiles($document, $data['attachments'], $userId);
|
||||
}
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
'data',
|
||||
'attachments.file',
|
||||
'creator:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 수정
|
||||
*/
|
||||
public function update(int $id, array $data): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 수정 가능 상태 확인
|
||||
if (! $document->canEdit()) {
|
||||
throw new BadRequestHttpException(__('error.document.not_editable'));
|
||||
}
|
||||
|
||||
// 기본 정보 수정
|
||||
$document->fill([
|
||||
'title' => $data['title'] ?? $document->title,
|
||||
'linkable_type' => $data['linkable_type'] ?? $document->linkable_type,
|
||||
'linkable_id' => $data['linkable_id'] ?? $document->linkable_id,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 반려 상태에서 수정 시 DRAFT로 변경
|
||||
if ($document->status === Document::STATUS_REJECTED) {
|
||||
$document->status = Document::STATUS_DRAFT;
|
||||
$document->submitted_at = null;
|
||||
$document->completed_at = null;
|
||||
}
|
||||
|
||||
$document->save();
|
||||
|
||||
// 결재선 수정
|
||||
if (isset($data['approvers'])) {
|
||||
$document->approvals()->delete();
|
||||
if (! empty($data['approvers'])) {
|
||||
$this->createApprovals($document, $data['approvers'], $userId);
|
||||
}
|
||||
}
|
||||
|
||||
// 문서 데이터 수정
|
||||
if (isset($data['data'])) {
|
||||
$document->data()->delete();
|
||||
if (! empty($data['data'])) {
|
||||
$this->saveDocumentData($document, $data['data']);
|
||||
}
|
||||
}
|
||||
|
||||
// 첨부파일 수정
|
||||
if (isset($data['attachments'])) {
|
||||
$document->attachments()->delete();
|
||||
if (! empty($data['attachments'])) {
|
||||
$this->attachFiles($document, $data['attachments'], $userId);
|
||||
}
|
||||
}
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
'data',
|
||||
'attachments.file',
|
||||
'creator:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 삭제
|
||||
*/
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// DRAFT 상태만 삭제 가능
|
||||
if ($document->status !== Document::STATUS_DRAFT) {
|
||||
throw new BadRequestHttpException(__('error.document.not_deletable'));
|
||||
}
|
||||
|
||||
$document->deleted_by = $userId;
|
||||
$document->save();
|
||||
$document->delete();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 결재 처리
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재 요청 (DRAFT → PENDING)
|
||||
*/
|
||||
public function submit(int $id): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 결재 요청 가능 상태 확인
|
||||
if (! $document->canSubmit()) {
|
||||
throw new BadRequestHttpException(__('error.document.not_submittable'));
|
||||
}
|
||||
|
||||
// 결재선 존재 확인
|
||||
$approvalCount = $document->approvals()->count();
|
||||
if ($approvalCount === 0) {
|
||||
throw new BadRequestHttpException(__('error.document.approvers_required'));
|
||||
}
|
||||
|
||||
$document->status = Document::STATUS_PENDING;
|
||||
$document->submitted_at = now();
|
||||
$document->updated_by = $userId;
|
||||
$document->save();
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
'creator:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인
|
||||
*/
|
||||
public function approve(int $id, ?string $comment = null): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $comment, $tenantId, $userId) {
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 결재 가능 상태 확인
|
||||
if (! $document->canApprove()) {
|
||||
throw new BadRequestHttpException(__('error.document.not_approvable'));
|
||||
}
|
||||
|
||||
// 현재 사용자의 대기 중인 결재 단계 찾기
|
||||
$myApproval = $document->approvals()
|
||||
->where('user_id', $userId)
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->orderBy('step')
|
||||
->first();
|
||||
|
||||
if (! $myApproval) {
|
||||
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
||||
}
|
||||
|
||||
// 순차 결재 확인 (이전 단계가 완료되었는지)
|
||||
$pendingBefore = $document->approvals()
|
||||
->where('step', '<', $myApproval->step)
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->exists();
|
||||
|
||||
if ($pendingBefore) {
|
||||
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
||||
}
|
||||
|
||||
// 승인 처리
|
||||
$myApproval->status = DocumentApproval::STATUS_APPROVED;
|
||||
$myApproval->comment = $comment;
|
||||
$myApproval->acted_at = now();
|
||||
$myApproval->updated_by = $userId;
|
||||
$myApproval->save();
|
||||
|
||||
// 모든 결재 완료 확인
|
||||
$allApproved = ! $document->approvals()
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->exists();
|
||||
|
||||
if ($allApproved) {
|
||||
$document->status = Document::STATUS_APPROVED;
|
||||
$document->completed_at = now();
|
||||
}
|
||||
|
||||
$document->updated_by = $userId;
|
||||
$document->save();
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
'creator:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려
|
||||
*/
|
||||
public function reject(int $id, string $comment): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $comment, $tenantId, $userId) {
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 결재 가능 상태 확인
|
||||
if (! $document->canApprove()) {
|
||||
throw new BadRequestHttpException(__('error.document.not_approvable'));
|
||||
}
|
||||
|
||||
// 현재 사용자의 대기 중인 결재 단계 찾기
|
||||
$myApproval = $document->approvals()
|
||||
->where('user_id', $userId)
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->orderBy('step')
|
||||
->first();
|
||||
|
||||
if (! $myApproval) {
|
||||
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
||||
}
|
||||
|
||||
// 순차 결재 확인
|
||||
$pendingBefore = $document->approvals()
|
||||
->where('step', '<', $myApproval->step)
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->exists();
|
||||
|
||||
if ($pendingBefore) {
|
||||
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
||||
}
|
||||
|
||||
// 반려 처리
|
||||
$myApproval->status = DocumentApproval::STATUS_REJECTED;
|
||||
$myApproval->comment = $comment;
|
||||
$myApproval->acted_at = now();
|
||||
$myApproval->updated_by = $userId;
|
||||
$myApproval->save();
|
||||
|
||||
// 문서 반려 상태로 변경
|
||||
$document->status = Document::STATUS_REJECTED;
|
||||
$document->completed_at = now();
|
||||
$document->updated_by = $userId;
|
||||
$document->save();
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
'creator:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 취소/회수 (작성자만)
|
||||
*/
|
||||
public function cancel(int $id): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 취소 가능 상태 확인
|
||||
if (! $document->canCancel()) {
|
||||
throw new BadRequestHttpException(__('error.document.not_cancellable'));
|
||||
}
|
||||
|
||||
// 작성자만 취소 가능
|
||||
if ($document->created_by !== $userId) {
|
||||
throw new BadRequestHttpException(__('error.document.only_creator_can_cancel'));
|
||||
}
|
||||
|
||||
$document->status = Document::STATUS_CANCELLED;
|
||||
$document->completed_at = now();
|
||||
$document->updated_by = $userId;
|
||||
$document->save();
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'creator:id,name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 문서번호 생성
|
||||
*/
|
||||
private function generateDocumentNo(int $tenantId, int $templateId): string
|
||||
{
|
||||
$prefix = 'DOC';
|
||||
$date = now()->format('Ymd');
|
||||
|
||||
// 오늘 생성된 문서 중 마지막 번호 조회
|
||||
$lastNumber = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('document_no', 'like', "{$prefix}-{$date}-%")
|
||||
->orderByDesc('document_no')
|
||||
->value('document_no');
|
||||
|
||||
if ($lastNumber) {
|
||||
$sequence = (int) substr($lastNumber, -4) + 1;
|
||||
} else {
|
||||
$sequence = 1;
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%04d', $prefix, $date, $sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 생성
|
||||
*/
|
||||
private function createApprovals(Document $document, array $approvers, int $userId): void
|
||||
{
|
||||
foreach ($approvers as $index => $approver) {
|
||||
DocumentApproval::create([
|
||||
'document_id' => $document->id,
|
||||
'user_id' => $approver['user_id'],
|
||||
'step' => $index + 1,
|
||||
'role' => $approver['role'] ?? '승인',
|
||||
'status' => DocumentApproval::STATUS_PENDING,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 데이터 저장
|
||||
*/
|
||||
private function saveDocumentData(Document $document, array $dataItems): void
|
||||
{
|
||||
foreach ($dataItems as $item) {
|
||||
DocumentData::create([
|
||||
'document_id' => $document->id,
|
||||
'section_id' => $item['section_id'] ?? null,
|
||||
'column_id' => $item['column_id'] ?? null,
|
||||
'row_index' => $item['row_index'] ?? 0,
|
||||
'field_key' => $item['field_key'],
|
||||
'field_value' => $item['field_value'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 연결
|
||||
*/
|
||||
private function attachFiles(Document $document, array $attachments, int $userId): void
|
||||
{
|
||||
foreach ($attachments as $attachment) {
|
||||
DocumentAttachment::create([
|
||||
'document_id' => $document->id,
|
||||
'file_id' => $attachment['file_id'],
|
||||
'attachment_type' => $attachment['attachment_type'] ?? DocumentAttachment::TYPE_GENERAL,
|
||||
'description' => $attachment['description'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user