From 039fd623df43001242f2e5206511b8f0e63da62b Mon Sep 17 00:00:00 2001 From: kent Date: Sun, 14 Dec 2025 00:20:09 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20products/materials=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - products, materials, product_components 테이블 삭제 마이그레이션 - FK 제약조건 정리 (orders, order_items, material_receipts, lots) - 관련 Models 삭제: Product, Material, ProductComponent 등 - 관련 Controllers 삭제: ProductController, MaterialController, ProductBomItemController - 관련 Services 삭제: ProductService, MaterialService, ProductBomService - 관련 Requests, Swagger 파일 삭제 - 라우트 정리: /products, /materials 엔드포인트 제거 모든 품목 관리는 /items 엔드포인트로 통합됨 item_id_mappings 테이블에 ID 매핑 보존 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Controllers/Api/V1/MaterialController.php | 52 -- .../Api/V1/ProductBomItemController.php | 114 --- .../Controllers/Api/V1/ProductController.php | 82 -- .../Material/MaterialStoreRequest.php | 32 - .../Material/MaterialUpdateRequest.php | 32 - .../Requests/Product/ProductStoreRequest.php | 42 - .../Requests/Product/ProductUpdateRequest.php | 42 - app/Models/Materials/Material.php | 79 -- app/Models/Products/CommonCode.php | 47 -- app/Models/Products/Part.php | 33 - app/Models/Products/Price.php | 258 ------ app/Models/Products/PriceRevision.php | 92 --- app/Models/Products/Product.php | 118 --- app/Models/Products/ProductComponent.php | 115 --- app/Services/MaterialService.php | 519 ------------ app/Services/ProductBomService.php | 537 ------------ app/Services/ProductService.php | 362 -------- .../Products/ProductComponentResolver.php | 234 ------ app/Swagger/v1/MaterialApi.php | 316 ------- app/Swagger/v1/ProductApi.php | 780 ------------------ app/Swagger/v1/ProductExtraSchemas.php | 178 ---- ...312_drop_products_and_materials_tables.php | 150 ++++ routes/api.php | 55 +- 23 files changed, 158 insertions(+), 4111 deletions(-) delete mode 100644 app/Http/Controllers/Api/V1/MaterialController.php delete mode 100644 app/Http/Controllers/Api/V1/ProductBomItemController.php delete mode 100644 app/Http/Controllers/Api/V1/ProductController.php delete mode 100644 app/Http/Requests/Material/MaterialStoreRequest.php delete mode 100644 app/Http/Requests/Material/MaterialUpdateRequest.php delete mode 100644 app/Http/Requests/Product/ProductStoreRequest.php delete mode 100644 app/Http/Requests/Product/ProductUpdateRequest.php delete mode 100644 app/Models/Materials/Material.php delete mode 100644 app/Models/Products/CommonCode.php delete mode 100644 app/Models/Products/Part.php delete mode 100644 app/Models/Products/Price.php delete mode 100644 app/Models/Products/PriceRevision.php delete mode 100644 app/Models/Products/Product.php delete mode 100644 app/Models/Products/ProductComponent.php delete mode 100644 app/Services/MaterialService.php delete mode 100644 app/Services/ProductBomService.php delete mode 100644 app/Services/ProductService.php delete mode 100644 app/Services/Products/ProductComponentResolver.php delete mode 100644 app/Swagger/v1/MaterialApi.php delete mode 100644 app/Swagger/v1/ProductApi.php delete mode 100644 app/Swagger/v1/ProductExtraSchemas.php create mode 100644 database/migrations/2025_12_14_001312_drop_products_and_materials_tables.php diff --git a/app/Http/Controllers/Api/V1/MaterialController.php b/app/Http/Controllers/Api/V1/MaterialController.php deleted file mode 100644 index ae94404..0000000 --- a/app/Http/Controllers/Api/V1/MaterialController.php +++ /dev/null @@ -1,52 +0,0 @@ -service->getMaterials($request->all()); - }, __('message.material.fetched')); - } - - public function store(MaterialStoreRequest $request) - { - return ApiResponse::handle(function () use ($request) { - // 동적 필드 지원을 위해 전체 입력값 전달 (Service에서 검증) - return $this->service->setMaterial($request->all()); - }, __('message.material.created')); - } - - public function show(int $id) - { - return ApiResponse::handle(function () use ($id) { - return $this->service->getMaterial($id); - }, __('message.material.fetched')); - } - - public function update(MaterialUpdateRequest $request, int $id) - { - return ApiResponse::handle(function () use ($request, $id) { - // 동적 필드 지원을 위해 전체 입력값 전달 (Service에서 검증) - return $this->service->updateMaterial($id, $request->all()); - }, __('message.material.updated')); - } - - public function destroy(int $id) - { - return ApiResponse::handle(function () use ($id) { - return $this->service->destroyMaterial($id); - }, __('message.material.deleted')); - } -} diff --git a/app/Http/Controllers/Api/V1/ProductBomItemController.php b/app/Http/Controllers/Api/V1/ProductBomItemController.php deleted file mode 100644 index 50eca59..0000000 --- a/app/Http/Controllers/Api/V1/ProductBomItemController.php +++ /dev/null @@ -1,114 +0,0 @@ -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 유효성 검사'); - } - - /** - * POST /api/v1/products/{id}/bom - * BOM 구성 저장 (기존 전체 삭제 후 재등록) - */ - public function replace(Request $request, int $id) - { - return ApiResponse::handle(function () use ($request, $id) { - // 서비스에서 트랜잭션 처리 + 예외는 글로벌 핸들러로 - return $this->service->replaceBom($id, $request->all()); - }, __('message.bom.creat')); - } - - /** 특정 제품 BOM에서 사용 중인 카테고리 목록 */ - public function listCategories(int $id) - { - return ApiResponse::handle(function () use ($id) { - return $this->service->listCategoriesForProduct($id); - }, __('message.bom.fetch')); - } - - /** 테넌트 전역 카테고리 추천(히스토리) */ - public function suggestCategories(Request $request) - { - return ApiResponse::handle(function () use ($request) { - $q = $request->query('q'); - $limit = (int) ($request->query('limit', 20)); - - return $this->service->listCategoriesForTenant($q, $limit); - }, __('message.bom.fetch')); - } - - /** Bom Tree */ - public function tree(Request $request, int $id) - { - return ApiResponse::handle( - function () use ($request, $id) { - return $this->service->tree($request, $id); - }, __('message.bom.fetch') - ); - } -} diff --git a/app/Http/Controllers/Api/V1/ProductController.php b/app/Http/Controllers/Api/V1/ProductController.php deleted file mode 100644 index 65f1765..0000000 --- a/app/Http/Controllers/Api/V1/ProductController.php +++ /dev/null @@ -1,82 +0,0 @@ -service->getCategory($request); - }, __('message.product.category_fetched')); - } - - // GET /products - public function index(Request $request) - { - return ApiResponse::handle(function () use ($request) { - return $this->service->index($request->all()); - }, __('message.product.fetched')); - } - - // POST /products - public function store(ProductStoreRequest $request) - { - return ApiResponse::handle(function () use ($request) { - return $this->service->store($request->validated()); - }, __('message.product.created')); - } - - // GET /products/{id} - public function show(int $id) - { - return ApiResponse::handle(function () use ($id) { - return $this->service->show($id); - }, __('message.product.fetched')); - } - - // PATCH /products/{id} - public function update(int $id, ProductUpdateRequest $request) - { - return ApiResponse::handle(function () use ($id, $request) { - return $this->service->update($id, $request->validated()); - }, __('message.product.updated')); - } - - // DELETE /products/{id} - public function destroy(int $id) - { - return ApiResponse::handle(function () use ($id) { - $this->service->destroy($id); - - return 'success'; - }, __('message.product.deleted')); - } - - // GET /products/search - public function search(Request $request) - { - return ApiResponse::handle(function () use ($request) { - return $this->service->search($request->all()); - }, __('message.product.searched')); - } - - // Note: toggle 메서드는 is_active 필드 제거로 인해 비활성화됨 - // 필요시 attributes JSON이나 별도 필드로 구현 - // POST /products/{id}/toggle - // public function toggle(int $id) - // { - // return ApiResponse::handle(function () use ($id) { - // return $this->service->toggle($id); - // }, __('message.product.toggled')); - // } -} diff --git a/app/Http/Requests/Material/MaterialStoreRequest.php b/app/Http/Requests/Material/MaterialStoreRequest.php deleted file mode 100644 index fd4c77f..0000000 --- a/app/Http/Requests/Material/MaterialStoreRequest.php +++ /dev/null @@ -1,32 +0,0 @@ - 'nullable|integer', - 'name' => 'required|string|max:100', - 'unit' => 'required|string|max:20', - 'is_inspection' => 'nullable|in:Y,N', - 'search_tag' => 'nullable|string|max:255', - 'remarks' => 'nullable|string|max:500', - 'attributes' => 'nullable|array', - 'attributes.*.label' => 'required|string|max:50', - 'attributes.*.value' => 'required|string|max:100', - 'attributes.*.unit' => 'nullable|string|max:20', - 'options' => 'nullable|array', - 'material_code' => 'nullable|string|max:30', - 'specification' => 'nullable|string|max:255', - ]; - } -} diff --git a/app/Http/Requests/Material/MaterialUpdateRequest.php b/app/Http/Requests/Material/MaterialUpdateRequest.php deleted file mode 100644 index 7c9eee4..0000000 --- a/app/Http/Requests/Material/MaterialUpdateRequest.php +++ /dev/null @@ -1,32 +0,0 @@ - 'nullable|integer', - 'name' => 'sometimes|string|max:100', - 'unit' => 'sometimes|string|max:20', - 'is_inspection' => 'nullable|in:Y,N', - 'search_tag' => 'nullable|string|max:255', - 'remarks' => 'nullable|string|max:500', - 'attributes' => 'nullable|array', - 'attributes.*.label' => 'required|string|max:50', - 'attributes.*.value' => 'required|string|max:100', - 'attributes.*.unit' => 'nullable|string|max:20', - 'options' => 'nullable|array', - 'material_code' => 'nullable|string|max:30', - 'specification' => 'nullable|string|max:255', - ]; - } -} diff --git a/app/Http/Requests/Product/ProductStoreRequest.php b/app/Http/Requests/Product/ProductStoreRequest.php deleted file mode 100644 index 91ece77..0000000 --- a/app/Http/Requests/Product/ProductStoreRequest.php +++ /dev/null @@ -1,42 +0,0 @@ - 'required|string|max:30', - 'name' => 'required|string|max:100', - 'unit' => 'nullable|string|max:10', - 'category_id' => 'required|integer', - 'product_type' => 'required|string|max:30', - 'description' => 'nullable|string|max:255', - - // 상태 플래그 - 'is_sellable' => 'nullable|boolean', - 'is_purchasable' => 'nullable|boolean', - 'is_producible' => 'nullable|boolean', - - // 하이브리드 구조: 고정 필드 - 'safety_stock' => 'nullable|integer|min:0', - 'lead_time' => 'nullable|integer|min:0', - 'is_variable_size' => 'nullable|boolean', - 'product_category' => 'nullable|string|max:20', - 'part_type' => 'nullable|string|max:20', - - // 하이브리드 구조: 동적 필드 - 'attributes' => 'nullable|array', - 'attributes_archive' => 'nullable|array', - ]; - } -} diff --git a/app/Http/Requests/Product/ProductUpdateRequest.php b/app/Http/Requests/Product/ProductUpdateRequest.php deleted file mode 100644 index 6379e53..0000000 --- a/app/Http/Requests/Product/ProductUpdateRequest.php +++ /dev/null @@ -1,42 +0,0 @@ - 'sometimes|string|max:30', - 'name' => 'sometimes|string|max:100', - 'unit' => 'nullable|string|max:10', - 'category_id' => 'sometimes|integer', - 'product_type' => 'sometimes|string|max:30', - 'description' => 'nullable|string|max:255', - - // 상태 플래그 - 'is_sellable' => 'nullable|boolean', - 'is_purchasable' => 'nullable|boolean', - 'is_producible' => 'nullable|boolean', - - // 하이브리드 구조: 고정 필드 - 'safety_stock' => 'nullable|integer|min:0', - 'lead_time' => 'nullable|integer|min:0', - 'is_variable_size' => 'nullable|boolean', - 'product_category' => 'nullable|string|max:20', - 'part_type' => 'nullable|string|max:20', - - // 하이브리드 구조: 동적 필드 - 'attributes' => 'nullable|array', - 'attributes_archive' => 'nullable|array', - ]; - } -} diff --git a/app/Models/Materials/Material.php b/app/Models/Materials/Material.php deleted file mode 100644 index 9eb7bbb..0000000 --- a/app/Models/Materials/Material.php +++ /dev/null @@ -1,79 +0,0 @@ - 'array', - 'options' => 'array', - 'is_active' => 'boolean', - ]; - - protected $hidden = [ - 'deleted_at', - ]; - - // 카테고리 - public function category() - { - return $this->belongsTo(Category::class); - } - - // 자재 입고 내역 - public function receipts() - { - return $this->hasMany(MaterialReceipt::class, 'material_id'); - } - - // 로트 관리 - public function lots() - { - return $this->hasMany(Lot::class, 'material_id'); - } - - // 파일 목록 (N:M, 폴리모픽) - public function files() - { - return $this->morphMany(File::class, 'fileable'); - } - - // 태그 목록 (N:M, 폴리모픽) - public function tags() - { - return $this->morphToMany(Tag::class, 'taggable'); - } -} diff --git a/app/Models/Products/CommonCode.php b/app/Models/Products/CommonCode.php deleted file mode 100644 index 9cc1fa9..0000000 --- a/app/Models/Products/CommonCode.php +++ /dev/null @@ -1,47 +0,0 @@ - 'array', - 'is_active' => 'boolean', - ]; - - // 관계: 상위 코드 - public function parent() - { - return $this->belongsTo(self::class, 'parent_id'); - } - - // 관계: 하위 코드들 - public function children() - { - return $this->hasMany(self::class, 'parent_id'); - } -} diff --git a/app/Models/Products/Part.php b/app/Models/Products/Part.php deleted file mode 100644 index 6b355a5..0000000 --- a/app/Models/Products/Part.php +++ /dev/null @@ -1,33 +0,0 @@ -belongsTo(CommonCode::class, 'category_id'); - } - - public function partType() - { - return $this->belongsTo(CommonCode::class, 'part_type_id'); - } - - // 태그 목록 (N:M, 폴리모픽) - public function tags() - { - return $this->morphToMany(Tag::class, 'taggable'); - } -} diff --git a/app/Models/Products/Price.php b/app/Models/Products/Price.php deleted file mode 100644 index 9454a54..0000000 --- a/app/Models/Products/Price.php +++ /dev/null @@ -1,258 +0,0 @@ - 'decimal:4', - 'processing_cost' => 'decimal:4', - 'loss_rate' => 'decimal:2', - 'margin_rate' => 'decimal:2', - 'sales_price' => 'decimal:4', - 'rounding_unit' => 'integer', - 'effective_from' => 'date', - 'effective_to' => 'date', - 'is_final' => 'boolean', - 'finalized_at' => 'datetime', - ]; - - /** - * 고객 그룹 관계 - */ - public function clientGroup(): BelongsTo - { - return $this->belongsTo(ClientGroup::class, 'client_group_id'); - } - - /** - * 리비전 이력 관계 - */ - public function revisions(): HasMany - { - return $this->hasMany(PriceRevision::class, 'price_id')->orderBy('revision_number', 'desc'); - } - - /** - * 품목 관계 (Polymorphic - item_type_code에 따라) - */ - public function item() - { - if ($this->item_type_code === 'PRODUCT') { - return $this->belongsTo(Product::class, 'item_id'); - } elseif ($this->item_type_code === 'MATERIAL') { - return $this->belongsTo(Material::class, 'item_id'); - } - - return null; - } - - /** - * 제품 관계 (item_type_code = PRODUCT인 경우) - */ - public function product(): BelongsTo - { - return $this->belongsTo(Product::class, 'item_id'); - } - - /** - * 자재 관계 (item_type_code = MATERIAL인 경우) - */ - public function material(): BelongsTo - { - return $this->belongsTo(Material::class, 'item_id'); - } - - // ========== 스코프 ========== - - /** - * 특정 품목 필터 - */ - public function scopeForItem($query, string $itemType, int $itemId) - { - return $query->where('item_type_code', $itemType) - ->where('item_id', $itemId); - } - - /** - * 고객 그룹 필터 - */ - public function scopeForClientGroup($query, ?int $clientGroupId) - { - return $query->where('client_group_id', $clientGroupId); - } - - /** - * 특정 일자에 유효한 단가 - */ - public function scopeValidAt($query, $date) - { - return $query->where('effective_from', '<=', $date) - ->where(function ($q) use ($date) { - $q->whereNull('effective_to') - ->orWhere('effective_to', '>=', $date); - }); - } - - /** - * 상태 필터 - */ - public function scopeStatus($query, string $status) - { - return $query->where('status', $status); - } - - /** - * 활성 단가만 - */ - public function scopeActive($query) - { - return $query->where('status', 'active'); - } - - /** - * 확정된 단가만 - */ - public function scopeFinalized($query) - { - return $query->where('is_final', true); - } - - // ========== 계산 메서드 ========== - - /** - * 총원가 계산 - * 총원가 = (매입단가 + 가공비) × (1 + LOSS율/100) - */ - public function calculateTotalCost(): float - { - $baseCost = ($this->purchase_price ?? 0) + ($this->processing_cost ?? 0); - $lossMultiplier = 1 + (($this->loss_rate ?? 0) / 100); - - return $baseCost * $lossMultiplier; - } - - /** - * 판매단가 계산 (마진율 기반) - * 판매단가 = 반올림(총원가 × (1 + 마진율/100), 반올림단위, 반올림규칙) - */ - public function calculateSalesPrice(): float - { - $totalCost = $this->calculateTotalCost(); - $marginMultiplier = 1 + (($this->margin_rate ?? 0) / 100); - $rawPrice = $totalCost * $marginMultiplier; - - return $this->applyRounding($rawPrice); - } - - /** - * 반올림 적용 - */ - private function applyRounding(float $value): float - { - $unit = $this->rounding_unit ?: 1; - - return match ($this->rounding_rule) { - 'ceil' => ceil($value / $unit) * $unit, - 'floor' => floor($value / $unit) * $unit, - default => round($value / $unit) * $unit, // 'round' - }; - } - - /** - * 확정 가능 여부 - */ - public function canFinalize(): bool - { - return ! $this->is_final && in_array($this->status, ['draft', 'active']); - } - - /** - * 수정 가능 여부 - */ - public function canEdit(): bool - { - return ! $this->is_final; - } - - /** - * 스냅샷 생성 (리비전용) - */ - public function toSnapshot(): array - { - return [ - 'purchase_price' => $this->purchase_price, - 'processing_cost' => $this->processing_cost, - 'loss_rate' => $this->loss_rate, - 'margin_rate' => $this->margin_rate, - 'sales_price' => $this->sales_price, - 'rounding_rule' => $this->rounding_rule, - 'rounding_unit' => $this->rounding_unit, - 'supplier' => $this->supplier, - 'effective_from' => $this->effective_from?->format('Y-m-d'), - 'effective_to' => $this->effective_to?->format('Y-m-d'), - 'status' => $this->status, - 'is_final' => $this->is_final, - 'note' => $this->note, - ]; - } -} diff --git a/app/Models/Products/PriceRevision.php b/app/Models/Products/PriceRevision.php deleted file mode 100644 index 914e818..0000000 --- a/app/Models/Products/PriceRevision.php +++ /dev/null @@ -1,92 +0,0 @@ - 'integer', - 'changed_at' => 'datetime', - 'before_snapshot' => 'array', - 'after_snapshot' => 'array', - ]; - - /** - * 단가 관계 - */ - public function price(): BelongsTo - { - return $this->belongsTo(Price::class, 'price_id'); - } - - /** - * 변경자 관계 - */ - public function changedByUser(): BelongsTo - { - return $this->belongsTo(\App\Models\Members\User::class, 'changed_by'); - } - - /** - * 변경된 필드 목록 추출 - */ - public function getChangedFields(): array - { - if (! $this->before_snapshot) { - return array_keys($this->after_snapshot ?? []); - } - - $changed = []; - foreach ($this->after_snapshot as $key => $newValue) { - $oldValue = $this->before_snapshot[$key] ?? null; - if ($oldValue !== $newValue) { - $changed[] = $key; - } - } - - return $changed; - } - - /** - * 특정 필드의 이전/이후 값 - */ - public function getFieldChange(string $field): array - { - return [ - 'before' => $this->before_snapshot[$field] ?? null, - 'after' => $this->after_snapshot[$field] ?? null, - ]; - } -} diff --git a/app/Models/Products/Product.php b/app/Models/Products/Product.php deleted file mode 100644 index 718fe46..0000000 --- a/app/Models/Products/Product.php +++ /dev/null @@ -1,118 +0,0 @@ - 'array', - 'attributes_archive' => 'array', - 'options' => 'array', - 'bom' => 'array', - 'bending_details' => 'array', - 'certification_start_date' => 'date', - 'certification_end_date' => 'date', - 'is_sellable' => 'boolean', - 'is_purchasable' => 'boolean', - 'is_producible' => 'boolean', - 'is_variable_size' => 'boolean', - 'is_active' => 'boolean', - ]; - - protected $hidden = [ - 'deleted_at', - ]; - - // 분류 - public function category() - { - return $this->belongsTo(Category::class, 'category_id'); - } - - // BOM (자기참조) — 라인 모델 경유 - public function componentLines() - { - return $this->hasMany(ProductComponent::class, 'parent_product_id')->orderBy('sort_order'); - } - - // 라인들 - public function parentLines() - { - return $this->hasMany(ProductComponent::class, 'child_product_id'); - } // 나를 쓰는 상위 라인들 - - // 편의: 직접 children/parents 제품에 접근 - public function children() - { - return $this->belongsToMany( - self::class, 'product_components', 'parent_product_id', 'child_product_id' - )->withPivot(['quantity', 'sort_order', 'is_default']) - ->withTimestamps(); - } - - public function parents() - { - return $this->belongsToMany( - self::class, 'product_components', 'child_product_id', 'parent_product_id' - )->withPivot(['quantity', 'sort_order', 'is_default']) - ->withTimestamps(); - } - - // 파일 / 태그 (폴리모픽) - public function files() - { - return $this->morphMany(File::class, 'fileable'); - } - - public function tags() - { - return $this->morphToMany(Tag::class, 'taggable'); - } - - // 스코프 - public function scopeType($q, string $type) - { - return $q->where('product_type', $type); - } - - public function scopeSellable($q) - { - return $q->where('is_sellable', 1); - } - - public function scopePurchasable($q) - { - return $q->where('is_purchasable', 1); - } - - public function scopeProducible($q) - { - return $q->where('is_producible', 1); - } -} diff --git a/app/Models/Products/ProductComponent.php b/app/Models/Products/ProductComponent.php deleted file mode 100644 index f6f6fd1..0000000 --- a/app/Models/Products/ProductComponent.php +++ /dev/null @@ -1,115 +0,0 @@ - 'decimal:6', - 'attributes' => 'array', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'deleted_at' => 'datetime', - ]; - - protected $hidden = [ - 'deleted_at', - ]; - - /** - * 상위 제품 (모델/제품) - */ - public function parentProduct() - { - return $this->belongsTo(Product::class, 'parent_product_id'); - } - - /** - * 참조된 제품 또는 자재를 동적으로 가져오기 - * ref_type에 따라 Product 또는 Material을 반환 - */ - public function referencedItem() - { - if ($this->ref_type === 'PRODUCT') { - return $this->belongsTo(Product::class, 'ref_id'); - } elseif ($this->ref_type === 'MATERIAL') { - return $this->belongsTo(Material::class, 'ref_id'); - } - - return null; - } - - /** - * 하위 제품 (ref_type = PRODUCT일 때만) - */ - public function product() - { - return $this->belongsTo(Product::class, 'ref_id') - ->where('ref_type', 'PRODUCT'); - } - - /** - * 하위 자재 (ref_type = MATERIAL일 때만) - */ - public function material() - { - return $this->belongsTo(Material::class, 'ref_id') - ->where('ref_type', 'MATERIAL'); - } - - // --------------------------------------------------- - // 🔎 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/MaterialService.php b/app/Services/MaterialService.php deleted file mode 100644 index e02fd06..0000000 --- a/app/Services/MaterialService.php +++ /dev/null @@ -1,519 +0,0 @@ -tenantId(); - - // 1. SystemFields에서 materials 테이블 고정 컬럼 - $systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_MATERIALS); - - // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 - $columnFields = ItemField::where('tenant_id', $tenantId) - ->where('source_table', 'materials') - ->where('storage_type', 'column') - ->whereNotNull('field_key') - ->pluck('field_key') - ->toArray(); - - // 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드) - $apiFields = ['item_type', 'type_code', 'bom']; - - return array_unique(array_merge($systemFields, $columnFields, $apiFields)); - } - - /** - * 정의된 필드 외의 동적 필드를 options로 추출 - */ - private function extractDynamicOptions(array $params): array - { - $knownFields = $this->getKnownFields(); - - $dynamicOptions = []; - foreach ($params as $key => $value) { - if (! in_array($key, $knownFields) && $value !== null && $value !== '') { - $dynamicOptions[$key] = $value; - } - } - - return $dynamicOptions; - } - - /** - * 기존 options 배열과 동적 필드를 병합 - * - 기존 options가 [{label, value}] 배열이면 동적 필드를 배열 항목으로 추가 - * - 기존 options가 {key: value} 맵이면 동적 필드를 맵에 병합 - */ - private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array - { - if (! is_array($existingOptions) || empty($existingOptions)) { - // 기존 options가 없으면 동적 필드만 반환 - return $dynamicOptions; - } - - // 기존 options가 연관 배열(맵)인지 판별 - $isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1); - - if ($isAssoc) { - // 맵 형태면 단순 병합 - return array_merge($existingOptions, $dynamicOptions); - } - - // 배열 형태 [{label, value}]면 동적 필드를 배열 항목으로 추가 - foreach ($dynamicOptions as $key => $value) { - $existingOptions[] = ['label' => $key, 'value' => $value]; - } - - return $existingOptions; - } - - /** 공통 검증 헬퍼 */ - protected function v(array $input, array $rules) - { - $v = Validator::make($input, $rules); - if ($v->fails()) { - return ['error' => $v->errors()->first(), 'code' => 422]; - } - - return $v->validated(); - } - - /** 목록 */ - public function getMaterials(array $params) - { - $tenantId = $this->tenantId(); - - $p = $this->v($params, [ - 'q' => 'nullable|string|max:100', - 'category' => 'nullable|integer|min:1', - 'page' => 'nullable|integer|min:1', - 'per_page' => 'nullable|integer|min:1|max:200', - ]); - if (isset($p['error'])) { - return $p; - } - - $q = Material::query() - ->where('tenant_id', $tenantId); // SoftDeletes가 있으면 기본적으로 deleted_at IS NULL - - if (! empty($p['category'])) { - $q->where('category_id', (int) $p['category']); - } - if (! empty($p['q'])) { - $kw = '%'.$p['q'].'%'; - $q->where(function ($w) use ($kw) { - $w->where('item_name', 'like', $kw) - ->orWhere('name', 'like', $kw) - ->orWhere('material_code', 'like', $kw) - ->orWhere('search_tag', 'like', $kw); - }); - } - - $q->orderBy('id'); - - $perPage = $p['per_page'] ?? 20; - $page = $p['page'] ?? null; - - return $q->paginate($perPage, ['*'], 'page', $page); - } - - /** 단건 조회 */ - public function getMaterial(int $id) - { - $tenantId = $this->tenantId(); - - /** @var Material|null $row */ - $row = Material::query() - ->where('tenant_id', $tenantId) - ->find($id); - - if (! $row) { - return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404]; - } - - // 모델에서 casts가 없을 수 있으니 안전하게 배열화 - $row->attributes = is_array($row->attributes) ? $row->attributes : ($row->attributes ? json_decode($row->attributes, true) : null); - $row->options = is_array($row->options) ? $row->options : ($row->options ? json_decode($row->options, true) : null); - - return $row; - } - - /** 등록 */ - public function setMaterial(array $params) - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - // 동적 필드를 options에 병합 - $dynamicOptions = $this->extractDynamicOptions($params); - if (! empty($dynamicOptions)) { - $existingOptions = $params['options'] ?? []; - $params['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); - } - - $p = $this->v($params, [ - 'category_id' => 'nullable|integer|min:1', - 'name' => 'required|string|max:100', - 'unit' => 'required|string|max:10', - 'is_inspection' => 'nullable|in:Y,N', - 'search_tag' => 'nullable|string', - 'remarks' => 'nullable|string', - 'attributes' => 'nullable|array', // [{label,value,unit}] 또는 map - 'options' => 'nullable|array', // [{label,value,unit}] 또는 map - 'material_code' => 'nullable|string|max:50', - 'specification' => 'nullable|string|max:100', - ]); - if (isset($p['error'])) { - return $p; - } - - // material_code 중복 체크 (삭제된 레코드 포함 - DB unique 제약은 deleted_at 무시) - if (! empty($p['material_code'])) { - $duplicate = Material::withTrashed() - ->where('tenant_id', $tenantId) - ->where('material_code', $p['material_code']) - ->first(['id', 'name', 'deleted_at']); - - if ($duplicate) { - if ($duplicate->deleted_at) { - return [ - 'error' => "자재코드 '{$p['material_code']}'가 삭제된 자재에서 사용 중입니다. 해당 자재를 복구하거나 완전 삭제 후 다시 시도하세요.", - 'code' => 422, - 'deleted_material_id' => $duplicate->id, - ]; - } - - return ['error' => "자재코드 '{$p['material_code']}'가 이미 존재합니다.", 'code' => 422]; - } - } - - // 기존 normalizeAttributes 사용(그대로), options는 새 normalizeOptions 사용 - $attributes = $this->normalizeAttributes($p['attributes'] ?? null); - $options = $this->normalizeOptions($p['options'] ?? null); - - $itemName = $this->buildItemName($p['name'], $attributes); - $specText = $p['specification'] ?? $this->buildSpecText($attributes); - - $m = new Material; - $m->tenant_id = $tenantId; - $m->category_id = $p['category_id'] ?? null; - $m->name = $p['name']; - $m->item_name = $itemName; - $m->specification = $specText; - $m->material_code = $p['material_code'] ?? null; - $m->unit = $p['unit']; - $m->is_inspection = $p['is_inspection'] ?? 'N'; - $m->search_tag = $p['search_tag'] ?? null; - $m->remarks = $p['remarks'] ?? null; - $m->attributes = $attributes ?? null; - $m->options = $options ?? null; - $m->created_by = $userId ?? 0; - $m->updated_by = $userId ?? null; - $m->save(); - - return $this->getMaterial($m->id); - } - - /** 수정 */ - public function updateMaterial(int $id, array $params = []) - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - /** @var Material|null $exists */ - $exists = Material::query()->where('tenant_id', $tenantId)->find($id); - if (! $exists) { - return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404]; - } - - // 동적 필드를 options에 병합 - $dynamicOptions = $this->extractDynamicOptions($params); - if (! empty($dynamicOptions)) { - $existingOptions = $params['options'] ?? []; - $params['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); - } - - $p = $this->v($params, [ - 'category_id' => 'nullable|integer|min:1', - 'name' => 'nullable|string|max:100', - 'unit' => 'nullable|string|max:10', - 'is_inspection' => 'nullable|in:Y,N', - 'search_tag' => 'nullable|string', - 'remarks' => 'nullable|string', - 'attributes' => 'nullable|array', // [{label,value,unit}] 또는 map - 'options' => 'nullable|array', // [{label,value,unit}] 또는 map - 'material_code' => 'nullable|string|max:50', - 'specification' => 'nullable|string|max:100', - ]); - if (isset($p['error'])) { - return $p; - } - - // material_code 중복 체크 (삭제된 레코드 포함 - DB unique 제약은 deleted_at 무시) - $finalMaterialCode = $p['material_code'] ?? $exists->material_code; - if (! empty($finalMaterialCode)) { - $duplicate = Material::withTrashed() - ->where('tenant_id', $tenantId) - ->where('material_code', $finalMaterialCode) - ->where('id', '!=', $id) - ->first(['id', 'name', 'deleted_at']); - - if ($duplicate) { - if ($duplicate->deleted_at) { - return [ - 'error' => "자재코드 '{$finalMaterialCode}'가 삭제된 자재에서 사용 중입니다. 해당 자재를 복구하거나 완전 삭제 후 다시 시도하세요.", - 'code' => 422, - 'deleted_material_id' => $duplicate->id, - ]; - } - - return ['error' => "자재코드 '{$finalMaterialCode}'가 이미 존재합니다.", 'code' => 422]; - } - } - - $currentAttrs = is_array($exists->attributes) ? $exists->attributes - : ($exists->attributes ? json_decode($exists->attributes, true) : null); - $currentOpts = is_array($exists->options) ? $exists->options - : ($exists->options ? json_decode($exists->options, true) : null); - - // 변경 점만 정규화 - $attrs = array_key_exists('attributes', $p) - ? $this->normalizeAttributes($p['attributes']) - : $currentAttrs; - $opts = array_key_exists('options', $p) - ? $this->normalizeOptions($p['options']) - : $currentOpts; - - $baseName = array_key_exists('name', $p) ? ($p['name'] ?? $exists->name) : $exists->name; - - $exists->category_id = $p['category_id'] ?? $exists->category_id; - $exists->name = $baseName; - $exists->item_name = $this->buildItemName($baseName, $attrs); - $exists->specification = array_key_exists('specification', $p) - ? ($p['specification'] ?? null) - : ($exists->specification ?: $this->buildSpecText($attrs)); - $exists->material_code = $p['material_code'] ?? $exists->material_code; - $exists->unit = $p['unit'] ?? $exists->unit; - $exists->is_inspection = $p['is_inspection'] ?? $exists->is_inspection; - $exists->search_tag = $p['search_tag'] ?? $exists->search_tag; - $exists->remarks = $p['remarks'] ?? $exists->remarks; - - if (array_key_exists('attributes', $p)) { - $exists->attributes = $attrs; - } - if (array_key_exists('options', $p)) { - $exists->options = $opts; - } - - $exists->updated_by = $userId ?? $exists->updated_by; - $exists->save(); - - return $this->getMaterial($exists->id); - } - - /** 삭제(소프트) */ - public function destroyMaterial(int $id) - { - $tenantId = $this->tenantId(); - - /** @var Material|null $row */ - $row = Material::query() - ->where('tenant_id', $tenantId) - ->find($id); - - if (! $row) { - return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404]; - } - - // 사용 중인 자재 삭제 방지 - $usageCheck = $this->checkMaterialUsage($id, $tenantId); - if ($usageCheck['in_use']) { - return [ - 'error' => '사용 중인 자재는 삭제할 수 없습니다.', - 'code' => 422, - 'usage' => $usageCheck['details'], - ]; - } - - $row->delete(); - - return ['id' => $id, 'deleted_at' => now()->toDateTimeString()]; - } - - /** - * 자재 사용 여부 체크 - * - material_receipts: 입고 내역 - * - lots: 로트 관리 - * - product_components: BOM 구성품 (ref_type='MATERIAL') - */ - private function checkMaterialUsage(int $materialId, int $tenantId): array - { - $details = []; - - // 1. 입고 내역 체크 - $receiptCount = \App\Models\Materials\MaterialReceipt::where('tenant_id', $tenantId) - ->where('material_id', $materialId) - ->count(); - if ($receiptCount > 0) { - $details['receipts'] = $receiptCount; - } - - // 2. 로트 체크 - $lotCount = \App\Models\Qualitys\Lot::where('tenant_id', $tenantId) - ->where('material_id', $materialId) - ->count(); - if ($lotCount > 0) { - $details['lots'] = $lotCount; - } - - // 3. BOM 구성품 체크 (ref_type='MATERIAL', ref_id=material_id) - $bomCount = \App\Models\Products\ProductComponent::where('tenant_id', $tenantId) - ->where('ref_type', 'MATERIAL') - ->where('ref_id', $materialId) - ->count(); - if ($bomCount > 0) { - $details['bom_components'] = $bomCount; - } - - return [ - 'in_use' => ! empty($details), - 'details' => $details, - ]; - } - - /* ------------------------- - 헬퍼: 규격/품목명 빌더 - attributes 예시: - [ - {"label":"두께","value":"10","unit":"T"}, - {"label":"길이","value":"150","unit":"CM"} - ] - → item_name: "철판 10T 150CM" - → specification: "두께 10T, 길이 150CM" - ------------------------- */ - - private function normalizeAttributes(?array $attrs): ?array - { - if (! $attrs) { - return null; - } - - $out = []; - foreach ($attrs as $a) { - if (! is_array($a)) { - continue; - } - $label = trim((string) ($a['label'] ?? '')); - $value = trim((string) ($a['value'] ?? '')); - $unit = trim((string) ($a['unit'] ?? '')); - if ($label === '' && $value === '' && $unit === '') { - continue; - } - - $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; - } - - return $out ?: null; - } - - private function buildItemName(string $name, ?array $attrs): string - { - if (! $attrs || count($attrs) === 0) { - return $name; - } - - $parts = []; - foreach ($attrs as $a) { - $value = (string) ($a['value'] ?? ''); - $unit = (string) ($a['unit'] ?? ''); - $chunk = trim($value.$unit); - if ($chunk !== '') { - $parts[] = $chunk; - } - } - - return trim($name.' '.implode(' ', $parts)); - } - - private function buildSpecText(?array $attrs): ?string - { - if (! $attrs || count($attrs) === 0) { - return null; - } - - $parts = []; - foreach ($attrs as $a) { - $label = (string) ($a['label'] ?? ''); - $value = (string) ($a['value'] ?? ''); - $unit = (string) ($a['unit'] ?? ''); - $valueWithUnit = trim($value.$unit); - - if ($label !== '' && $valueWithUnit !== '') { - $parts[] = "{$label} {$valueWithUnit}"; - } elseif ($valueWithUnit !== '') { - $parts[] = $valueWithUnit; - } - } - - return $parts ? implode(', ', $parts) : null; - } - - /** - * options 입력을 [{label, value, unit}] 형태로 정규화. - * - 맵 형태 {"key": "value"}도 배열로 변환 - * - 항상 [{label, value, unit}] 형태로 저장 - */ - private function normalizeOptions(?array $in): ?array - { - if (! $in) { - return null; - } - - // 연관 맵 형태인지 간단 판별 - $isAssoc = array_keys($in) !== range(0, count($in) - 1); - - if ($isAssoc) { - // 맵 형태를 [{label, value, unit}] 배열로 변환 - $out = []; - foreach ($in as $k => $v) { - $label = trim((string) $k); - $value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE); - if ($label !== '' || $value !== '') { - $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; - } - } - - return $out ?: null; - } - - // 리스트(triple) 정규화 - $out = []; - foreach ($in as $a) { - if (! is_array($a)) { - continue; - } - $label = trim((string) ($a['label'] ?? '')); - $value = trim((string) ($a['value'] ?? '')); - $unit = trim((string) ($a['unit'] ?? '')); - - if ($label === '' && $value === '') { - continue; - } - - $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; - } - - return $out ?: null; - } -} diff --git a/app/Services/ProductBomService.php b/app/Services/ProductBomService.php deleted file mode 100644 index 3560ede..0000000 --- a/app/Services/ProductBomService.php +++ /dev/null @@ -1,537 +0,0 @@ -tenantId(); - - // 부모 제품 유효성 - $this->assertProduct($tenantId, $parentProductId); - - $items = ProductComponent::query() - ->where('tenant_id', $tenantId) - ->where('parent_product_id', $parentProductId) - ->orderBy('sort_order') - ->get(); - - // 리졸브(제품/자재) - ref_id 기준 - $productIds = $items->where('ref_type', 'PRODUCT')->pluck('ref_id')->filter()->unique()->values(); - $materialIds = $items->where('ref_type', 'MATERIAL')->pluck('ref_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, - 'ref_id' => (int) $row->ref_id, - 'quantity' => $row->quantity, - 'sort_order' => (int) $row->sort_order, - 'is_default' => (int) $row->is_default, - ]; - - if ($row->ref_type === 'PRODUCT') { - $p = $products->get($row->ref_id); - - return $base + [ - 'code' => $p?->code, - 'name' => $p?->name, - 'product_type' => $p?->product_type, - 'category_id' => $p?->category_id, - ]; - } else { // MATERIAL - $m = $materials->get($row->ref_id); - - return $base + [ - '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; - $createdIds = []; - $updatedIds = []; - - DB::transaction(function () use ($tenantId, $userId, $parentProductId, $items, &$created, &$updated, &$createdIds, &$updatedIds) { - 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')); - } - - $pc->update([ - 'ref_type' => $payload['ref_type'], - 'ref_id' => (int) $payload['ref_id'], - 'quantity' => $payload['quantity'], - 'sort_order' => $payload['sort_order'] ?? $pc->sort_order, - 'is_default' => $payload['is_default'] ?? $pc->is_default, - 'updated_by' => $userId, - ]); - $updated++; - $updatedIds[] = $pc->id; - } else { - // 신규 - $pc = ProductComponent::create([ - 'tenant_id' => $tenantId, - 'parent_product_id' => $parentProductId, - 'ref_type' => $payload['ref_type'], - 'ref_id' => (int) $payload['ref_id'], - 'quantity' => $payload['quantity'], - 'sort_order' => $payload['sort_order'] ?? 0, - 'is_default' => $payload['is_default'] ?? 0, - 'created_by' => $userId, - ]); - $created++; - $createdIds[] = $pc->id; - } - } - }); - - return [ - 'created' => $created, - 'updated' => $updated, - 'created_ids' => $createdIds, - 'updated_ids' => $updatedIds, - ]; - } - - // 단건 수정 - 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'] - : (int) $pc->ref_id; - - $this->assertReference($tenantId, $parentProductId, $refType, $refId); - - $pc->ref_type = $refType; - $pc->ref_id = $refId; - } - - 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_id; - if (isset($seen[$key])) { - $errors[] = ['id' => $row->id, 'error' => 'DUPLICATE_ITEM']; - } else { - $seen[$key] = true; - } - // 자기참조 - if ($row->ref_type === 'PRODUCT' && (int) $row->ref_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 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')); - } - } - } - - /** - * 특정 제품의 BOM을 전체 교체(기존 삭제 → 새 데이터 일괄 삽입) - * - $productId: products.id - * - $payload: ['categories' => [ {id?, name?, items: [{ref_type, ref_id, quantity, sort_order?}, ...]}, ... ]] - * 반환: ['deleted_count' => int, 'inserted_count' => int] - */ - public function replaceBom(int $productId, array $payload): array - { - if ($productId <= 0) { - throw new BadRequestHttpException(__('error.bad_request')); // 400 - } - - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - // 0) ====== 빈 카테고리 제거 ====== - $rawCats = Arr::get($payload, 'categories', []); - $normalized = []; - - foreach ((array) $rawCats as $cat) { - $catId = Arr::get($cat, 'id'); - $catName = Arr::get($cat, 'name'); - - $items = array_values(array_filter((array) Arr::get($cat, 'items', []), function ($it) { - $type = Arr::get($it, 'ref_type'); - $id = (int) Arr::get($it, 'ref_id'); - $qty = Arr::get($it, 'quantity'); - - return in_array($type, ['MATERIAL', 'PRODUCT'], true) - && $id > 0 - && is_numeric($qty); - })); - - if (count($items) === 0) { - continue; // 아이템 없으면 skip - } - - $normalized[] = [ - 'id' => $catId, - 'name' => $catName, - 'items' => $items, - ]; - } - - // 🔕 전부 비었으면: 기존 BOM 전체 삭제 후 성공 - if (count($normalized) === 0) { - $deleted = ProductComponent::where('tenant_id', $tenantId) - ->where('parent_product_id', $productId) - ->delete(); - - return [ - 'deleted_count' => $deleted, - 'inserted_count' => 0, - 'message' => '모든 BOM 항목이 비어 기존 데이터를 삭제했습니다.', - ]; - } - - // 1) ====== 검증 ====== - $v = Validator::make( - ['categories' => $normalized], - [ - 'categories' => ['required', 'array', 'min:1'], - 'categories.*.id' => ['nullable', 'integer'], - 'categories.*.name' => ['nullable', 'string', 'max:100'], - 'categories.*.items' => ['required', 'array', 'min:1'], - 'categories.*.items.*.ref_type' => ['required', 'in:MATERIAL,PRODUCT'], - 'categories.*.items.*.ref_id' => ['required', 'integer', 'min:1'], - 'categories.*.items.*.quantity' => ['required', 'numeric', 'min:0'], - 'categories.*.items.*.sort_order' => ['nullable', 'integer', 'min:0'], - ] - ); - if ($v->fails()) { - throw new ValidationException($v, null, __('error.validation_failed')); - } - - // 2) ====== 플랫 레코드 생성 (note 제거) ====== - $rows = []; - $now = now(); - foreach ($normalized as $cat) { - $catId = Arr::get($cat, 'id'); - $catName = Arr::get($cat, 'name'); - - foreach ($cat['items'] as $idx => $item) { - $rows[] = [ - 'tenant_id' => $tenantId, - 'parent_product_id' => $productId, - 'category_id' => $catId, - 'category_name' => $catName, - 'ref_type' => $item['ref_type'], - 'ref_id' => (int) $item['ref_id'], - 'quantity' => (string) $item['quantity'], - 'sort_order' => isset($item['sort_order']) ? (int) $item['sort_order'] : $idx, - 'created_by' => $userId, - 'updated_by' => $userId, - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - } - - // 3) ====== 트랜잭션: 기존 삭제 후 신규 삽입 ====== - return DB::transaction(function () use ($tenantId, $productId, $rows) { - $deleted = ProductComponent::where('tenant_id', $tenantId) - ->where('parent_product_id', $productId) - ->delete(); - - $inserted = 0; - foreach (array_chunk($rows, 500) as $chunk) { - $ok = ProductComponent::insert($chunk); - $inserted += $ok ? count($chunk) : 0; - } - - return [ - 'deleted_count' => $deleted, - 'inserted_count' => $inserted, - 'message' => 'BOM 저장 성공', - ]; - }); - } - - /** 제품별: 현재 BOM에 쓰인 카테고리 */ - public function listCategoriesForProduct(int $productId): array - { - if ($productId <= 0) { - throw new BadRequestHttpException(__('error.bad_request')); - } - - $tenantId = $this->tenantId(); - - $rows = ProductComponent::query() - ->where('tenant_id', $tenantId) - ->where('parent_product_id', $productId) - ->whereNotNull('category_name') - ->select([ - DB::raw('category_id'), - DB::raw('category_name'), - DB::raw('COUNT(*) as count'), - ]) - ->groupBy('category_id', 'category_name') - ->orderByDesc('count') - ->orderBy('category_name') - ->get() - ->toArray(); - - return $rows; - } - - /** 테넌트 전역: 자주 쓰인 카테고리 추천(+검색) */ - public function listCategoriesForTenant(?string $q, int $limit = 20): array - { - $tenantId = $this->tenantId(); - - $query = ProductComponent::query() - ->where('tenant_id', $tenantId) - ->whereNotNull('category_name') - ->select([ - DB::raw('category_id'), - DB::raw('category_name'), - DB::raw('COUNT(*) as count'), - ]) - ->groupBy('category_id', 'category_name'); - - if ($q) { - $query->havingRaw('category_name LIKE ?', ["%{$q}%"]); - } - - $rows = $query - ->orderByDesc('count') - ->orderBy('category_name') - ->limit($limit > 0 ? $limit : 20) - ->get() - ->toArray(); - - return $rows; - } - - public function tree($request, int $productId): array - { - $depth = (int) $request->query('depth', config('products.default_tree_depth', 10)); - $resolver = app(ProductComponentResolver::class); - - // 트리 배열만 반환 (ApiResponse가 바깥에서 래핑) - return $resolver->resolveTree($productId, $depth); - } -} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php deleted file mode 100644 index 16fc9a9..0000000 --- a/app/Services/ProductService.php +++ /dev/null @@ -1,362 +0,0 @@ -tenantId(); - - // 1. SystemFields에서 products 테이블 고정 컬럼 - $systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_PRODUCTS); - - // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 - $columnFields = ItemField::where('tenant_id', $tenantId) - ->where('source_table', 'products') - ->where('storage_type', 'column') - ->whereNotNull('field_key') - ->pluck('field_key') - ->toArray(); - - // 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드) - $apiFields = ['item_type', 'type_code', 'bom']; - - return array_unique(array_merge($systemFields, $columnFields, $apiFields)); - } - - /** - * 정의된 필드 외의 동적 필드를 options로 추출 - */ - private function extractDynamicOptions(array $params): array - { - $knownFields = $this->getKnownFields(); - - $dynamicOptions = []; - foreach ($params as $key => $value) { - if (! in_array($key, $knownFields) && $value !== null && $value !== '') { - $dynamicOptions[$key] = $value; - } - } - - return $dynamicOptions; - } - - /** - * 기존 options 배열과 동적 필드를 병합 - */ - private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array - { - if (! is_array($existingOptions) || empty($existingOptions)) { - return $dynamicOptions; - } - - $isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1); - - if ($isAssoc) { - return array_merge($existingOptions, $dynamicOptions); - } - - foreach ($dynamicOptions as $key => $value) { - $existingOptions[] = ['label' => $key, 'value' => $value]; - } - - return $existingOptions; - } - - /** - * options 입력을 [{label, value, unit}] 형태로 정규화 - */ - private function normalizeOptions(?array $in): ?array - { - if (! $in) { - return null; - } - - $isAssoc = array_keys($in) !== range(0, count($in) - 1); - - if ($isAssoc) { - $out = []; - foreach ($in as $k => $v) { - $label = trim((string) $k); - $value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE); - if ($label !== '' || $value !== '') { - $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; - } - } - - return $out ?: null; - } - - $out = []; - foreach ($in as $a) { - if (! is_array($a)) { - continue; - } - $label = trim((string) ($a['label'] ?? '')); - $value = trim((string) ($a['value'] ?? '')); - $unit = trim((string) ($a['unit'] ?? '')); - - if ($label === '' && $value === '') { - continue; - } - - $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; - } - - return $out ?: null; - } - - /** - * 카테고리 트리 전체 조회 (parent_id = null 기준) - */ - public function getCategory($request) - { - $parentId = $request->parentId ?? null; - $group = $request->group ?? 'category'; - - // 재귀적으로 트리 구성 - $list = $this->fetchCategoryTree($parentId, $group); - - return $list; - } - - /** - * 내부 재귀 함수 (하위 카테고리 트리 구조로 구성) - */ - protected function fetchCategoryTree(?int $parentId = null) - { - $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) { - $children = $this->fetchCategoryTree($category->id); - $category->setRelation('children', $children); - } - - return $categories; - } - - /** - * (예시) 기존의 flat 리스트 조회 - */ - public static function getCategoryFlat($group = 'category') - { - $query = CommonCode::where('code_group', $group)->whereNull('parent_id'); - - 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() - ->with('category:id,name') // 필요한 컬럼만 가져오기 - ->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); - } - // Note: is_active 필드는 하이브리드 구조로 전환하면서 제거됨 - // 필요시 attributes JSON이나 별도 필드로 관리 - // if ($active !== null && $active !== '') { - // $query->where('is_active', (int) $active); - // } - - $paginator = $query->orderBy('id')->paginate($size); - - // 날짜 형식을 위해 분리 - $paginator->setCollection( - $paginator->getCollection()->transform(function ($item) { - $arr = $item->toArray(); - $arr['created_at'] = $item->created_at - ? $item->created_at->format('Y-m-d') - : null; - - return $arr; - }) - ); - - return $paginator; - } - - // 생성 - public function store(array $data) - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - // 동적 필드를 options에 병합 - $dynamicOptions = $this->extractDynamicOptions($data); - if (! empty($dynamicOptions)) { - $existingOptions = $data['options'] ?? []; - $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); - } - - // options 정규화 - if (isset($data['options'])) { - $data['options'] = $this->normalizeOptions($data['options']); - } - - $payload = $data; - - // tenant별 code 유니크 수동 체크 - $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'] ?? true; - $payload['is_purchasable'] = $payload['is_purchasable'] ?? false; - $payload['is_producible'] = $payload['is_producible'] ?? true; - - 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')); - } - - // 동적 필드를 options에 병합 - $dynamicOptions = $this->extractDynamicOptions($data); - if (! empty($dynamicOptions)) { - $existingOptions = $data['options'] ?? []; - $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); - } - - // options 정규화 - if (isset($data['options'])) { - $data['options'] = $this->normalizeOptions($data['options']); - } - - $payload = $data; - - // code 변경 시 중복 체크 - 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']); - } - - // Note: toggle 메서드는 is_active 필드 제거로 인해 비활성화됨 - // 필요시 attributes JSON이나 별도 필드로 구현 - // 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/Services/Products/ProductComponentResolver.php b/app/Services/Products/ProductComponentResolver.php deleted file mode 100644 index 5104dee..0000000 --- a/app/Services/Products/ProductComponentResolver.php +++ /dev/null @@ -1,234 +0,0 @@ -tenantId = $this->tenantId ?? $this->tenantId(); - } - - /** 테넌트별로 "제품처럼 자식이 있을 수 있는" ref_type 목록 */ - protected function productLikeTypes(): array - { - $all = config('products.product_like_types', []); - $byTenant = Arr::get($all, (string) $this->tenantId, null); - - if (is_array($byTenant) && $byTenant) { - return $byTenant; - } - - return Arr::get($all, '*', ['PRODUCT']); - } - - /** 한 부모 ID에 달린 컴포넌트 라인들 로드 (메모이즈) */ - protected function getLinesForParent(int $parentId): array - { - // 간단 메모이즈(요청 범위); 대량 호출 방지 - static $memo = []; - if (array_key_exists($parentId, $memo)) { - return $memo[$parentId]; - } - - $rows = ProductComponent::where('tenant_id', $this->tenantId) - ->where('parent_product_id', $parentId) - ->orderBy('sort_order')->orderBy('id') - ->get([ - 'id', 'tenant_id', 'parent_product_id', - 'category_id', 'category_name', - 'ref_type', 'ref_id', 'quantity', 'sort_order', - ]) - ->map(fn ($r) => $r->getAttributes()) // ✅ 핵심 수정 - ->all(); - - return $memo[$parentId] = $rows; - } - - /** ref_type/ref_id 에 해당하는 노드의 "표시용 정보"를 로드 */ - protected function resolveNodeInfo(string $refType, int $refId): array - { - if ($refType === 'PRODUCT') { - $p = Product::query() - ->where('tenant_id', $this->tenantId) - ->find($refId, ['id', 'code', 'name', 'product_type', 'category_id']); - if (! $p) { - return [ - 'id' => $refId, - 'code' => null, - 'name' => null, - 'product_type' => null, - 'category_id' => null, - ]; - } - - return [ - 'id' => (int) $p->id, - 'code' => $p->code, - 'name' => $p->name, - 'product_type' => $p->product_type, - 'category_id' => $p->category_id, - ]; - } - - // ✅ MATERIAL 분기: materials 테이블 스키마 반영 - if ($refType === 'MATERIAL') { - $m = DB::table('materials') - ->where('tenant_id', $this->tenantId) - ->where('id', $refId) - ->whereNull('deleted_at') // 소프트 삭제 고려 - ->first([ - 'id', - 'material_code', // 코드 - 'item_name', // 표시명(있으면 우선) - 'name', // fallback 표시명 - 'specification', // 규격 - 'unit', - 'category_id', - ]); - - if (! $m) { - return [ - 'id' => (int) $refId, - 'code' => null, - 'name' => null, - 'unit' => null, - 'category_id' => null, - ]; - } - - // item_name 우선, 없으면 name 사용 - $displayName = $m->item_name ?: $m->name; - - return [ - 'id' => (int) $m->id, - 'code' => $m->material_code, // 표준 코드 필드 - 'name' => $displayName, // 사용자에게 보일 이름 - 'unit' => $m->unit, - 'spec' => $m->specification, // 있으면 프론트에서 활용 가능 - 'category_id' => $m->category_id, - ]; - } - - // 알 수 없는 타입 폴백 - return [ - 'id' => $refId, - 'code' => null, - 'name' => null, - ]; - } - - /** - * 단일 제품을 루트로 트리를 생성 (재귀 / 사이클 방지 / 깊이 제한) - * - * @param int $productId 루트 제품 ID - * @param int|null $maxDepth 최대 깊이(루트=0). null 이면 config default - * @return array 트리 구조 - */ - public function resolveTree(int $productId, ?int $maxDepth = null): array - { - $maxDepth = $maxDepth ?? (int) config('products.default_tree_depth', 10); - - $root = Product::query() - ->where('tenant_id', $this->tenantId) - ->findOrFail($productId, ['id', 'code', 'name', 'product_type', 'category_id']); - - $visited = []; // 사이클 방지용 (product id 기준) - - $node = [ - 'type' => 'PRODUCT', - 'id' => $root->id, - 'code' => $root->code, - 'name' => $root->name, - 'product_type' => $root->product_type, - 'category_id' => $root->category_id, - 'quantity' => 1, // 루트는 수량 1로 간주 - 'category' => null, // 루트는 임의 - 'children' => [], - 'depth' => 0, - ]; - - $node['children'] = $this->resolveChildren($root->id, 0, $maxDepth, $visited); - - return $node; - } - - /** - * 하위 노드(들) 재귀 확장 - * - * @param array $visited product-id 기준 사이클 방지 - */ - protected function resolveChildren(int $parentId, int $depth, int $maxDepth, array &$visited): array - { - // 깊이 제한 - if ($depth >= $maxDepth) { - return []; - } - - $lines = $this->getLinesForParent($parentId); - if (! $lines) { - return []; - } - - $productLike = $this->productLikeTypes(); - $children = []; - - foreach ($lines as $line) { - $refType = (string) $line['ref_type']; - $refId = (int) $line['ref_id']; - $qty = (float) $line['quantity']; - - if (! $refType || $refId <= 0) { - // 로그 남기고 스킵 - // logger()->warning('Invalid component line', ['line' => $line]); - continue; - } - - $info = $this->resolveNodeInfo($refType, $refId); - - $child = [ - 'type' => $refType, - 'id' => $info['id'] ?? $refId, - 'code' => $info['code'] ?? null, - 'name' => $info['name'] ?? null, - 'product_type' => $info['product_type'] ?? null, - 'category_id' => $info['category_id'] ?? null, - 'quantity' => $qty, - 'category' => [ - 'id' => $line['category_id'], - 'name' => $line['category_name'], - ], - 'sort_order' => (int) $line['sort_order'], - 'children' => [], - 'depth' => $depth + 1, - ]; - - // 제품처럼 자식이 달릴 수 있는 타입이면 재귀 - if (in_array($refType, $productLike, true)) { - // 사이클 방지: 같은 product id 재방문 금지 - $pid = (int) $child['id']; - if ($pid > 0) { - if (isset($visited[$pid])) { - $child['cycle'] = true; // 표식만 남기고 children 안탐 - } else { - $visited[$pid] = true; - $child['children'] = $this->resolveChildren($pid, $depth + 1, $maxDepth, $visited); - unset($visited[$pid]); // 백트래킹 - } - } - } - - $children[] = $child; - } - - return $children; - } -} diff --git a/app/Swagger/v1/MaterialApi.php b/app/Swagger/v1/MaterialApi.php deleted file mode 100644 index 53f085d..0000000 --- a/app/Swagger/v1/MaterialApi.php +++ /dev/null @@ -1,316 +0,0 @@ -dropForeignKeyIfExists($table, 'orders', 'fk_orders_product'); + }); + + Schema::table('order_items', function (Blueprint $table) { + $this->dropForeignKeyIfExists($table, 'order_items', 'fk_order_items_product'); + }); + + // 2. FK 제약조건 삭제 (materials 참조) + Schema::table('material_receipts', function (Blueprint $table) { + $this->dropForeignKeyIfExists($table, 'material_receipts', 'fk_receipts_material_id'); + }); + + Schema::table('lots', function (Blueprint $table) { + $this->dropForeignKeyIfExists($table, 'lots', 'fk_lots_material_id'); + }); + + // 3. 관련 테이블 삭제 (종속 테이블 먼저) + Schema::dropIfExists('product_components'); // products의 BOM 테이블 + + // 4. products 테이블 삭제 + Schema::dropIfExists('products'); + + // 5. materials 테이블 삭제 + Schema::dropIfExists('materials'); + + // 6. 삭제 완료 로그 + DB::statement("SELECT 'Dropped: products, materials, product_components tables' AS result"); + } + + /** + * 롤백: 테이블 재생성 (데이터 복원 불가) + * + * 주의: 데이터는 item_id_mappings를 통해 items 테이블에서 복원해야 함 + */ + public function down(): void + { + // products 테이블 재생성 + if (! Schema::hasTable('products')) { + Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('code', 100); + $table->string('name', 255); + $table->string('unit', 20)->nullable(); + $table->unsignedBigInteger('category_id')->nullable(); + $table->enum('type', ['PRODUCT', 'PART', 'SUBASSEMBLY'])->default('PRODUCT'); + $table->json('attributes')->nullable(); + $table->json('options')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'code']); + $table->index(['tenant_id', 'type']); + }); + } + + // materials 테이블 재생성 + if (! Schema::hasTable('materials')) { + Schema::create('materials', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('code', 100); + $table->string('name', 255); + $table->string('unit', 20)->nullable(); + $table->unsignedBigInteger('category_id')->nullable(); + $table->enum('type', ['SUB_MATERIAL', 'RAW_MATERIAL', 'CONSUMABLE'])->default('SUB_MATERIAL'); + $table->json('attributes')->nullable(); + $table->json('options')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'code']); + $table->index(['tenant_id', 'type']); + }); + } + + // product_components 테이블 재생성 + if (! Schema::hasTable('product_components')) { + Schema::create('product_components', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('product_id'); + $table->string('ref_type', 20); // MATERIAL, PRODUCT + $table->unsignedBigInteger('ref_id'); + $table->decimal('quantity', 10, 4)->default(1); + $table->string('unit', 20)->nullable(); + $table->integer('order_no')->default(0); + $table->text('note')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'product_id']); + }); + } + + // FK 재생성 (주의: 데이터가 없으면 무결성 오류 가능) + // 실제 운영에서는 데이터 복원 후 FK를 별도로 추가해야 함 + DB::statement("SELECT 'Tables recreated. FK constraints NOT restored. Restore data from items table using item_id_mappings.' AS warning"); + } + + /** + * FK 존재 여부 확인 후 삭제 + */ + private function dropForeignKeyIfExists(Blueprint $table, string $tableName, string $fkName): void + { + $fkExists = DB::select(" + SELECT COUNT(*) as cnt + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = ? + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + ", [config('database.connections.mysql.database'), $tableName, $fkName]); + + if ($fkExists[0]->cnt > 0) { + $table->dropForeign($fkName); + } + } +}; diff --git a/routes/api.php b/routes/api.php index 20e7f0a..7a40266 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,14 +35,14 @@ use App\Http\Controllers\Api\V1\ItemsBomController; use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsFileController; -use App\Http\Controllers\Api\V1\MaterialController; +// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\ModelSetController; use App\Http\Controllers\Api\V1\PermissionController; use App\Http\Controllers\Api\V1\PostController; use App\Http\Controllers\Api\V1\PricingController; -use App\Http\Controllers\Api\V1\ProductBomItemController; -use App\Http\Controllers\Api\V1\ProductController; +// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 +// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 use App\Http\Controllers\Api\V1\QuoteController; use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RegisterController; @@ -409,35 +409,10 @@ Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); // 변경이력 }); - // Products & Materials (제품/자재 통합 관리) - Route::prefix('products')->group(function () { + // REMOVED: Products & Materials 라우트 삭제됨 (products/materials 테이블 삭제) + // 모든 품목 관리는 /items 엔드포인트 사용 - // 제품 카테고리 (기존 product/category에서 이동) - Route::get('/categories', [ProductController::class, 'getCategory'])->name('v1.products.categories'); // 제품 카테고리 - - // 자재 관리 (기존 독립 materials에서 이동) - ProductController 기본 라우팅보다 앞에 위치 - Route::get('/materials', [MaterialController::class, 'index'])->name('v1.products.materials.index'); // 자재 목록 - Route::post('/materials', [MaterialController::class, 'store'])->name('v1.products.materials.store'); // 자재 생성 - Route::get('/materials/{id}', [MaterialController::class, 'show'])->name('v1.products.materials.show'); // 자재 단건 - Route::patch('/materials/{id}', [MaterialController::class, 'update'])->name('v1.products.materials.update'); // 자재 수정 - Route::delete('/materials/{id}', [MaterialController::class, 'destroy'])->name('v1.products.materials.destroy'); // 자재 삭제 - - // (선택) 드롭다운/모달용 간편 검색 & 활성 토글 - Route::get('/search', [ProductController::class, 'search'])->name('v1.products.search'); - Route::post('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle'); - - 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) - - // BOM 카테고리 - Route::get('bom/categories', [ProductBomItemController::class, 'suggestCategories'])->name('v1.products.bom.categories.suggest'); // 전역(테넌트) 추천 - Route::get('{id}/bom/categories', [ProductBomItemController::class, 'listCategories'])->name('v1.products.bom.categories'); // 해당 제품에서 사용 중 - }); - - // Items (통합 품목 조회 - materials + products UNION) + // Items (통합 품목 관리 - items 테이블) Route::prefix('items')->group(function () { Route::get('', [ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록 Route::post('', [ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성 @@ -469,22 +444,8 @@ Route::delete('/{fileId}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (file_id) }); - // BOM (product_components: ref_type=PRODUCT|MATERIAL) - Route::prefix('products/{id}/bom')->group(function () { - Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace'); - - 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'); - - Route::get('/tree', [ProductBomItemController::class, 'tree'])->name('v1.products.bom.tree'); - }); + // REMOVED: products/{id}/bom 라우트 삭제됨 (product_components 테이블 삭제) + // BOM 관리는 /items/{id}/bom 엔드포인트 사용 // 설계 전용 (Design) - 운영과 분리된 네임스페이스/경로 Route::prefix('design')->group(function () {