feat: 공정관리 API 구현 (L-1)

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 18:56:24 +09:00
parent 5ae326521f
commit 3994e0faf1
8 changed files with 666 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Process\StoreProcessRequest;
use App\Http\Requests\V1\Process\UpdateProcessRequest;
use App\Services\ProcessService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProcessController extends Controller
{
public function __construct(
private readonly ProcessService $processService
) {}
/**
* 공정 목록 조회
*/
public function index(Request $request): JsonResponse
{
$params = $request->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');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\V1\Process;
use Illuminate\Foundation\Http\FormRequest;
class StoreProcessRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'process_name' => ['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' => '비고',
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\V1\Process;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProcessRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'process_name' => ['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' => '비고',
];
}
}

50
app/Models/Process.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Models\Traits\BelongsToTenant;
use App\Models\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Process extends Model
{
use BelongsToTenant;
use HasFactory;
use ModelTrait;
use SoftDeletes;
protected $fillable = [
'tenant_id',
'process_code',
'process_name',
'description',
'process_type',
'department',
'work_log_template',
'required_workers',
'equipment_info',
'work_steps',
'note',
'is_active',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'work_steps' => 'array',
'is_active' => 'boolean',
'required_workers' => 'integer',
];
/**
* 공정 자동 분류 규칙
*/
public function classificationRules(): HasMany
{
return $this->hasMany(ProcessClassificationRule::class)->orderBy('priority');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProcessClassificationRule extends Model
{
use HasFactory;
protected $fillable = [
'process_id',
'registration_type',
'rule_type',
'matching_type',
'condition_value',
'priority',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
'priority' => 'integer',
];
/**
* 공정
*/
public function process(): BelongsTo
{
return $this->belongsTo(Process::class);
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Services;
use App\Models\Process;
use App\Models\ProcessClassificationRule;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProcessService extends Service
{
/**
* 공정 목록 조회 (검색/페이징)
*/
public function index(array $params)
{
$tenantId = $this->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,
];
}
}

View File

@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 공정 테이블
Schema::create('processes', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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