feat: [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
- Approval 모델에 linkable morphTo 관계 추가 - DocumentService: 상신 시 Approval 자동 생성 + approval_steps 변환 - ApprovalService: 승인/반려/회수 시 Document 상태 동기화 - approvals 테이블에 linkable_type, linkable_id 컬럼 마이그레이션 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,8 @@ class Approval extends Model
|
||||
'completed_at',
|
||||
'current_step',
|
||||
'attachments',
|
||||
'linkable_type',
|
||||
'linkable_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -135,6 +138,14 @@ public function referenceSteps(): HasMany
|
||||
->orderBy('step_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 대상 (Document 등)
|
||||
*/
|
||||
public function linkable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Tenants\ApprovalForm;
|
||||
use App\Models\Tenants\ApprovalLine;
|
||||
@@ -567,7 +568,7 @@ public function show(int $id): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return Approval::query()
|
||||
$approval = Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with([
|
||||
'form:id,name,code,category,template',
|
||||
@@ -579,6 +580,19 @@ public function show(int $id): Approval
|
||||
'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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,6 +856,9 @@ public function approve(int $id, ?string $comment = null): Approval
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
@@ -895,6 +912,9 @@ public function reject(int $id, string $comment): Approval
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
@@ -934,6 +954,9 @@ public function cancel(int $id): Approval
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화 (steps 삭제 전에 실행)
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
// 결재 단계들 삭제
|
||||
$approval->steps()->delete();
|
||||
|
||||
@@ -944,6 +967,57 @@ public function cancel(int $id): Approval
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 열람 처리
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
use App\Models\Documents\DocumentAttachment;
|
||||
use App\Models\Documents\DocumentData;
|
||||
use App\Models\Documents\DocumentTemplate;
|
||||
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\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
@@ -275,6 +279,9 @@ public function submit(int $id): Document
|
||||
$document->updated_by = $userId;
|
||||
$document->save();
|
||||
|
||||
// Approval 시스템 브릿지: 결재함(/approval/inbox)에 표시되도록 Approval 자동 생성
|
||||
$this->createApprovalBridge($document);
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
@@ -283,6 +290,78 @@ public function submit(int $id): Document
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Document → Approval 브릿지 생성
|
||||
* Document 상신 시 Approval 레코드를 자동 생성하여 /approval/inbox에 표시
|
||||
*/
|
||||
private function createApprovalBridge(Document $document): void
|
||||
{
|
||||
$form = ApprovalForm::where('code', 'document')
|
||||
->where('tenant_id', $document->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $form) {
|
||||
return; // 문서 결재 양식 미등록 시 스킵 (기존 동작 유지)
|
||||
}
|
||||
|
||||
// 기존 브릿지가 있으면 스킵 (재상신 방지)
|
||||
$existingApproval = Approval::where('linkable_type', Document::class)
|
||||
->where('linkable_id', $document->id)
|
||||
->whereNotIn('status', [Approval::STATUS_CANCELLED])
|
||||
->first();
|
||||
|
||||
if ($existingApproval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 문서번호 생성 (Approval 체계)
|
||||
$today = now()->format('Ymd');
|
||||
$lastNumber = Approval::where('tenant_id', $document->tenant_id)
|
||||
->where('document_number', 'like', "AP-{$today}-%")
|
||||
->orderByDesc('document_number')
|
||||
->value('document_number');
|
||||
|
||||
$seq = 1;
|
||||
if ($lastNumber && preg_match('/AP-\d{8}-(\d{4})/', $lastNumber, $matches)) {
|
||||
$seq = (int) $matches[1] + 1;
|
||||
}
|
||||
$documentNumber = sprintf('AP-%s-%04d', $today, $seq);
|
||||
|
||||
$approval = Approval::create([
|
||||
'tenant_id' => $document->tenant_id,
|
||||
'document_number' => $documentNumber,
|
||||
'form_id' => $form->id,
|
||||
'title' => $document->title,
|
||||
'content' => [
|
||||
'document_id' => $document->id,
|
||||
'template_id' => $document->template_id,
|
||||
'document_no' => $document->document_no,
|
||||
],
|
||||
'status' => Approval::STATUS_PENDING,
|
||||
'drafter_id' => $document->created_by,
|
||||
'drafted_at' => now(),
|
||||
'current_step' => 1,
|
||||
'linkable_type' => Document::class,
|
||||
'linkable_id' => $document->id,
|
||||
'created_by' => $document->updated_by ?? $document->created_by,
|
||||
]);
|
||||
|
||||
// document_approvals → approval_steps 변환
|
||||
$docApprovals = $document->approvals()
|
||||
->orderBy('step')
|
||||
->get();
|
||||
|
||||
foreach ($docApprovals as $docApproval) {
|
||||
ApprovalStep::create([
|
||||
'approval_id' => $approval->id,
|
||||
'step_order' => $docApproval->step,
|
||||
'step_type' => ApprovalLine::STEP_TYPE_APPROVAL,
|
||||
'approver_id' => $docApproval->user_id,
|
||||
'status' => ApprovalStep::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 결재(approvals) 테이블에 연결 대상(linkable) 컬럼 추가
|
||||
* - Document 시스템과 Approval 시스템을 브릿지하기 위한 다형성 관계
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('approvals', function (Blueprint $table) {
|
||||
$table->string('linkable_type')->nullable()->after('attachments')->comment('연결 대상 모델 (예: App\\Models\\Documents\\Document)');
|
||||
$table->unsignedBigInteger('linkable_id')->nullable()->after('linkable_type')->comment('연결 대상 ID');
|
||||
$table->index(['linkable_type', 'linkable_id'], 'idx_linkable');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('approvals', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_linkable');
|
||||
$table->dropColumn(['linkable_type', 'linkable_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user