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:
2026-03-05 11:00:53 +09:00
parent ef7d9fae24
commit cd847e01a0
4 changed files with 197 additions and 1 deletions

View File

@@ -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();
}
/**
* 생성자
*/

View File

@@ -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,
]);
}
/**
* 참조 열람 처리
*/

View File

@@ -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,
]);
}
}
/**
* 결재 승인
*/

View File

@@ -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']);
});
}
};