From 3994e0faf1b760c8863ad4ff8013e02977c555b0 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 26 Dec 2025 18:56:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A0=95=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20(L-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processes, process_classification_rules 테이블 마이그레이션 - Process, ProcessClassificationRule 모델 (BelongsToTenant, SoftDeletes) - ProcessService: CRUD + 통계/옵션/상태토글 - ProcessController + FormRequest 검증 - API 라우트 등록 (/v1/processes) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Http/Controllers/V1/ProcessController.php | 110 +++++++ .../V1/Process/StoreProcessRequest.php | 54 ++++ .../V1/Process/UpdateProcessRequest.php | 54 ++++ app/Models/Process.php | 50 ++++ app/Models/ProcessClassificationRule.php | 36 +++ app/Services/ProcessService.php | 276 ++++++++++++++++++ ...25_12_26_183130_create_processes_table.php | 72 +++++ routes/api.php | 14 + 8 files changed, 666 insertions(+) create mode 100644 app/Http/Controllers/V1/ProcessController.php create mode 100644 app/Http/Requests/V1/Process/StoreProcessRequest.php create mode 100644 app/Http/Requests/V1/Process/UpdateProcessRequest.php create mode 100644 app/Models/Process.php create mode 100644 app/Models/ProcessClassificationRule.php create mode 100644 app/Services/ProcessService.php create mode 100644 database/migrations/2025_12_26_183130_create_processes_table.php diff --git a/app/Http/Controllers/V1/ProcessController.php b/app/Http/Controllers/V1/ProcessController.php new file mode 100644 index 0000000..3c6b4fb --- /dev/null +++ b/app/Http/Controllers/V1/ProcessController.php @@ -0,0 +1,110 @@ +only(['page', 'size', 'q', 'status', 'process_type']); + $result = $this->processService->index($params); + + return ApiResponse::handle($result, 'message.fetched'); + } + + /** + * 공정 상세 조회 + */ + public function show(int $id): JsonResponse + { + $result = $this->processService->show($id); + + return ApiResponse::handle($result, 'message.fetched'); + } + + /** + * 공정 생성 + */ + public function store(StoreProcessRequest $request): JsonResponse + { + $result = $this->processService->store($request->validated()); + + return ApiResponse::handle($result, 'message.created', 201); + } + + /** + * 공정 수정 + */ + public function update(UpdateProcessRequest $request, int $id): JsonResponse + { + $result = $this->processService->update($id, $request->validated()); + + return ApiResponse::handle($result, 'message.updated'); + } + + /** + * 공정 삭제 + */ + public function destroy(int $id): JsonResponse + { + $this->processService->destroy($id); + + return ApiResponse::handle(null, 'message.deleted'); + } + + /** + * 공정 일괄 삭제 + */ + public function destroyMany(Request $request): JsonResponse + { + $ids = $request->input('ids', []); + $count = $this->processService->destroyMany($ids); + + return ApiResponse::handle(['deleted_count' => $count], 'message.deleted'); + } + + /** + * 공정 상태 토글 + */ + public function toggleActive(int $id): JsonResponse + { + $result = $this->processService->toggleActive($id); + + return ApiResponse::handle($result, 'message.updated'); + } + + /** + * 공정 옵션 목록 (드롭다운용) + */ + public function options(): JsonResponse + { + $result = $this->processService->options(); + + return ApiResponse::handle($result, 'message.fetched'); + } + + /** + * 공정 통계 + */ + public function stats(): JsonResponse + { + $result = $this->processService->getStats(); + + return ApiResponse::handle($result, 'message.fetched'); + } +} diff --git a/app/Http/Requests/V1/Process/StoreProcessRequest.php b/app/Http/Requests/V1/Process/StoreProcessRequest.php new file mode 100644 index 0000000..22d5df6 --- /dev/null +++ b/app/Http/Requests/V1/Process/StoreProcessRequest.php @@ -0,0 +1,54 @@ + ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'process_type' => ['required', 'string', 'in:생산,검사,포장,조립'], + 'department' => ['nullable', 'string', 'max:100'], + 'work_log_template' => ['nullable', 'string', 'max:100'], + 'required_workers' => ['nullable', 'integer', 'min:1'], + 'equipment_info' => ['nullable', 'string', 'max:255'], + 'work_steps' => ['nullable'], + 'note' => ['nullable', 'string'], + 'is_active' => ['nullable', 'boolean'], + + // 분류 규칙 + 'classification_rules' => ['nullable', 'array'], + 'classification_rules.*.registration_type' => ['nullable', 'string', 'in:pattern,individual'], + 'classification_rules.*.rule_type' => ['required_with:classification_rules.*', 'string', 'in:품목코드,품목명,품목구분'], + 'classification_rules.*.matching_type' => ['required_with:classification_rules.*', 'string', 'in:startsWith,endsWith,contains,equals'], + 'classification_rules.*.condition_value' => ['required_with:classification_rules.*', 'string', 'max:255'], + 'classification_rules.*.priority' => ['nullable', 'integer', 'min:0'], + 'classification_rules.*.description' => ['nullable', 'string', 'max:255'], + 'classification_rules.*.is_active' => ['nullable', 'boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'process_name' => '공정명', + 'description' => '공정 설명', + 'process_type' => '공정구분', + 'department' => '담당부서', + 'work_log_template' => '작업일지 양식', + 'required_workers' => '필요인원', + 'equipment_info' => '설비정보', + 'work_steps' => '작업단계', + 'note' => '비고', + ]; + } +} diff --git a/app/Http/Requests/V1/Process/UpdateProcessRequest.php b/app/Http/Requests/V1/Process/UpdateProcessRequest.php new file mode 100644 index 0000000..2099516 --- /dev/null +++ b/app/Http/Requests/V1/Process/UpdateProcessRequest.php @@ -0,0 +1,54 @@ + ['sometimes', 'required', 'string', 'max:100'], + 'description' => ['nullable', 'string'], + 'process_type' => ['sometimes', 'required', 'string', 'in:생산,검사,포장,조립'], + 'department' => ['nullable', 'string', 'max:100'], + 'work_log_template' => ['nullable', 'string', 'max:100'], + 'required_workers' => ['nullable', 'integer', 'min:1'], + 'equipment_info' => ['nullable', 'string', 'max:255'], + 'work_steps' => ['nullable'], + 'note' => ['nullable', 'string'], + 'is_active' => ['nullable', 'boolean'], + + // 분류 규칙 + 'classification_rules' => ['nullable', 'array'], + 'classification_rules.*.registration_type' => ['nullable', 'string', 'in:pattern,individual'], + 'classification_rules.*.rule_type' => ['required_with:classification_rules.*', 'string', 'in:품목코드,품목명,품목구분'], + 'classification_rules.*.matching_type' => ['required_with:classification_rules.*', 'string', 'in:startsWith,endsWith,contains,equals'], + 'classification_rules.*.condition_value' => ['required_with:classification_rules.*', 'string', 'max:255'], + 'classification_rules.*.priority' => ['nullable', 'integer', 'min:0'], + 'classification_rules.*.description' => ['nullable', 'string', 'max:255'], + 'classification_rules.*.is_active' => ['nullable', 'boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'process_name' => '공정명', + 'description' => '공정 설명', + 'process_type' => '공정구분', + 'department' => '담당부서', + 'work_log_template' => '작업일지 양식', + 'required_workers' => '필요인원', + 'equipment_info' => '설비정보', + 'work_steps' => '작업단계', + 'note' => '비고', + ]; + } +} diff --git a/app/Models/Process.php b/app/Models/Process.php new file mode 100644 index 0000000..47fd3fc --- /dev/null +++ b/app/Models/Process.php @@ -0,0 +1,50 @@ + 'array', + 'is_active' => 'boolean', + 'required_workers' => 'integer', + ]; + + /** + * 공정 자동 분류 규칙 + */ + public function classificationRules(): HasMany + { + return $this->hasMany(ProcessClassificationRule::class)->orderBy('priority'); + } +} diff --git a/app/Models/ProcessClassificationRule.php b/app/Models/ProcessClassificationRule.php new file mode 100644 index 0000000..8c5c3be --- /dev/null +++ b/app/Models/ProcessClassificationRule.php @@ -0,0 +1,36 @@ + 'boolean', + 'priority' => 'integer', + ]; + + /** + * 공정 + */ + public function process(): BelongsTo + { + return $this->belongsTo(Process::class); + } +} diff --git a/app/Services/ProcessService.php b/app/Services/ProcessService.php new file mode 100644 index 0000000..af24aae --- /dev/null +++ b/app/Services/ProcessService.php @@ -0,0 +1,276 @@ +tenantId(); + + $page = (int) ($params['page'] ?? 1); + $size = (int) ($params['size'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $status = $params['status'] ?? null; + $processType = $params['process_type'] ?? null; + + $query = Process::query() + ->where('tenant_id', $tenantId) + ->with('classificationRules'); + + // 검색어 + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('process_name', 'like', "%{$q}%") + ->orWhere('process_code', 'like', "%{$q}%") + ->orWhere('description', 'like', "%{$q}%") + ->orWhere('department', 'like', "%{$q}%"); + }); + } + + // 상태 필터 + if ($status === 'active') { + $query->where('is_active', true); + } elseif ($status === 'inactive') { + $query->where('is_active', false); + } + + // 공정구분 필터 + if ($processType) { + $query->where('process_type', $processType); + } + + $query->orderBy('process_code'); + + return $query->paginate($size, ['*'], 'page', $page); + } + + /** + * 공정 상세 조회 + */ + public function show(int $id) + { + $tenantId = $this->tenantId(); + + $process = Process::where('tenant_id', $tenantId) + ->with('classificationRules') + ->find($id); + + if (! $process) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $process; + } + + /** + * 공정 생성 + */ + public function store(array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 공정코드 자동 생성 + $data['process_code'] = $this->generateProcessCode($tenantId); + $data['tenant_id'] = $tenantId; + $data['created_by'] = $userId; + $data['is_active'] = $data['is_active'] ?? true; + + // work_steps가 문자열이면 배열로 변환 + if (isset($data['work_steps']) && is_string($data['work_steps'])) { + $data['work_steps'] = array_map('trim', explode(',', $data['work_steps'])); + } + + $rules = $data['classification_rules'] ?? []; + unset($data['classification_rules']); + + $process = Process::create($data); + + // 분류 규칙 저장 + $this->syncClassificationRules($process, $rules); + + return $process->load('classificationRules'); + }); + } + + /** + * 공정 수정 + */ + public function update(int $id, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $process = Process::where('tenant_id', $tenantId)->find($id); + if (! $process) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($process, $data, $userId) { + $data['updated_by'] = $userId; + + // work_steps가 문자열이면 배열로 변환 + if (isset($data['work_steps']) && is_string($data['work_steps'])) { + $data['work_steps'] = array_map('trim', explode(',', $data['work_steps'])); + } + + $rules = $data['classification_rules'] ?? null; + unset($data['classification_rules']); + + $process->update($data); + + // 분류 규칙 동기화 (전달된 경우만) + if ($rules !== null) { + $this->syncClassificationRules($process, $rules); + } + + return $process->fresh('classificationRules'); + }); + } + + /** + * 공정 삭제 + */ + public function destroy(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $process = Process::where('tenant_id', $tenantId)->find($id); + if (! $process) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $process->update(['deleted_by' => $userId]); + $process->delete(); + + return true; + } + + /** + * 공정 일괄 삭제 + */ + public function destroyMany(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $count = Process::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update(['deleted_by' => $userId, 'deleted_at' => now()]); + + return $count; + } + + /** + * 공정 상태 토글 + */ + public function toggleActive(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $process = Process::where('tenant_id', $tenantId)->find($id); + if (! $process) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $process->update([ + 'is_active' => ! $process->is_active, + 'updated_by' => $userId, + ]); + + return $process->fresh('classificationRules'); + } + + /** + * 공정코드 자동 생성 (P-001, P-002, ...) + */ + private function generateProcessCode(int $tenantId): string + { + $lastProcess = Process::where('tenant_id', $tenantId) + ->withTrashed() + ->orderByRaw("CAST(SUBSTRING(process_code, 3) AS UNSIGNED) DESC") + ->first(); + + if ($lastProcess && preg_match('/^P-(\d+)$/', $lastProcess->process_code, $matches)) { + $nextNum = (int) $matches[1] + 1; + } else { + $nextNum = 1; + } + + return sprintf('P-%03d', $nextNum); + } + + /** + * 분류 규칙 동기화 + */ + private function syncClassificationRules(Process $process, array $rules): void + { + // 기존 규칙 삭제 + $process->classificationRules()->delete(); + + // 새 규칙 생성 + foreach ($rules as $index => $rule) { + ProcessClassificationRule::create([ + 'process_id' => $process->id, + 'registration_type' => $rule['registration_type'] ?? 'pattern', + 'rule_type' => $rule['rule_type'], + 'matching_type' => $rule['matching_type'], + 'condition_value' => $rule['condition_value'], + 'priority' => $rule['priority'] ?? $index, + 'description' => $rule['description'] ?? null, + 'is_active' => $rule['is_active'] ?? true, + ]); + } + } + + /** + * 드롭다운용 공정 옵션 목록 + */ + public function options() + { + $tenantId = $this->tenantId(); + + return Process::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('process_code') + ->select('id', 'process_code', 'process_name', 'process_type', 'department') + ->get(); + } + + /** + * 통계 + */ + public function getStats() + { + $tenantId = $this->tenantId(); + + $total = Process::where('tenant_id', $tenantId)->count(); + $active = Process::where('tenant_id', $tenantId)->where('is_active', true)->count(); + $inactive = Process::where('tenant_id', $tenantId)->where('is_active', false)->count(); + + $byType = Process::where('tenant_id', $tenantId) + ->where('is_active', true) + ->groupBy('process_type') + ->selectRaw('process_type, count(*) as count') + ->pluck('count', 'process_type'); + + return [ + 'total' => $total, + 'active' => $active, + 'inactive' => $inactive, + 'by_type' => $byType, + ]; + } +} diff --git a/database/migrations/2025_12_26_183130_create_processes_table.php b/database/migrations/2025_12_26_183130_create_processes_table.php new file mode 100644 index 0000000..30d004e --- /dev/null +++ b/database/migrations/2025_12_26_183130_create_processes_table.php @@ -0,0 +1,72 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('process_code', 20)->comment('공정코드 (P-001)'); + $table->string('process_name', 100)->comment('공정명'); + $table->text('description')->nullable()->comment('공정 설명'); + $table->string('process_type', 20)->default('생산')->comment('공정구분 (생산/검사/포장/조립)'); + $table->string('department', 100)->nullable()->comment('담당부서'); + $table->string('work_log_template', 100)->nullable()->comment('작업일지 양식'); + $table->unsignedInteger('required_workers')->default(1)->comment('필요인원'); + $table->string('equipment_info', 255)->nullable()->comment('설비정보'); + $table->json('work_steps')->nullable()->comment('세부 작업단계 배열'); + $table->text('note')->nullable()->comment('비고'); + $table->boolean('is_active')->default(true)->comment('사용여부'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id'); + $table->unique(['tenant_id', 'process_code']); + $table->index(['tenant_id', 'is_active']); + $table->index(['tenant_id', 'process_type']); + }); + + // 공정 자동 분류 규칙 테이블 + Schema::create('process_classification_rules', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('process_id')->comment('공정 ID'); + $table->string('registration_type', 20)->default('pattern')->comment('등록방식 (pattern/individual)'); + $table->string('rule_type', 20)->comment('규칙유형 (품목코드/품목명/품목구분)'); + $table->string('matching_type', 20)->comment('매칭방식 (startsWith/endsWith/contains/equals)'); + $table->string('condition_value', 255)->comment('조건값'); + $table->unsignedInteger('priority')->default(0)->comment('우선순위'); + $table->string('description', 255)->nullable()->comment('설명'); + $table->boolean('is_active')->default(true)->comment('활성여부'); + $table->timestamps(); + + // 외래키 + $table->foreign('process_id')->references('id')->on('processes')->onDelete('cascade'); + + // 인덱스 + $table->index('process_id'); + $table->index(['process_id', 'is_active', 'priority']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('process_classification_rules'); + Schema::dropIfExists('processes'); + } +}; diff --git a/routes/api.php b/routes/api.php index 4f1fba5..dd1d1ac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -103,6 +103,7 @@ use App\Http\Controllers\Api\V1\WorkOrderController; use App\Http\Controllers\Api\V1\WorkResultController; use App\Http\Controllers\Api\V1\WorkSettingController; +use App\Http\Controllers\V1\ProcessController; use Illuminate\Support\Facades\Route; // V1 초기 개발 @@ -1027,6 +1028,19 @@ Route::post('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기 }); + // 공정 관리 API (Process Management) + Route::prefix('processes')->group(function () { + Route::get('', [ProcessController::class, 'index'])->name('v1.processes.index'); + Route::get('/options', [ProcessController::class, 'options'])->name('v1.processes.options'); + Route::get('/stats', [ProcessController::class, 'stats'])->name('v1.processes.stats'); + Route::post('', [ProcessController::class, 'store'])->name('v1.processes.store'); + Route::delete('', [ProcessController::class, 'destroyMany'])->name('v1.processes.destroy-many'); + Route::get('/{id}', [ProcessController::class, 'show'])->whereNumber('id')->name('v1.processes.show'); + 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'); + }); + // 작업지시 관리 API (Production) Route::prefix('work-orders')->group(function () { // 기본 CRUD