diff --git a/app/Http/Controllers/V1/ProcessStepController.php b/app/Http/Controllers/V1/ProcessStepController.php new file mode 100644 index 0000000..7b48a2a --- /dev/null +++ b/app/Http/Controllers/V1/ProcessStepController.php @@ -0,0 +1,84 @@ + $this->processStepService->index($processId), + 'message.fetched' + ); + } + + /** + * 공정 단계 상세 조회 + */ + public function show(int $processId, int $stepId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->processStepService->show($processId, $stepId), + 'message.fetched' + ); + } + + /** + * 공정 단계 생성 + */ + public function store(StoreProcessStepRequest $request, int $processId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->processStepService->store($processId, $request->validated()), + 'message.created' + ); + } + + /** + * 공정 단계 수정 + */ + public function update(UpdateProcessStepRequest $request, int $processId, int $stepId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->processStepService->update($processId, $stepId, $request->validated()), + 'message.updated' + ); + } + + /** + * 공정 단계 삭제 + */ + public function destroy(int $processId, int $stepId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->processStepService->destroy($processId, $stepId), + 'message.deleted' + ); + } + + /** + * 공정 단계 순서 변경 + */ + public function reorder(ReorderProcessStepRequest $request, int $processId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->processStepService->reorder($processId, $request->validated('items')), + 'message.reordered' + ); + } +} diff --git a/app/Http/Requests/V1/ProcessStep/ReorderProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/ReorderProcessStepRequest.php new file mode 100644 index 0000000..8e254e9 --- /dev/null +++ b/app/Http/Requests/V1/ProcessStep/ReorderProcessStepRequest.php @@ -0,0 +1,31 @@ + ['required', 'array', 'min:1'], + 'items.*.id' => ['required', 'integer'], + 'items.*.sort_order' => ['required', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'items' => '정렬 항목', + 'items.*.id' => '단계 ID', + 'items.*.sort_order' => '정렬순서', + ]; + } +} diff --git a/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php new file mode 100644 index 0000000..543bf95 --- /dev/null +++ b/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php @@ -0,0 +1,41 @@ + ['required', 'string', 'max:100'], + 'is_required' => ['nullable', 'boolean'], + 'needs_approval' => ['nullable', 'boolean'], + 'needs_inspection' => ['nullable', 'boolean'], + 'is_active' => ['nullable', 'boolean'], + 'connection_type' => ['nullable', 'string', 'max:20'], + 'connection_target' => ['nullable', 'string', 'max:255'], + 'completion_type' => ['nullable', 'string', 'max:30'], + ]; + } + + public function attributes(): array + { + return [ + 'step_name' => '단계명', + 'is_required' => '필수여부', + 'needs_approval' => '승인필요여부', + 'needs_inspection' => '검사필요여부', + 'is_active' => '사용여부', + 'connection_type' => '연결유형', + 'connection_target' => '연결대상', + 'completion_type' => '완료유형', + ]; + } +} diff --git a/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php new file mode 100644 index 0000000..0fc3ca7 --- /dev/null +++ b/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php @@ -0,0 +1,41 @@ + ['sometimes', 'required', 'string', 'max:100'], + 'is_required' => ['nullable', 'boolean'], + 'needs_approval' => ['nullable', 'boolean'], + 'needs_inspection' => ['nullable', 'boolean'], + 'is_active' => ['nullable', 'boolean'], + 'connection_type' => ['nullable', 'string', 'max:20'], + 'connection_target' => ['nullable', 'string', 'max:255'], + 'completion_type' => ['nullable', 'string', 'max:30'], + ]; + } + + public function attributes(): array + { + return [ + 'step_name' => '단계명', + 'is_required' => '필수여부', + 'needs_approval' => '승인필요여부', + 'needs_inspection' => '검사필요여부', + 'is_active' => '사용여부', + 'connection_type' => '연결유형', + 'connection_target' => '연결대상', + 'completion_type' => '완료유형', + ]; + } +} diff --git a/app/Models/Process.php b/app/Models/Process.php index 49ad213..0c222b4 100644 --- a/app/Models/Process.php +++ b/app/Models/Process.php @@ -69,6 +69,14 @@ public function items(): BelongsToMany ->orderByPivot('priority'); } + /** + * 공정 단계 + */ + public function steps(): HasMany + { + return $this->hasMany(ProcessStep::class)->orderBy('sort_order'); + } + /** * 작업지시들 */ diff --git a/app/Models/ProcessStep.php b/app/Models/ProcessStep.php new file mode 100644 index 0000000..953fda9 --- /dev/null +++ b/app/Models/ProcessStep.php @@ -0,0 +1,42 @@ + 'boolean', + 'needs_approval' => 'boolean', + 'needs_inspection' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 공정 + */ + public function process(): BelongsTo + { + return $this->belongsTo(Process::class); + } +} diff --git a/app/Services/ProcessService.php b/app/Services/ProcessService.php index 34fec91..06e3688 100644 --- a/app/Services/ProcessService.php +++ b/app/Services/ProcessService.php @@ -25,7 +25,7 @@ public function index(array $params) $query = Process::query() ->where('tenant_id', $tenantId) - ->with(['classificationRules', 'processItems.item:id,code,name']); + ->with(['classificationRules', 'processItems.item:id,code,name', 'steps']); // 검색어 if ($q !== '') { @@ -62,7 +62,7 @@ public function show(int $id) $tenantId = $this->tenantId(); $process = Process::where('tenant_id', $tenantId) - ->with(['classificationRules', 'processItems.item:id,code,name']) + ->with(['classificationRules', 'processItems.item:id,code,name', 'steps']) ->find($id); if (! $process) { @@ -104,7 +104,7 @@ public function store(array $data) // 개별 품목 연결 $this->syncProcessItems($process, $itemIds); - return $process->load(['classificationRules', 'processItems.item:id,code,name']); + return $process->load(['classificationRules', 'processItems.item:id,code,name', 'steps']); }); } @@ -145,7 +145,7 @@ public function update(int $id, array $data) $this->syncProcessItems($process, $itemIds); } - return $process->fresh(['classificationRules', 'processItems.item:id,code,name']); + return $process->fresh(['classificationRules', 'processItems.item:id,code,name', 'steps']); }); } @@ -201,7 +201,7 @@ public function toggleActive(int $id) 'updated_by' => $userId, ]); - return $process->fresh(['classificationRules', 'processItems.item:id,code,name']); + return $process->fresh(['classificationRules', 'processItems.item:id,code,name', 'steps']); } /** diff --git a/app/Services/ProcessStepService.php b/app/Services/ProcessStepService.php new file mode 100644 index 0000000..44fc288 --- /dev/null +++ b/app/Services/ProcessStepService.php @@ -0,0 +1,140 @@ +findProcess($processId); + + return $process->steps() + ->orderBy('sort_order') + ->get(); + } + + /** + * 공정 단계 상세 조회 + */ + public function show(int $processId, int $stepId) + { + $this->findProcess($processId); + + $step = ProcessStep::where('process_id', $processId)->find($stepId); + if (! $step) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $step; + } + + /** + * 공정 단계 생성 + */ + public function store(int $processId, array $data) + { + $process = $this->findProcess($processId); + + $data['process_id'] = $process->id; + $data['step_code'] = $this->generateStepCode($process->id); + $data['sort_order'] = ($process->steps()->max('sort_order') ?? 0) + 1; + $data['is_active'] = $data['is_active'] ?? true; + + return ProcessStep::create($data); + } + + /** + * 공정 단계 수정 + */ + public function update(int $processId, int $stepId, array $data) + { + $this->findProcess($processId); + + $step = ProcessStep::where('process_id', $processId)->find($stepId); + if (! $step) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $step->update($data); + + return $step->fresh(); + } + + /** + * 공정 단계 삭제 + */ + public function destroy(int $processId, int $stepId) + { + $this->findProcess($processId); + + $step = ProcessStep::where('process_id', $processId)->find($stepId); + if (! $step) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $step->delete(); + + return true; + } + + /** + * 공정 단계 순서 변경 + */ + public function reorder(int $processId, array $items) + { + $this->findProcess($processId); + + return DB::transaction(function () use ($processId, $items) { + foreach ($items as $item) { + ProcessStep::where('process_id', $processId) + ->where('id', $item['id']) + ->update(['sort_order' => $item['sort_order']]); + } + + return ProcessStep::where('process_id', $processId) + ->orderBy('sort_order') + ->get(); + }); + } + + /** + * 부모 공정 조회 (tenant scope) + */ + private function findProcess(int $processId): Process + { + $tenantId = $this->tenantId(); + + $process = Process::where('tenant_id', $tenantId)->find($processId); + if (! $process) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $process; + } + + /** + * 단계코드 자동 생성 (STP-001, STP-002, ...) + */ + private function generateStepCode(int $processId): string + { + $lastStep = ProcessStep::where('process_id', $processId) + ->orderByRaw('CAST(SUBSTRING(step_code, 5) AS UNSIGNED) DESC') + ->first(); + + if ($lastStep && preg_match('/^STP-(\d+)$/', $lastStep->step_code, $matches)) { + $nextNum = (int) $matches[1] + 1; + } else { + $nextNum = 1; + } + + return sprintf('STP-%03d', $nextNum); + } +} diff --git a/database/migrations/2026_02_03_000001_create_process_steps_table.php b/database/migrations/2026_02_03_000001_create_process_steps_table.php new file mode 100644 index 0000000..aa7dffc --- /dev/null +++ b/database/migrations/2026_02_03_000001_create_process_steps_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('process_id') + ->comment('공정 ID') + ->constrained('processes') + ->cascadeOnDelete(); + $table->string('step_code', 20)->comment('단계코드 (STP-001)'); + $table->string('step_name', 100)->comment('단계명'); + $table->boolean('is_required')->default(false)->comment('필수여부'); + $table->boolean('needs_approval')->default(false)->comment('승인필요여부'); + $table->boolean('needs_inspection')->default(false)->comment('검사필요여부'); + $table->boolean('is_active')->default(true)->comment('사용여부'); + $table->unsignedInteger('sort_order')->default(0)->comment('정렬순서'); + $table->string('connection_type', 20)->nullable()->comment('연결유형 (팝업/없음)'); + $table->string('connection_target', 255)->nullable()->comment('연결대상'); + $table->string('completion_type', 30)->nullable()->comment('완료유형 (선택완료시완료/클릭시완료)'); + $table->timestamps(); + + $table->unique(['process_id', 'step_code']); + $table->index(['process_id', 'is_active', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('process_steps'); + } +}; diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 92aea15..5574549 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\V1\WorkOrderController; use App\Http\Controllers\Api\V1\WorkResultController; use App\Http\Controllers\V1\ProcessController; +use App\Http\Controllers\V1\ProcessStepController; use Illuminate\Support\Facades\Route; // Process API (공정 관리) @@ -26,6 +27,16 @@ Route::put('/{id}', [ProcessController::class, 'update'])->whereNumber('id')->name('v1.processes.update'); Route::delete('/{id}', [ProcessController::class, 'destroy'])->whereNumber('id')->name('v1.processes.destroy'); Route::patch('/{id}/toggle', [ProcessController::class, 'toggleActive'])->whereNumber('id')->name('v1.processes.toggle'); + + // Process Steps (공정 단계) + Route::prefix('{processId}/steps')->whereNumber('processId')->group(function () { + Route::get('', [ProcessStepController::class, 'index'])->name('v1.processes.steps.index'); + Route::post('', [ProcessStepController::class, 'store'])->name('v1.processes.steps.store'); + Route::patch('/reorder', [ProcessStepController::class, 'reorder'])->name('v1.processes.steps.reorder'); + Route::get('/{stepId}', [ProcessStepController::class, 'show'])->whereNumber('stepId')->name('v1.processes.steps.show'); + Route::put('/{stepId}', [ProcessStepController::class, 'update'])->whereNumber('stepId')->name('v1.processes.steps.update'); + Route::delete('/{stepId}', [ProcessStepController::class, 'destroy'])->whereNumber('stepId')->name('v1.processes.steps.destroy'); + }); }); // Work Order API (작업지시 관리)