From 6e50fbd1fa008a59acaf30c7c0bd4dd0ed9571ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 19 Mar 2026 09:03:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[material]=20=EB=B6=80=EC=A0=81?= =?UTF-8?q?=ED=95=A9=EA=B4=80=EB=A6=AC=20=EA=B2=B0=EC=9E=AC=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: approval_id FK 추가 - Model: approval() BelongsTo 관계 - Service: submitForApproval() 결재상신 (결재문서+결재선 생성) - ApprovalService: 승인→CLOSED, 반려/회수→approval_id 해제 - Controller: POST /{id}/submit-approval 엔드포인트 - Route: submit-approval 라우트 등록 --- .../Api/V1/NonconformingReportController.php | 15 +++ app/Models/Materials/NonconformingReport.php | 7 ++ app/Services/ApprovalService.php | 63 +++++++++++ app/Services/NonconformingReportService.php | 100 ++++++++++++++++++ ...oval_id_to_nonconforming_reports_table.php | 22 ++++ lang/ko/error.php | 2 + routes/api/v1/inventory.php | 1 + 7 files changed, 210 insertions(+) create mode 100644 database/migrations/2026_03_19_110000_add_approval_id_to_nonconforming_reports_table.php diff --git a/app/Http/Controllers/Api/V1/NonconformingReportController.php b/app/Http/Controllers/Api/V1/NonconformingReportController.php index 3daf85bd..587783ce 100644 --- a/app/Http/Controllers/Api/V1/NonconformingReportController.php +++ b/app/Http/Controllers/Api/V1/NonconformingReportController.php @@ -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')); + } } diff --git a/app/Models/Materials/NonconformingReport.php b/app/Models/Materials/NonconformingReport.php index 287e0c2f..b7ca4210 100644 --- a/app/Models/Materials/NonconformingReport.php +++ b/app/Models/Materials/NonconformingReport.php @@ -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); diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 456ea192..2eb32824 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -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, + ]); + } + } + // ========================================================================= // 위임 관리 // ========================================================================= diff --git a/app/Services/NonconformingReportService.php b/app/Services/NonconformingReportService.php index 06b715fd..b71aead7 100644 --- a/app/Services/NonconformingReportService.php +++ b/app/Services/NonconformingReportService.php @@ -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); + } + /** * 상태 전이 검증 */ diff --git a/database/migrations/2026_03_19_110000_add_approval_id_to_nonconforming_reports_table.php b/database/migrations/2026_03_19_110000_add_approval_id_to_nonconforming_reports_table.php new file mode 100644 index 00000000..3855af92 --- /dev/null +++ b/database/migrations/2026_03_19_110000_add_approval_id_to_nonconforming_reports_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('approval_id')->nullable()->after('status')->comment('결재 문서 ID'); + }); + } + + public function down(): void + { + Schema::table('nonconforming_reports', function (Blueprint $table) { + $table->dropColumn('approval_id'); + }); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 95c33deb..c914ef12 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -538,6 +538,8 @@ 'closed_cannot_delete' => '종결된 부적합 보고서는 삭제할 수 없습니다.', 'invalid_status_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다.", 'analysis_required' => '조치완료로 변경하려면 원인 분석과 처리 방안을 먼저 입력해야 합니다.', + 'must_be_resolved_for_approval' => '조치완료 상태에서만 결재상신할 수 있습니다.', + 'approval_already_exists' => '이미 결재가 진행 중입니다.', ], // 데모 테넌트 관련 diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 83c45e97..9dd8e624 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -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 (배차차량 관리)