feat: [material] 부적합관리 결재 연동 구현

- Migration: approval_id FK 추가
- Model: approval() BelongsTo 관계
- Service: submitForApproval() 결재상신 (결재문서+결재선 생성)
- ApprovalService: 승인→CLOSED, 반려/회수→approval_id 해제
- Controller: POST /{id}/submit-approval 엔드포인트
- Route: submit-approval 라우트 등록
This commit is contained in:
김보곤
2026-03-19 09:03:12 +09:00
parent 847c60b03d
commit 6e50fbd1fa
7 changed files with 210 additions and 0 deletions

View File

@@ -66,4 +66,19 @@ public function changeStatus(Request $request, int $id): JsonResponse
return $this->service->changeStatus($id, $request->input('status'));
}, __('message.updated'));
}
public function submitApproval(Request $request, int $id): JsonResponse
{
$request->validate([
'title' => 'nullable|string|max:200',
'form_id' => 'nullable|integer',
'steps' => 'required|array|min:1',
'steps.*.approver_id' => 'required|integer',
'steps.*.step_type' => 'nullable|string|in:approval,agreement,reference',
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->submitForApproval($id, $request->all());
}, __('message.created'));
}
}

View File

@@ -6,6 +6,7 @@
use App\Models\Departments\Department;
use App\Models\Items\Item;
use App\Models\Orders\Order;
use App\Models\Tenants\Approval;
use App\Models\Users\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
@@ -24,6 +25,7 @@ class NonconformingReport extends Model
'tenant_id',
'nc_number',
'status',
'approval_id',
'nc_type',
'occurred_at',
'confirmed_at',
@@ -104,6 +106,11 @@ public function items(): HasMany
return $this->hasMany(NonconformingReportItem::class)->orderBy('sort_order');
}
public function approval(): BelongsTo
{
return $this->belongsTo(Approval::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);

View File

@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Documents\Document;
use App\Models\Materials\NonconformingReport;
use App\Models\Members\User;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalDelegation;
@@ -903,6 +904,7 @@ public function approve(int $id, ?string $comment = null): Approval
// Leave 연동 (승인 완료 시)
if ($approval->status === Approval::STATUS_APPROVED) {
$this->handleApprovalCompleted($approval);
$this->handleNonconformingApproved($approval);
}
return $approval->fresh([
@@ -964,6 +966,7 @@ public function reject(int $id, string $comment): Approval
// Leave 연동 (반려 시)
$this->handleApprovalRejected($approval, $comment);
$this->handleNonconformingRejected($approval);
return $approval->fresh([
'form:id,name,code,category',
@@ -1027,6 +1030,7 @@ public function cancel(int $id, ?string $recallReason = null): Approval
// Leave 연동 (회수 시)
$this->handleApprovalCancelled($approval);
$this->handleNonconformingCancelled($approval);
return $approval->fresh([
'form:id,name,code,category',
@@ -1172,6 +1176,7 @@ public function preDecide(int $id, ?string $comment = null): Approval
// Leave 연동 (승인 완료)
$this->handleApprovalCompleted($approval);
$this->handleNonconformingApproved($approval);
return $approval->fresh([
'form:id,name,code,category',
@@ -1732,6 +1737,64 @@ private function createLeaveFromApproval(Approval $approval): Leave
]);
}
// =========================================================================
// 부적합관리 연동
// =========================================================================
/**
* 부적합 결재 승인 시 → CLOSED
*/
private function handleNonconformingApproved(Approval $approval): void
{
if ($approval->linkable_type !== NonconformingReport::class) {
return;
}
$report = NonconformingReport::find($approval->linkable_id);
if ($report && $report->status === NonconformingReport::STATUS_RESOLVED) {
$report->update([
'status' => NonconformingReport::STATUS_CLOSED,
'updated_by' => $approval->updated_by,
]);
}
}
/**
* 부적합 결재 반려 시 → RESOLVED로 유지 (재상신 가능)
*/
private function handleNonconformingRejected(Approval $approval): void
{
if ($approval->linkable_type !== NonconformingReport::class) {
return;
}
$report = NonconformingReport::find($approval->linkable_id);
if ($report) {
$report->update([
'approval_id' => null,
'updated_by' => $approval->updated_by,
]);
}
}
/**
* 부적합 결재 회수 시 → 결재 연결 해제
*/
private function handleNonconformingCancelled(Approval $approval): void
{
if ($approval->linkable_type !== NonconformingReport::class) {
return;
}
$report = NonconformingReport::find($approval->linkable_id);
if ($report) {
$report->update([
'approval_id' => null,
'updated_by' => $approval->updated_by,
]);
}
}
// =========================================================================
// 위임 관리
// =========================================================================

View File

@@ -4,6 +4,9 @@
use App\Models\Materials\NonconformingReport;
use App\Models\Materials\NonconformingReportItem;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalLine;
use App\Models\Tenants\ApprovalStep;
use Illuminate\Support\Facades\DB;
class NonconformingReportService extends Service
@@ -68,6 +71,9 @@ public function show(int $id): NonconformingReport
'actionManager:id,name',
'relatedEmployee:id,name',
'files',
'approval:id,document_number,status,completed_at',
'approval.steps:id,approval_id,step_order,step_type,approver_id,status,comment,acted_at',
'approval.steps.approver:id,name',
])
->findOrFail($id);
}
@@ -235,6 +241,80 @@ public function stats(array $params): array
];
}
/**
* 결재상신 (RESOLVED 상태에서만 가능)
*/
public function submitForApproval(int $id, array $data): NonconformingReport
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$report = NonconformingReport::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if ($report->status !== NonconformingReport::STATUS_RESOLVED) {
abort(422, __('error.nonconforming.must_be_resolved_for_approval'));
}
if ($report->approval_id) {
abort(422, __('error.nonconforming.approval_already_exists'));
}
$steps = $data['steps'] ?? [];
if (empty($steps)) {
abort(422, __('error.approval.steps_required'));
}
// 결재 문서 생성
$approval = Approval::create([
'tenant_id' => $tenantId,
'document_number' => $this->generateApprovalNumber($tenantId),
'form_id' => $data['form_id'] ?? null,
'title' => $data['title'] ?? "부적합 처리 결재 - {$report->nc_number}",
'content' => [
'nc_number' => $report->nc_number,
'nc_type' => $report->nc_type,
'site_name' => $report->site_name,
'defect_description' => $report->defect_description,
'cause_analysis' => $report->cause_analysis,
'corrective_action' => $report->corrective_action,
'total_cost' => $report->total_cost,
],
'status' => Approval::STATUS_PENDING,
'drafter_id' => $userId,
'department_id' => $report->department_id,
'drafted_at' => now(),
'current_step' => 1,
'linkable_type' => NonconformingReport::class,
'linkable_id' => $report->id,
'created_by' => $userId,
'updated_by' => $userId,
]);
// 결재선 단계 생성
foreach ($steps as $index => $step) {
ApprovalStep::create([
'tenant_id' => $tenantId,
'approval_id' => $approval->id,
'step_order' => $index + 1,
'step_type' => $step['step_type'] ?? ApprovalLine::STEP_TYPE_APPROVAL,
'approver_id' => $step['approver_id'],
'status' => ApprovalStep::STATUS_PENDING,
]);
}
// 보고서에 결재 연결
$report->update([
'approval_id' => $approval->id,
'updated_by' => $userId,
]);
return $this->show($report->id);
});
}
// ── private ──
/**
@@ -298,6 +378,26 @@ private function sumItemAmounts(array $items): int
return $total;
}
/**
* 결재 문서번호 생성
*/
private function generateApprovalNumber(int $tenantId): string
{
$prefix = 'AP';
$date = now()->format('Ymd');
$pattern = "{$prefix}-{$date}-";
$lastNumber = Approval::withTrashed()
->where('tenant_id', $tenantId)
->where('document_number', 'like', "{$pattern}%")
->orderByDesc('document_number')
->value('document_number');
$seq = $lastNumber ? ((int) substr($lastNumber, -4) + 1) : 1;
return sprintf('%s-%s-%04d', $prefix, $date, $seq);
}
/**
* 상태 전이 검증
*/

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nonconforming_reports', function (Blueprint $table) {
$table->unsignedBigInteger('approval_id')->nullable()->after('status')->comment('결재 문서 ID');
});
}
public function down(): void
{
Schema::table('nonconforming_reports', function (Blueprint $table) {
$table->dropColumn('approval_id');
});
}
};

View File

@@ -538,6 +538,8 @@
'closed_cannot_delete' => '종결된 부적합 보고서는 삭제할 수 없습니다.',
'invalid_status_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다.",
'analysis_required' => '조치완료로 변경하려면 원인 분석과 처리 방안을 먼저 입력해야 합니다.',
'must_be_resolved_for_approval' => '조치완료 상태에서만 결재상신할 수 있습니다.',
'approval_already_exists' => '이미 결재가 진행 중입니다.',
],
// 데모 테넌트 관련

View File

@@ -138,6 +138,7 @@
Route::put('/{id}', [NonconformingReportController::class, 'update'])->whereNumber('id')->name('v1.nonconforming-reports.update');
Route::delete('/{id}', [NonconformingReportController::class, 'destroy'])->whereNumber('id')->name('v1.nonconforming-reports.destroy');
Route::patch('/{id}/status', [NonconformingReportController::class, 'changeStatus'])->whereNumber('id')->name('v1.nonconforming-reports.change-status');
Route::post('/{id}/submit-approval', [NonconformingReportController::class, 'submitApproval'])->whereNumber('id')->name('v1.nonconforming-reports.submit-approval');
});
// Vehicle Dispatch API (배차차량 관리)