diff --git a/app/Http/Requests/V1/Process/StoreProcessRequest.php b/app/Http/Requests/V1/Process/StoreProcessRequest.php index 68eda989..93e173d7 100644 --- a/app/Http/Requests/V1/Process/StoreProcessRequest.php +++ b/app/Http/Requests/V1/Process/StoreProcessRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + 'parent_id' => ['nullable', 'integer', 'exists:processes,id'], 'process_name' => ['required', 'string', 'max:100'], 'description' => ['nullable', 'string'], 'process_type' => ['required', 'string', 'in:생산,검사,포장,조립'], diff --git a/app/Http/Requests/V1/Process/UpdateProcessRequest.php b/app/Http/Requests/V1/Process/UpdateProcessRequest.php index 36579148..041ae778 100644 --- a/app/Http/Requests/V1/Process/UpdateProcessRequest.php +++ b/app/Http/Requests/V1/Process/UpdateProcessRequest.php @@ -13,7 +13,10 @@ public function authorize(): bool public function rules(): array { + $processId = $this->route('id'); + return [ + 'parent_id' => ['nullable', 'integer', 'exists:processes,id', "not_in:{$processId}"], 'process_name' => ['sometimes', 'required', 'string', 'max:100'], 'description' => ['nullable', 'string'], 'process_type' => ['sometimes', 'required', 'string', 'in:생산,검사,포장,조립'], diff --git a/app/Models/Process.php b/app/Models/Process.php index 6dc1636f..070c69c0 100644 --- a/app/Models/Process.php +++ b/app/Models/Process.php @@ -21,6 +21,7 @@ class Process extends Model protected $fillable = [ 'tenant_id', + 'parent_id', 'process_code', 'process_name', 'description', @@ -47,6 +48,24 @@ class Process extends Model 'required_workers' => 'integer', ]; + /** 부모 공정 */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** 자식 공정 */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('process_code'); + } + + /** 루트 공정만 조회 */ + public function scopeRoots($query) + { + return $query->whereNull('parent_id'); + } + /** * 중간검사 양식 */ diff --git a/app/Services/ProcessService.php b/app/Services/ProcessService.php index 8865cbe5..77ae1e9d 100644 --- a/app/Services/ProcessService.php +++ b/app/Services/ProcessService.php @@ -23,9 +23,11 @@ public function index(array $params) $status = $params['status'] ?? null; $processType = $params['process_type'] ?? null; + $eagerLoad = ['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category', 'parent:id,process_code,process_name', 'children:id,parent_id,process_code,process_name,is_active']; + $query = Process::query() ->where('tenant_id', $tenantId) - ->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']); + ->with($eagerLoad); // 검색어 if ($q !== '') { @@ -62,7 +64,7 @@ public function show(int $id) $tenantId = $this->tenantId(); $process = Process::where('tenant_id', $tenantId) - ->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']) + ->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category', 'parent:id,process_code,process_name', 'children:id,parent_id,process_code,process_name,is_active']) ->find($id); if (! $process) { @@ -81,6 +83,16 @@ public function store(array $data) $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { + // 2depth 제한: 부모가 이미 자식이면 거부 + if (! empty($data['parent_id'])) { + $parent = Process::find($data['parent_id']); + if ($parent && $parent->parent_id) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['2단계까지만 허용됩니다. 선택한 부모 공정이 이미 하위 공정입니다.'], + ]); + } + } + // 공정코드 자동 생성 $data['process_code'] = $this->generateProcessCode($tenantId); $data['tenant_id'] = $tenantId; @@ -122,6 +134,22 @@ public function update(int $id, array $data) } return DB::transaction(function () use ($process, $data, $userId) { + // parent_id 변경 시 2depth + 순환 참조 검증 + if (array_key_exists('parent_id', $data) && $data['parent_id']) { + $parent = Process::find($data['parent_id']); + if ($parent && $parent->parent_id) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['2단계까지만 허용됩니다.'], + ]); + } + // 자기 자식을 부모로 설정하는 것 방지 + if ($process->children()->where('id', $data['parent_id'])->exists()) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['하위 공정을 부모로 설정할 수 없습니다.'], + ]); + } + } + $data['updated_by'] = $userId; // work_steps가 문자열이면 배열로 변환 @@ -288,6 +316,7 @@ public function duplicate(int $id) $newProcess = Process::create([ 'tenant_id' => $tenantId, + 'parent_id' => $source->parent_id, 'process_code' => $newCode, 'process_name' => $source->process_name.' (복사)', 'description' => $source->description, diff --git a/database/migrations/2026_03_21_152057_add_parent_id_to_processes_table.php b/database/migrations/2026_03_21_152057_add_parent_id_to_processes_table.php new file mode 100644 index 00000000..d45c97f4 --- /dev/null +++ b/database/migrations/2026_03_21_152057_add_parent_id_to_processes_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('parent_id') + ->nullable() + ->after('tenant_id') + ->comment('부모 공정 ID (NULL이면 루트)'); + + $table->foreign('parent_id') + ->references('id') + ->on('processes') + ->onDelete('set null'); + + $table->index(['tenant_id', 'parent_id']); + }); + } + + public function down(): void + { + Schema::table('processes', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropIndex(['tenant_id', 'parent_id']); + $table->dropColumn('parent_id'); + }); + } +};