diff --git a/app/Http/Controllers/Api/V1/CategoryFieldController.php b/app/Http/Controllers/Api/V1/CategoryFieldController.php new file mode 100644 index 0000000..95b950e --- /dev/null +++ b/app/Http/Controllers/Api/V1/CategoryFieldController.php @@ -0,0 +1,71 @@ +service->index($id, $request->all()); + }, '카테고리 필드 목록'); + } + + // POST /categories/{id}/fields + public function store(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->store($id, $request->all()); + }, '카테고리 필드 생성'); + } + + // GET /categories/fields/{field} + public function show(int $field) + { + return ApiResponse::handle(function () use ($field) { + return $this->service->show($field); + }, '카테고리 필드 조회'); + } + + // PATCH /categories/fields/{field} + public function update(int $field, Request $request) + { + return ApiResponse::handle(function () use ($field, $request) { + return $this->service->update($field, $request->all()); + }, '카테고리 필드 수정'); + } + + // DELETE /categories/fields/{field} + public function destroy(int $field) + { + return ApiResponse::handle(function () use ($field) { + $this->service->destroy($field); + return 'success'; + }, '카테고리 필드 삭제'); + } + + // POST /categories/{id}/fields/reorder + public function reorder(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $this->service->reorder($id, $request->input()); + return 'success'; + }, '카테고리 필드 정렬 저장'); + } + + // PUT /categories/{id}/fields/bulk-upsert + public function bulkUpsert(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->bulkUpsert($id, $request->input('items', [])); + }, '카테고리 필드 일괄 업서트'); + } +} diff --git a/app/Http/Controllers/Api/V1/CategoryLogController.php b/app/Http/Controllers/Api/V1/CategoryLogController.php new file mode 100644 index 0000000..b99c198 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CategoryLogController.php @@ -0,0 +1,29 @@ +service->index($id, $request->all()); + }, '카테고리 변경이력 목록'); + } + + // GET /categories/logs/{log} + public function show(int $log) + { + return ApiResponse::handle(function () use ($log) { + return $this->service->show($log); + }, '카테고리 변경이력 조회'); + } +} diff --git a/app/Http/Controllers/Api/V1/CategoryTemplateController.php b/app/Http/Controllers/Api/V1/CategoryTemplateController.php new file mode 100644 index 0000000..cd92cb1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CategoryTemplateController.php @@ -0,0 +1,79 @@ +service->index($id, $request->all()); + }, '카테고리 템플릿 목록'); + } + + // POST /categories/{id}/templates + public function store(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->store($id, $request->all()); + }, '카테고리 템플릿 생성'); + } + + // GET /categories/templates/{tpl} + public function show(int $tpl) + { + return ApiResponse::handle(function () use ($tpl) { + return $this->service->show($tpl); + }, '카테고리 템플릿 조회'); + } + + // PATCH /categories/templates/{tpl} + public function update(int $tpl, Request $request) + { + return ApiResponse::handle(function () use ($tpl, $request) { + return $this->service->update($tpl, $request->all()); + }, '카테고리 템플릿 수정'); + } + + // DELETE /categories/templates/{tpl} + public function destroy(int $tpl) + { + return ApiResponse::handle(function () use ($tpl) { + $this->service->destroy($tpl); + return 'success'; + }, '카테고리 템플릿 삭제'); + } + + // POST /categories/{id}/templates/{tpl}/apply + public function apply(int $id, int $tpl) + { + return ApiResponse::handle(function () use ($id, $tpl) { + $this->service->apply($id, $tpl); + return 'success'; + }, '카테고리 템플릿 적용'); + } + + // GET /categories/{id}/templates/{tpl}/preview + public function preview(int $id, int $tpl) + { + return ApiResponse::handle(function () use ($id, $tpl) { + return $this->service->preview($id, $tpl); + }, '카테고리 템플릿 미리보기'); + } + + // GET /categories/{id}/templates/diff?a=&b= + public function diff(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->diff($id, (int)$request->query('a'), (int)$request->query('b')); + }, '카테고리 템플릿 비교'); + } +} diff --git a/app/Http/Controllers/Api/V1/ProductBomItemController.php b/app/Http/Controllers/Api/V1/ProductBomItemController.php new file mode 100644 index 0000000..33308d3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProductBomItemController.php @@ -0,0 +1,71 @@ +service->index($id, $request->all()); + }, 'BOM 항목 목록'); + } + + // POST /products/{id}/bom/items/bulk + public function bulkUpsert(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->bulkUpsert($id, $request->input('items', [])); + }, 'BOM 일괄 업서트'); + } + + // PATCH /products/{id}/bom/items/{item} + public function update(int $id, int $item, Request $request) + { + return ApiResponse::handle(function () use ($id, $item, $request) { + return $this->service->update($id, $item, $request->all()); + }, 'BOM 항목 수정'); + } + + // DELETE /products/{id}/bom/items/{item} + public function destroy(int $id, int $item) + { + return ApiResponse::handle(function () use ($id, $item) { + $this->service->destroy($id, $item); + return 'success'; + }, 'BOM 항목 삭제'); + } + + // POST /products/{id}/bom/items/reorder + public function reorder(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $this->service->reorder($id, $request->input('items', [])); + return 'success'; + }, 'BOM 정렬 변경'); + } + + // GET /products/{id}/bom/summary + public function summary(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->summary($id); + }, 'BOM 요약'); + } + + // GET /products/{id}/bom/validate + public function validateBom(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->validateBom($id); + }, 'BOM 유효성 검사'); + } +} diff --git a/app/Http/Controllers/Api/V1/ProductController.php b/app/Http/Controllers/Api/V1/ProductController.php index 176e9ac..2c393d5 100644 --- a/app/Http/Controllers/Api/V1/ProductController.php +++ b/app/Http/Controllers/Api/V1/ProductController.php @@ -10,56 +10,70 @@ class ProductController extends Controller { - public function index(Request $request) - { - // - } + public function __construct(private ProductService $service) {} public function getCategory(Request $request) { return ApiResponse::handle(function () use ($request) { - return ProductService::getCategory($request); + return $this->service->getCategory($request); }, '제품 카테고리 조회'); } - - /** - * Show the form for creating a new resource. - */ - public function create() + // GET /products + public function index(Request $request) { - // + return ApiResponse::handle(function () use ($request) { + return $this->service->index($request->all()); + }, '제품 목록'); } - /** - * Store a newly created resource in storage. - */ + // POST /products public function store(Request $request) { - // + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->all()); + }, '제품 생성'); } - - public function show(Request $request, $userNo) + // GET /products/{id} + public function show(int $id) { - // + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, '제품 단건'); } - - /** - * Show the form for editing the specified resource. - */ - public function edit(string $id) + // PATCH /products/{id} + public function update(int $id, Request $request) { - // + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->all()); + }, '제품 수정'); } - /** - * Update the specified resource in storage. - */ - public function update(Request $request, string $id) + // DELETE /products/{id} + public function destroy(int $id) { - // + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + return 'success'; + }, '제품 삭제'); + } + + // GET /products/search + public function search(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->search($request->all()); + }, '제품 검색'); + } + + // POST /products/{id}/toggle + public function toggle(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->toggle($id); + }, '제품 활성 토글'); } } diff --git a/app/Models/Products/ProductComponent.php b/app/Models/Products/ProductComponent.php index e2f92e9..9fab41b 100644 --- a/app/Models/Products/ProductComponent.php +++ b/app/Models/Products/ProductComponent.php @@ -2,29 +2,92 @@ namespace App\Models\Products; -use App\Traits\BelongsToTenant; -use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use App\Traits\ModelTrait; +use App\Traits\BelongsToTenant; class ProductComponent extends Model { - use SoftDeletes, BelongsToTenant, ModelTrait; + use SoftDeletes, ModelTrait, BelongsToTenant; protected $table = 'product_components'; protected $fillable = [ - 'tenant_id','parent_product_id','child_product_id', - 'quantity','sort_order','is_default', - 'created_by','updated_by' + 'tenant_id', + 'parent_product_id', + 'ref_type', + 'child_product_id', + 'material_id', + 'quantity', + 'sort_order', + 'is_default', + 'created_by', + 'updated_by', ]; protected $casts = [ 'quantity' => 'decimal:4', - 'sort_order' => 'integer', 'is_default' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', ]; - public function parent() { return $this->belongsTo(Product::class, 'parent_product_id'); } - public function child() { return $this->belongsTo(Product::class, 'child_product_id'); } + protected $hidden = [ + 'deleted_at', + ]; + + + /** + * 상위 제품 (모델/제품) + */ + public function parentProduct() + { + return $this->belongsTo(Product::class, 'parent_product_id'); + } + + /** + * 하위 제품 (ref_type = PRODUCT일 때만 의미 있음) + */ + public function childProduct() + { + return $this->belongsTo(Product::class, 'child_product_id'); + } + + /** + * 하위 자재 (ref_type = MATERIAL일 때만 의미 있음) + */ + public function material() + { + return $this->belongsTo(\App\Models\Materials\Material::class, 'material_id'); + } + + // --------------------------------------------------- + // 🔎 Query Scopes + // --------------------------------------------------- + + /** + * 제품 BOM 아이템만 + */ + public function scopeProducts($query) + { + return $query->where('ref_type', 'PRODUCT'); + } + + /** + * 자재 BOM 아이템만 + */ + public function scopeMaterials($query) + { + return $query->where('ref_type', 'MATERIAL'); + } + + /** + * 특정 상위 제품의 BOM + */ + public function scopeForParent($query, int $parentProductId) + { + return $query->where('parent_product_id', $parentProductId); + } } diff --git a/app/Services/CategoryFieldService.php b/app/Services/CategoryFieldService.php new file mode 100644 index 0000000..a21b5b9 --- /dev/null +++ b/app/Services/CategoryFieldService.php @@ -0,0 +1,246 @@ +tenantId(); + + $size = (int)($params['size'] ?? 20); + $sort = $params['sort'] ?? 'sort_order'; + $order = strtolower($params['order'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; + + return CategoryField::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->orderBy($sort, $order) + ->paginate($size); + } + + public function store(int $categoryId, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $this->assertCategoryExists($tenantId, $categoryId); + + $v = Validator::make($data, [ + 'field_key' => 'required|string|max:30|alpha_dash', + 'field_name' => 'required|string|max:100', + 'field_type' => 'required|string|max:20', + 'is_required' => 'nullable|in:Y,N', + 'sort_order' => 'nullable|integer|min:0', + 'default_value' => 'nullable|string|max:100', + 'options' => 'nullable|json', + 'description' => 'nullable|string|max:255', + ]); + $payload = $v->validate(); + + // 카테고리 내 field_key 유니크 검증 + $exists = CategoryField::query() + ->where(compact('tenant_id')) + ->where('category_id', $categoryId) + ->where('field_key', $payload['field_key']) + ->exists(); + if ($exists) { + throw new BadRequestHttpException(__('error.duplicate_key')); // ko/error.php에 매핑 + } + + $payload['tenant_id'] = $tenantId; + $payload['category_id'] = $categoryId; + $payload['is_required'] = $payload['is_required'] ?? 'N'; + $payload['sort_order'] = $payload['sort_order'] ?? 0; + $payload['created_by'] = $userId; + + return CategoryField::create($payload); + } + + public function show(int $fieldId) + { + $tenantId = $this->tenantId(); + + $field = CategoryField::query() + ->where('tenant_id', $tenantId) + ->find($fieldId); + + if (!$field) { + throw new BadRequestHttpException(__('error.not_found')); + } + return $field; + } + + public function update(int $fieldId, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $field = CategoryField::query() + ->where('tenant_id', $tenantId) + ->find($fieldId); + + if (!$field) { + throw new BadRequestHttpException(__('error.not_found')); + } + + $v = Validator::make($data, [ + 'field_key' => 'sometimes|string|max:30|alpha_dash', + 'field_name' => 'sometimes|string|max:100', + 'field_type' => 'sometimes|string|max:20', + 'is_required' => 'sometimes|in:Y,N', + 'sort_order' => 'sometimes|integer|min:0', + 'default_value' => 'nullable|string|max:100', + 'options' => 'nullable|json', + 'description' => 'nullable|string|max:255', + ]); + $payload = $v->validate(); + + if (isset($payload['field_key']) && $payload['field_key'] !== $field->field_key) { + $dup = CategoryField::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $field->category_id) + ->where('field_key', $payload['field_key']) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + } + + $payload['updated_by'] = $userId; + $field->update($payload); + return $field->refresh(); + } + + public function destroy(int $fieldId): void + { + $tenantId = $this->tenantId(); + $field = CategoryField::query() + ->where('tenant_id', $tenantId) + ->find($fieldId); + + if (!$field) { + throw new BadRequestHttpException(__('error.not_found')); + } + $field->delete(); + } + + public function reorder(int $categoryId, array $items): void + { + $tenantId = $this->tenantId(); + $this->assertCategoryExists($tenantId, $categoryId); + + $rows = $items['items'] ?? $items; // 둘 다 허용 + if (!is_array($rows)) { + throw new BadRequestHttpException(__('error.invalid_payload')); + } + + DB::transaction(function () use ($tenantId, $categoryId, $rows) { + foreach ($rows as $row) { + if (!isset($row['id'], $row['sort_order'])) continue; + CategoryField::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->where('id', $row['id']) + ->update(['sort_order' => (int)$row['sort_order']]); + } + }); + } + + public function bulkUpsert(int $categoryId, array $items): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $this->assertCategoryExists($tenantId, $categoryId); + + if (!is_array($items) || empty($items)) { + throw new BadRequestHttpException(__('error.empty_items')); + } + + $result = ['created' => 0, 'updated' => 0]; + + DB::transaction(function () use ($tenantId, $userId, $categoryId, $items, &$result) { + foreach ($items as $it) { + $v = Validator::make($it, [ + 'id' => 'nullable|integer', + 'field_key' => 'sometimes|required_without:id|string|max:30|alpha_dash', + 'field_name' => 'required|string|max:100', + 'field_type' => 'required|string|max:20', + 'is_required' => 'nullable|in:Y,N', + 'sort_order' => 'nullable|integer|min:0', + 'default_value' => 'nullable|string|max:100', + 'options' => 'nullable|json', + 'description' => 'nullable|string|max:255', + ]); + $payload = $v->validate(); + + if (!empty($payload['id'])) { + $model = CategoryField::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->find($payload['id']); + if (!$model) { + throw new BadRequestHttpException(__('error.not_found')); + } + + // field_key 변경 유니크 검사 + if (isset($payload['field_key']) && $payload['field_key'] !== $model->field_key) { + $dup = CategoryField::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->where('field_key', $payload['field_key']) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + } + + $payload['updated_by'] = $userId; + $model->update($payload); + $result['updated']++; + } else { + // 신규 생성 + if (empty($payload['field_key'])) { + throw new BadRequestHttpException(__('error.required', ['attr' => 'field_key'])); + } + $dup = CategoryField::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->where('field_key', $payload['field_key']) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + + $payload['tenant_id'] = $tenantId; + $payload['category_id'] = $categoryId; + $payload['is_required'] = $payload['is_required'] ?? 'N'; + $payload['sort_order'] = $payload['sort_order'] ?? 0; + $payload['created_by'] = $userId; + + CategoryField::create($payload); + $result['created']++; + } + } + }); + + return $result; + } + + private function assertCategoryExists(int $tenantId, int $categoryId): void + { + $exists = Category::query() + ->where('tenant_id', $tenantId) + ->where('id', $categoryId) + ->exists(); + if (!$exists) { + throw new BadRequestHttpException(__('error.category_not_found')); + } + } +} diff --git a/app/Services/CategoryLogService.php b/app/Services/CategoryLogService.php new file mode 100644 index 0000000..dc34efc --- /dev/null +++ b/app/Services/CategoryLogService.php @@ -0,0 +1,42 @@ +tenantId(); + + $size = (int)($params['size'] ?? 20); + $action = $params['action'] ?? null; // insert|update|delete + $from = $params['from'] ?? null; // Y-m-d + $to = $params['to'] ?? null; + + $q = CategoryLog::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->orderByDesc('changed_at'); + + if ($action) $q->where('action', $action); + if ($from) $q->whereDate('changed_at', '>=', Carbon::parse($from)->toDateString()); + if ($to) $q->whereDate('changed_at', '<=', Carbon::parse($to)->toDateString()); + + return $q->paginate($size); + } + + public function show(int $logId) + { + $tenantId = $this->tenantId(); + + $log = CategoryLog::query() + ->where('tenant_id', $tenantId) + ->find($logId); + + if (!$log) throw new BadRequestHttpException(__('error.not_found')); + return $log; + } +} diff --git a/app/Services/CategoryTemplateService.php b/app/Services/CategoryTemplateService.php new file mode 100644 index 0000000..ff21392 --- /dev/null +++ b/app/Services/CategoryTemplateService.php @@ -0,0 +1,194 @@ +tenantId(); + $size = (int)($params['size'] ?? 20); + + return CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->orderByDesc('version_no') + ->paginate($size); + } + + public function store(int $categoryId, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $this->assertCategoryExists($tenantId, $categoryId); + + $v = Validator::make($data, [ + 'version_no' => 'required|integer|min:1', + 'template_json' => 'required|json', + 'applied_at' => 'required|date', + 'remarks' => 'nullable|string|max:255', + ]); + $payload = $v->validate(); + + $dup = CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->where('version_no', $payload['version_no']) + ->exists(); + if ($dup) { + throw new BadRequestHttpException(__('error.duplicate_key')); // version_no 중복 + } + + $payload['tenant_id'] = $tenantId; + $payload['category_id'] = $categoryId; + $payload['created_by'] = $userId; + + return CategoryTemplate::create($payload); + } + + public function show(int $tplId) + { + $tenantId = $this->tenantId(); + + $tpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->find($tplId); + + if (!$tpl) throw new BadRequestHttpException(__('error.not_found')); + return $tpl; + } + + public function update(int $tplId, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $tpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->find($tplId); + + if (!$tpl) throw new BadRequestHttpException(__('error.not_found')); + + $v = Validator::make($data, [ + 'template_json' => 'nullable|json', + 'applied_at' => 'nullable|date', + 'remarks' => 'nullable|string|max:255', + ]); + $payload = $v->validate(); + $payload['updated_by'] = $userId; + + $tpl->update($payload); + return $tpl->refresh(); + } + + public function destroy(int $tplId): void + { + $tenantId = $this->tenantId(); + + $tpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->find($tplId); + + if (!$tpl) throw new BadRequestHttpException(__('error.not_found')); + $tpl->delete(); + } + + /** + * 적용 정책: + * - categories.active_template_version (또는 별도 맵 테이블)에 version_no 반영 + * - (옵션) template_json 기반으로 category_fields를 실제로 갱신하려면 여기서 동기화 + */ + public function apply(int $categoryId, int $tplId): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $tpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->find($tplId); + + if (!$tpl) throw new BadRequestHttpException(__('error.not_found')); + + DB::transaction(function () use ($tenantId, $userId, $categoryId, $tpl) { + // 1) categories 테이블에 활성 버전 반영(컬럼이 있다면) + // Category::where('tenant_id', $tenantId)->where('id', $categoryId)->update([ + // 'active_template_version' => $tpl->version_no, + // 'updated_by' => $userId, + // ]); + + // 2) (선택) template_json → category_fields 동기화 + // - 추가/수정/삭제 전략은 운영정책에 맞게 구현 + // - 여기서는 예시로 "fields" 배열만 처리 + // $snapshot = json_decode($tpl->template_json, true); + // foreach (($snapshot['fields'] ?? []) as $i => $f) { + // // key, name, type, required, default, options 매핑 ... + // } + }); + } + + public function preview(int $categoryId, int $tplId): array + { + $tenantId = $this->tenantId(); + + $tpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId) + ->where('category_id', $categoryId) + ->find($tplId); + + if (!$tpl) throw new BadRequestHttpException(__('error.not_found')); + + $json = json_decode($tpl->template_json, true); + if (!is_array($json)) { + throw new BadRequestHttpException(__('error.invalid_payload')); + } + // 프론트 렌더링 편의 구조로 가공 가능 + return $json; + } + + public function diff(int $categoryId, int $a, int $b): array + { + $tenantId = $this->tenantId(); + + $aTpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId)->where('category_id', $categoryId) + ->where('version_no', $a)->first(); + + $bTpl = CategoryTemplate::query() + ->where('tenant_id', $tenantId)->where('category_id', $categoryId) + ->where('version_no', $b)->first(); + + if (!$aTpl || !$bTpl) throw new BadRequestHttpException(__('error.not_found')); + + $aj = json_decode($aTpl->template_json, true) ?: []; + $bj = json_decode($bTpl->template_json, true) ?: []; + + // 아주 단순한 diff 예시 (fields 키만 비교) + $aKeys = collect($aj['fields'] ?? [])->pluck('key')->all(); + $bKeys = collect($bj['fields'] ?? [])->pluck('key')->all(); + + return [ + 'added' => array_values(array_diff($bKeys, $aKeys)), + 'removed' => array_values(array_diff($aKeys, $bKeys)), + // 변경(diff in detail)은 정책에 맞게 확장 + ]; + } + + private function assertCategoryExists(int $tenantId, int $categoryId): void + { + $exists = Category::query() + ->where('tenant_id', $tenantId) + ->where('id', $categoryId) + ->exists(); + if (!$exists) { + throw new BadRequestHttpException(__('error.category_not_found')); + } + } +} diff --git a/app/Services/ProductBomService.php b/app/Services/ProductBomService.php new file mode 100644 index 0000000..8dfdafd --- /dev/null +++ b/app/Services/ProductBomService.php @@ -0,0 +1,335 @@ +tenantId(); + + // 부모 제품 유효성 + $this->assertProduct($tenantId, $parentProductId); + + $items = ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->orderBy('sort_order') + ->get(); + + // 리졸브(제품/자재) + $productIds = $items->where('ref_type', 'PRODUCT')->pluck('child_product_id')->filter()->unique()->values(); + $materialIds = $items->where('ref_type', 'MATERIAL')->pluck('material_id')->filter()->unique()->values(); + + $products = $productIds->isNotEmpty() + ? Product::query()->where('tenant_id', $tenantId)->whereIn('id', $productIds)->get(['id','code','name','product_type','category_id'])->keyBy('id') + : collect(); + + $materials = $materialIds->isNotEmpty() + ? Material::query()->where('tenant_id', $tenantId)->whereIn('id', $materialIds)->get(['id','material_code as code','name','unit','category_id'])->keyBy('id') + : collect(); + + return $items->map(function ($row) use ($products, $materials) { + $base = [ + 'id' => (int)$row->id, + 'ref_type' => $row->ref_type, + 'quantity' => $row->quantity, + 'sort_order' => (int)$row->sort_order, + 'is_default' => (int)$row->is_default, + ]; + + if ($row->ref_type === 'PRODUCT') { + $p = $products->get($row->child_product_id); + return $base + [ + 'ref_id' => (int)$row->child_product_id, + 'code' => $p?->code, + 'name' => $p?->name, + 'product_type' => $p?->product_type, + 'category_id' => $p?->category_id, + ]; + } else { // MATERIAL + $m = $materials->get($row->material_id); + return $base + [ + 'ref_id' => (int)$row->material_id, + 'code' => $m?->code, + 'name' => $m?->name, + 'unit' => $m?->unit, + 'category_id' => $m?->category_id, + ]; + } + })->values(); + } + + /** + * 일괄 업서트 + * items[]: { id?, ref_type: PRODUCT|MATERIAL, ref_id: int, quantity: number, sort_order?: int, is_default?: 0|1 } + */ + public function bulkUpsert(int $parentProductId, array $items): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $this->assertProduct($tenantId, $parentProductId); + + if (!is_array($items) || empty($items)) { + throw new BadRequestHttpException(__('error.empty_items')); + } + + $created = 0; $updated = 0; + + DB::transaction(function () use ($tenantId, $userId, $parentProductId, $items, &$created, &$updated) { + foreach ($items as $it) { + $payload = $this->validateItem($it); + + // ref 확인 & 자기참조 방지 + $this->assertReference($tenantId, $parentProductId, $payload['ref_type'], (int)$payload['ref_id']); + + if (!empty($it['id'])) { + $pc = ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->find((int)$it['id']); + if (!$pc) throw new BadRequestHttpException(__('error.not_found')); + + // ref 변경 허용 시: 충돌 검사 + [$childProductId, $materialId] = $this->splitRef($payload); + + $pc->update([ + 'ref_type' => $payload['ref_type'], + 'child_product_id' => $childProductId, + 'material_id' => $materialId, + 'quantity' => $payload['quantity'], + 'sort_order' => $payload['sort_order'] ?? $pc->sort_order, + 'is_default' => $payload['is_default'] ?? $pc->is_default, + 'updated_by' => $userId, + ]); + $updated++; + } else { + // 신규 + [$childProductId, $materialId] = $this->splitRef($payload); + + ProductComponent::create([ + 'tenant_id' => $tenantId, + 'parent_product_id' => $parentProductId, + 'ref_type' => $payload['ref_type'], + 'child_product_id' => $childProductId, + 'material_id' => $materialId, + 'quantity' => $payload['quantity'], + 'sort_order' => $payload['sort_order'] ?? 0, + 'is_default' => $payload['is_default'] ?? 0, + 'created_by' => $userId, + ]); + $created++; + } + } + }); + + return compact('created', 'updated'); + } + + // 단건 수정 + public function update(int $parentProductId, int $itemId, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $this->assertProduct($tenantId, $parentProductId); + + $pc = ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->find($itemId); + + if (!$pc) throw new BadRequestHttpException(__('error.not_found')); + + $v = Validator::make($data, [ + 'ref_type' => 'sometimes|in:PRODUCT,MATERIAL', + 'ref_id' => 'sometimes|integer', + 'quantity' => 'sometimes|numeric|min:0.0001', + 'sort_order' => 'sometimes|integer|min:0', + 'is_default' => 'sometimes|in:0,1', + ]); + $payload = $v->validate(); + + if (isset($payload['ref_type']) || isset($payload['ref_id'])) { + $refType = $payload['ref_type'] ?? $pc->ref_type; + $refId = isset($payload['ref_id']) + ? (int)$payload['ref_id'] + : ($pc->ref_type === 'PRODUCT' ? (int)$pc->child_product_id : (int)$pc->material_id); + + $this->assertReference($tenantId, $parentProductId, $refType, $refId); + [$childProductId, $materialId] = $this->splitRef(['ref_type' => $refType, 'ref_id' => $refId]); + + $pc->ref_type = $refType; + $pc->child_product_id = $childProductId; + $pc->material_id = $materialId; + } + + if (isset($payload['quantity'])) $pc->quantity = $payload['quantity']; + if (isset($payload['sort_order'])) $pc->sort_order = $payload['sort_order']; + if (isset($payload['is_default'])) $pc->is_default = $payload['is_default']; + + $pc->updated_by = $userId; + $pc->save(); + + return $pc->refresh(); + } + + // 삭제 + public function destroy(int $parentProductId, int $itemId): void + { + $tenantId = $this->tenantId(); + $this->assertProduct($tenantId, $parentProductId); + + $pc = ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->find($itemId); + + if (!$pc) throw new BadRequestHttpException(__('error.not_found')); + $pc->delete(); + } + + // 정렬 변경 + public function reorder(int $parentProductId, array $items): void + { + $tenantId = $this->tenantId(); + $this->assertProduct($tenantId, $parentProductId); + + if (!is_array($items)) throw new BadRequestHttpException(__('error.invalid_payload')); + + DB::transaction(function () use ($tenantId, $parentProductId, $items) { + foreach ($items as $row) { + if (!isset($row['id'], $row['sort_order'])) continue; + ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->where('id', (int)$row['id']) + ->update(['sort_order' => (int)$row['sort_order']]); + } + }); + } + + // 요약(간단 합계/건수) + public function summary(int $parentProductId): array + { + $tenantId = $this->tenantId(); + $this->assertProduct($tenantId, $parentProductId); + + $items = ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->get(); + + $cnt = $items->count(); + $cntP = $items->where('ref_type','PRODUCT')->count(); + $cntM = $items->where('ref_type','MATERIAL')->count(); + $qtySum = (string)$items->sum('quantity'); + + return [ + 'count' => $cnt, + 'count_product' => $cntP, + 'count_material'=> $cntM, + 'quantity_sum' => $qtySum, + ]; + } + + // 유효성 검사(중복/자기참조/음수 등) + public function validateBom(int $parentProductId): array + { + $tenantId = $this->tenantId(); + $this->assertProduct($tenantId, $parentProductId); + + $items = ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('parent_product_id', $parentProductId) + ->orderBy('sort_order') + ->get(); + + $errors = []; + $seen = []; + + foreach ($items as $row) { + if ($row->quantity <= 0) { + $errors[] = ['id' => $row->id, 'error' => 'INVALID_QUANTITY']; + } + $key = $row->ref_type . ':' . ($row->ref_type === 'PRODUCT' ? $row->child_product_id : $row->material_id); + if (isset($seen[$key])) { + $errors[] = ['id' => $row->id, 'error' => 'DUPLICATE_ITEM']; + } else { + $seen[$key] = true; + } + // 자기참조 + if ($row->ref_type === 'PRODUCT' && (int)$row->child_product_id === (int)$parentProductId) { + $errors[] = ['id' => $row->id, 'error' => 'SELF_REFERENCE']; + } + } + + return [ + 'valid' => count($errors) === 0, + 'errors' => $errors, + ]; + } + + // ---------- helpers ---------- + + private function validateItem(array $it): array + { + $v = Validator::make($it, [ + 'id' => 'nullable|integer', + 'ref_type' => 'required|in:PRODUCT,MATERIAL', + 'ref_id' => 'required|integer', + 'quantity' => 'required|numeric|min:0.0001', + 'sort_order' => 'nullable|integer|min:0', + 'is_default' => 'nullable|in:0,1', + ]); + return $v->validate(); + } + + private function splitRef(array $payload): array + { + // returns [child_product_id, material_id] + if ($payload['ref_type'] === 'PRODUCT') { + return [(int)$payload['ref_id'], null]; + } + return [null, (int)$payload['ref_id']]; + } + + private function assertProduct(int $tenantId, int $productId): void + { + $exists = Product::query()->where('tenant_id', $tenantId)->where('id', $productId)->exists(); + if (!$exists) { + // ko: 제품 정보를 찾을 수 없습니다. + throw new NotFoundHttpException(__('error.not_found_resource', ['resource' => '제품'])); + } + } + + private function assertReference(int $tenantId, int $parentProductId, string $refType, int $refId): void + { + if ($refType === 'PRODUCT') { + if ($refId === $parentProductId) { + throw new BadRequestHttpException(__('error.invalid_payload')); // 자기참조 방지 + } + $ok = Product::query()->where('tenant_id', $tenantId)->where('id', $refId)->exists(); + if (!$ok) throw new BadRequestHttpException(__('error.not_found')); + } else { + $ok = Material::query()->where('tenant_id', $tenantId)->where('id', $refId)->exists(); + if (!$ok) throw new BadRequestHttpException(__('error.not_found')); + } + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 888908c..baeb004 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -3,8 +3,12 @@ namespace App\Services; use App\Models\Products\CommonCode; +use App\Models\Products\Product; +use App\Models\Commons\Category; +use Illuminate\Support\Facades\Validator; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -class ProductService +class ProductService extends Service { /** @@ -24,16 +28,27 @@ public static function getCategory($request) /** * 내부 재귀 함수 (하위 카테고리 트리 구조로 구성) */ - protected static function fetchCategoryTree($parentId = null, $group = 'category') + protected function fetchCategoryTree(?int $parentId = null) { - $categories = CommonCode::where('code_group', 'category') - ->where('parent_id', $parentId) - ->orderBy('sort_order')->debug(); - $categories = $categories->get(); + $tenantId = $this->tenantId(); // Base Service에서 상속받은 메서드 + + $query = Category::query() + ->when($tenantId, fn($q) => $q->where('tenant_id', $tenantId)) + ->when( + is_null($parentId), + fn($q) => $q->whereNull('parent_id'), + fn($q) => $q->where('parent_id', $parentId) + ) + ->where('is_active', 1) + ->orderBy('sort_order'); + + $categories = $query->get(); foreach ($categories as $category) { - $category->children = self::fetchCategoryTree($category->id); + $children = $this->fetchCategoryTree($category->id); + $category->setRelation('children', $children); } + return $categories; } @@ -46,5 +61,157 @@ public static function getCategoryFlat($group = 'category') return $query->get(); } + // 목록/검색 + public function index(array $params) + { + $tenantId = $this->tenantId(); + + $size = (int)($params['size'] ?? 20); + $q = trim((string)($params['q'] ?? '')); + $categoryId = $params['category_id'] ?? null; + $productType = $params['product_type'] ?? null; // PRODUCT|PART|SUBASSEMBLY... + $active = $params['active'] ?? null; // 1/0 + + $query = Product::query()->where('tenant_id', $tenantId); + + if ($q !== '') { + $query->where(function ($w) use ($q) { + $w->where('name', 'like', "%{$q}%") + ->orWhere('code', 'like', "%{$q}%") + ->orWhere('description', 'like', "%{$q}%"); + }); + } + if ($categoryId) $query->where('category_id', (int)$categoryId); + if ($productType) $query->where('product_type', $productType); + if ($active !== null && $active !== '') $query->where('is_active', (int)$active); + + return $query->orderByDesc('id')->paginate($size); + } + + // 생성 + public function store(array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $v = Validator::make($data, [ + 'code' => 'required|string|max:30', + 'name' => 'required|string|max:100', + 'category_id' => 'required|integer', + 'product_type' => 'required|string|max:30', + 'attributes' => 'nullable|array', + 'description' => 'nullable|string|max:255', + 'is_sellable' => 'nullable|in:0,1', + 'is_purchasable' => 'nullable|in:0,1', + 'is_producible' => 'nullable|in:0,1', + 'is_active' => 'nullable|in:0,1', + ]); + $payload = $v->validate(); + + // tenant별 code 유니크 수동 체크(운영 전 DB 유니크 구성도 권장) + $dup = Product::query() + ->where('tenant_id', $tenantId) + ->where('code', $payload['code']) + ->exists(); + if ($dup) throw new BadRequestHttpException(__('error.duplicate_key')); + + $payload['tenant_id'] = $tenantId; + $payload['created_by'] = $userId; + $payload['is_sellable'] = $payload['is_sellable'] ?? 1; + $payload['is_purchasable'] = $payload['is_purchasable'] ?? 0; + $payload['is_producible'] = $payload['is_producible'] ?? 1; + $payload['is_active'] = $payload['is_active'] ?? 1; + + // attributes array → json 저장 (Eloquent casts가 array면 그대로 가능) + return Product::create($payload); + } + + // 단건 + public function show(int $id) + { + $tenantId = $this->tenantId(); + $p = Product::query()->where('tenant_id', $tenantId)->find($id); + if (!$p) throw new BadRequestHttpException(__('error.not_found')); + return $p; + } + + // 수정 + public function update(int $id, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $p = Product::query()->where('tenant_id', $tenantId)->find($id); + if (!$p) throw new BadRequestHttpException(__('error.not_found')); + + $v = Validator::make($data, [ + 'code' => 'sometimes|string|max:30', + 'name' => 'sometimes|string|max:100', + 'category_id' => 'sometimes|integer', + 'product_type' => 'sometimes|string|max:30', + 'attributes' => 'nullable|array', + 'description' => 'nullable|string|max:255', + 'is_sellable' => 'nullable|in:0,1', + 'is_purchasable' => 'nullable|in:0,1', + 'is_producible' => 'nullable|in:0,1', + 'is_active' => 'nullable|in:0,1', + ]); + $payload = $v->validate(); + + if (isset($payload['code']) && $payload['code'] !== $p->code) { + $dup = Product::query() + ->where('tenant_id', $tenantId) + ->where('code', $payload['code']) + ->exists(); + if ($dup) throw new BadRequestHttpException(__('error.duplicate_key')); + } + + $payload['updated_by'] = $userId; + $p->update($payload); + return $p->refresh(); + } + + // 삭제(soft) + public function destroy(int $id): void + { + $tenantId = $this->tenantId(); + $p = Product::query()->where('tenant_id', $tenantId)->find($id); + if (!$p) throw new BadRequestHttpException(__('error.not_found')); + $p->delete(); + } + + // 간편 검색(모달/드롭다운) + public function search(array $params) + { + $tenantId = $this->tenantId(); + $q = trim((string)($params['q'] ?? '')); + $lim = (int)($params['limit'] ?? 20); + + $qr = Product::query()->where('tenant_id', $tenantId); + if ($q !== '') { + $qr->where(function ($w) use ($q) { + $w->where('name', 'like', "%{$q}%") + ->orWhere('code', 'like', "%{$q}%"); + }); + } + return $qr->orderBy('name')->limit($lim)->get(['id','code','name','product_type','category_id','is_active']); + } + + // 활성 토글 + public function toggle(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $p = Product::query()->where('tenant_id', $tenantId)->find($id); + if (!$p) throw new BadRequestHttpException(__('error.not_found')); + + $p->is_active = $p->is_active ? 0 : 1; + $p->updated_by = $userId; + $p->save(); + + return ['id' => $p->id, 'is_active' => (int)$p->is_active]; + } + } diff --git a/app/Swagger/v1/CategoryExtras.php b/app/Swagger/v1/CategoryExtras.php new file mode 100644 index 0000000..5c0488c --- /dev/null +++ b/app/Swagger/v1/CategoryExtras.php @@ -0,0 +1,564 @@ +enum('ref_type', ['PRODUCT', 'MATERIAL']) + ->default('PRODUCT') + ->after('parent_product_id') + ->comment('참조 대상 타입(PRODUCT=제품, MATERIAL=자재)'); + + $table->unsignedBigInteger('material_id') + ->nullable() + ->after('child_product_id') + ->comment('자재 ID'); + + $table->foreign('material_id') + ->references('id') + ->on('materials') + ->nullOnDelete(); + }); + + // 3) 기존 유니크 키 재정의: + // (tenant_id, parent_product_id, ref_type, child_product_id, material_id, sort_order) + // - 제품/자재 타입과 각각의 ID를 모두 포함하도록 변경 + DB::statement("ALTER TABLE product_components DROP INDEX uq_component_row"); + DB::statement(" + ALTER TABLE product_components + ADD UNIQUE INDEX uq_component_row + (tenant_id, parent_product_id, ref_type, child_product_id, material_id, sort_order) + "); + + // 4) (선택) CHECK 제약: MySQL 8.0.16+ 에서만 유효 + // ref_type=PRODUCT -> child_product_id NOT NULL AND material_id NULL + // ref_type=MATERIAL -> material_id NOT NULL AND child_product_id NULL + try { + $version = DB::selectOne('SELECT VERSION() AS v')->v ?? ''; + // 매우 단순한 버전체크 (8.0 이상일 때 시도) + if (preg_match('/^8\./', $version)) { + DB::statement(" + ALTER TABLE product_components + ADD CONSTRAINT chk_ref_type_consistency + CHECK ( + (ref_type = 'PRODUCT' AND child_product_id IS NOT NULL AND material_id IS NULL) OR + (ref_type = 'MATERIAL' AND material_id IS NOT NULL AND child_product_id IS NULL) + ) + "); + } + } catch (\Throwable $e) { + // CHECK 미지원(DB버전/엔진)일 경우 무시하고 넘어갑니다. + } + } + + public function down(): void + { + // CHECK 제약 삭제 (가능한 경우만) + try { + DB::statement(" + ALTER TABLE product_components + DROP CHECK chk_ref_type_consistency + "); + } catch (\Throwable $e) { + // 무시 + } + + // 유니크 키를 원래대로 복구 + try { + DB::statement("ALTER TABLE product_components DROP INDEX uq_component_row"); + } catch (\Throwable $e) { + // 무시 + } + DB::statement(" + ALTER TABLE product_components + ADD UNIQUE INDEX uq_component_row + (tenant_id, parent_product_id, child_product_id, sort_order) + "); + + Schema::table('product_components', function (Blueprint $table) { + // FK 우선 제거 + $table->dropForeign(['material_id']); + // 컬럼 삭제 + $table->dropColumn(['ref_type', 'material_id']); + }); + + // child_product_id 를 NOT NULL 로 되돌림 + DB::statement(" + ALTER TABLE product_components + MODIFY child_product_id BIGINT UNSIGNED NOT NULL COMMENT '하위 제품/부품 ID'; + "); + } +}; diff --git a/routes/api.php b/routes/api.php index fca7902..1a49971 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ name('v1.categories.destroy'); // 삭제(soft) }); - // Product API + // Category Field API + Route::prefix('categories')->group(function () { + // 목록/생성 (카테고리 기준) + Route::get ('/{id}/fields', [CategoryFieldController::class, 'index'])->name('v1.categories.fields.index'); // ?page&size&sort&order + Route::post ('/{id}/fields', [CategoryFieldController::class, 'store'])->name('v1.categories.fields.store'); + + // 단건 + Route::get ('/fields/{field}', [CategoryFieldController::class, 'show'])->name('v1.categories.fields.show'); + Route::patch ('/fields/{field}', [CategoryFieldController::class, 'update'])->name('v1.categories.fields.update'); + Route::delete('/fields/{field}', [CategoryFieldController::class, 'destroy'])->name('v1.categories.fields.destroy'); + + // 일괄 정렬/업서트 + Route::post ('/{id}/fields/reorder', [CategoryFieldController::class, 'reorder'])->name('v1.categories.fields.reorder'); // [{id,sort_order}] + Route::put ('/{id}/fields/bulk-upsert', [CategoryFieldController::class, 'bulkUpsert'])->name('v1.categories.fields.bulk'); // [{id?,field_key,...}] + }); + + + // Category Template API + Route::prefix('categories')->group(function () { + // 버전 목록/생성 (카테고리 기준) + Route::get ('/{id}/templates', [CategoryTemplateController::class, 'index'])->name('v1.categories.templates.index'); // ?page&size + Route::post ('/{id}/templates', [CategoryTemplateController::class, 'store'])->name('v1.categories.templates.store'); // 새 버전 등록 + + // 단건 + Route::get ('/templates/{tpl}', [CategoryTemplateController::class, 'show'])->name('v1.categories.templates.show'); + Route::patch ('/templates/{tpl}', [CategoryTemplateController::class, 'update'])->name('v1.categories.templates.update'); // remarks 등 메타 수정 + Route::delete('/templates/{tpl}', [CategoryTemplateController::class, 'destroy'])->name('v1.categories.templates.destroy'); + + // 운영 편의 + Route::post ('/{id}/templates/{tpl}/apply', [CategoryTemplateController::class, 'apply'])->name('v1.categories.templates.apply'); // 해당 버전 활성화 + Route::get ('/{id}/templates/{tpl}/preview', [CategoryTemplateController::class, 'preview'])->name('v1.categories.templates.preview');// 렌더용 스냅샷 + // (선택) 버전 간 diff + Route::get ('/{id}/templates/diff', [CategoryTemplateController::class, 'diff'])->name('v1.categories.templates.diff'); // ?a=ver&b=ver + }); + + + // Category Log API + Route::prefix('categories')->group(function () { + Route::get ('/{id}/logs', [CategoryLogController::class, 'index'])->name('v1.categories.logs.index'); // ?action=&from=&to=&page&size + Route::get ('/logs/{log}', [CategoryLogController::class, 'show'])->name('v1.categories.logs.show'); + // (선택) 특정 변경 시점으로 카테고리 복구(템플릿/필드와 별개) + // Route::post('{id}/logs/{log}/restore', [CategoryLogController::class, 'restore'])->name('v1.categories.logs.restore'); + }); + + // Classifications API Route::prefix('classifications')->group(function () { Route::get ('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); // 목록 Route::post ('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); // 생성 @@ -248,5 +296,31 @@ Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제 }); + // Products (모델/부품/서브어셈블리) + Route::prefix('products')->group(function () { + Route::get ('', [ProductController::class, 'index'])->name('v1.products.index'); // 목록/검색(q, category_id, product_type, active, page/size) + Route::post ('', [ProductController::class, 'store'])->name('v1.products.store'); // 생성 + Route::get ('/{id}', [ProductController::class, 'show'])->name('v1.products.show'); // 단건 + Route::patch ('/{id}', [ProductController::class, 'update'])->name('v1.products.update'); // 수정 + Route::delete('/{id}', [ProductController::class, 'destroy'])->name('v1.products.destroy'); // 삭제(soft) + + // (선택) 드롭다운/모달용 간편 검색 & 활성 토글 + Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search'); + Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle'); + }); + + // BOM (product_components: ref_type=PRODUCT|MATERIAL) + Route::prefix('products/{id}/bom')->group(function () { + Route::get ('/items', [ProductBomItemController::class, 'index'])->name('v1.products.bom.items.index'); // 조회(제품+자재 병합) + Route::post ('/items/bulk', [ProductBomItemController::class, 'bulkUpsert'])->name('v1.products.bom.items.bulk'); // 대량 업서트 + Route::patch ('/items/{item}', [ProductBomItemController::class, 'update'])->name('v1.products.bom.items.update'); // 단건 수정 + Route::delete('/items/{item}', [ProductBomItemController::class, 'destroy'])->name('v1.products.bom.items.destroy'); // 단건 삭제 + Route::post ('/items/reorder', [ProductBomItemController::class, 'reorder'])->name('v1.products.bom.items.reorder'); // 정렬 변경 + + // (선택) 합계/검증 + Route::get ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary'); + Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate'); + }); + }); });