fix : BOM구성 API, DB 작업
- product_components 컬럼 변경 - BOM 구성, 카테고리리스트, BOM트리(재귀)호출 API 개발
This commit is contained in:
@@ -68,4 +68,47 @@ public function validateBom(int $id)
|
|||||||
return $this->service->validateBom($id);
|
return $this->service->validateBom($id);
|
||||||
}, 'BOM 유효성 검사');
|
}, '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')
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,16 @@ class Product extends Model
|
|||||||
public function category() { return $this->belongsTo(Category::class, 'category_id'); }
|
public function category() { return $this->belongsTo(Category::class, 'category_id'); }
|
||||||
|
|
||||||
// BOM (자기참조) — 라인 모델 경유
|
// BOM (자기참조) — 라인 모델 경유
|
||||||
public function componentLines() { return $this->hasMany(ProductComponent::class, 'parent_product_id'); } // 라인들
|
public function componentLines()
|
||||||
public function parentLines() { return $this->hasMany(ProductComponent::class, 'child_product_id'); } // 나를 쓰는 상위 라인들
|
{
|
||||||
|
return $this->hasMany(ProductComponent::class, 'parent_product_id')->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라인들
|
||||||
|
public function parentLines()
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductComponent::class, 'child_product_id');
|
||||||
|
} // 나를 쓰는 상위 라인들
|
||||||
|
|
||||||
// 편의: 직접 children/parents 제품에 접근
|
// 편의: 직접 children/parents 제품에 접근
|
||||||
public function children()
|
public function children()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models\Products;
|
namespace App\Models\Products;
|
||||||
|
|
||||||
|
use App\Models\Materials\Material;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use App\Traits\ModelTrait;
|
use App\Traits\ModelTrait;
|
||||||
@@ -16,12 +17,12 @@ class ProductComponent extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'tenant_id',
|
'tenant_id',
|
||||||
'parent_product_id',
|
'parent_product_id',
|
||||||
|
'category_id',
|
||||||
|
'category_name',
|
||||||
'ref_type',
|
'ref_type',
|
||||||
'child_product_id',
|
'ref_id',
|
||||||
'material_id',
|
|
||||||
'quantity',
|
'quantity',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'is_default',
|
|
||||||
'created_by',
|
'created_by',
|
||||||
'updated_by',
|
'updated_by',
|
||||||
];
|
];
|
||||||
@@ -60,7 +61,7 @@ public function childProduct()
|
|||||||
*/
|
*/
|
||||||
public function material()
|
public function material()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\Materials\Material::class, 'material_id');
|
return $this->belongsTo(Material::class, 'material_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------
|
// ---------------------------------------------------
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
use App\Models\Materials\Material;
|
use App\Models\Materials\Material;
|
||||||
use App\Models\Products\Product;
|
use App\Models\Products\Product;
|
||||||
use App\Models\Products\ProductComponent;
|
use App\Models\Products\ProductComponent;
|
||||||
|
use App\Services\Products\ProductComponentResolver;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
@@ -332,4 +335,188 @@ private function assertReference(int $tenantId, int $parentProductId, string $re
|
|||||||
if (!$ok) throw new BadRequestHttpException(__('error.not_found'));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
187
app/Services/Products/ProductComponentResolver.php
Normal file
187
app/Services/Products/ProductComponentResolver.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Products;
|
||||||
|
|
||||||
|
use App\Models\Products\Product;
|
||||||
|
use App\Models\Products\ProductComponent;
|
||||||
|
use App\Services\Service;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ProductComponentResolver extends Service
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ?int $tenantId = null
|
||||||
|
) {
|
||||||
|
// 주입값 없으면 Service::tenantId() 사용 (app('tenant_id')에서 끌어옴)
|
||||||
|
$this->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' => $p->id,
|
||||||
|
'code' => $p->code,
|
||||||
|
'name' => $p->name,
|
||||||
|
'product_type' => $p->product_type,
|
||||||
|
'category_id' => $p->category_id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MATERIAL 등 다른 타입은 여기서 분기 추가
|
||||||
|
// if ($refType === 'MATERIAL') {
|
||||||
|
// $m = DB::table('materials')
|
||||||
|
// ->where('tenant_id', $this->tenantId)
|
||||||
|
// ->where('id', $refId)
|
||||||
|
// ->first(['id','code','name','unit']);
|
||||||
|
// return $m ? ['id'=>$m->id,'code'=>$m->code,'name'=>$m->name,'unit'=>$m->unit] : ['id'=>$refId,'code'=>null,'name'=>null];
|
||||||
|
// }
|
||||||
|
|
||||||
|
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 int $parentId
|
||||||
|
* @param int $depth
|
||||||
|
* @param int $maxDepth
|
||||||
|
* @param array $visited product-id 기준 사이클 방지
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,43 +2,144 @@
|
|||||||
|
|
||||||
namespace App\Swagger\v1;
|
namespace App\Swagger\v1;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @OA\Tag(name="Product", description="제품 카테고리/검색(간단)")
|
* @OA\Tag(name="Product", description="제품 카테고리/검색(간단)")
|
||||||
* @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD")
|
* @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD")
|
||||||
* @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리")
|
* @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리")
|
||||||
*/
|
*
|
||||||
|
* ========= 공용 스키마(이 파일에서 사용하는 것만 정의) =========
|
||||||
|
*
|
||||||
/**
|
* 트리 노드 스키마 (재귀)
|
||||||
* 카테고리 목록 조회 (기존)
|
* @OA\Schema(
|
||||||
* @OA\Get(
|
* schema="BomTreeNode",
|
||||||
* path="/api/v1/product/category",
|
* type="object",
|
||||||
* summary="제품 카테고리 목록 조회",
|
* description="BOM 트리 한 노드(제품/자재 공통)",
|
||||||
* description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.",
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
* tags={"Products"},
|
* @OA\Property(property="type", type="string", example="PRODUCT", description="PRODUCT / MATERIAL 등"),
|
||||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
* @OA\Property(property="code", type="string", example="PRD-001"),
|
||||||
* @OA\Response(
|
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
|
||||||
* response=200,
|
* @OA\Property(property="unit", type="string", nullable=true, example="SET"),
|
||||||
* description="카테고리 목록 조회 성공",
|
* @OA\Property(property="quantity", type="number", format="float", example=1),
|
||||||
* @OA\JsonContent(
|
* @OA\Property(property="sort_order", type="integer", example=0),
|
||||||
* allOf={
|
* @OA\Property(
|
||||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
* property="category",
|
||||||
* @OA\Schema(
|
* type="object",
|
||||||
* @OA\Property(
|
* nullable=true,
|
||||||
* property="data",
|
* @OA\Property(property="id", type="integer", nullable=true, example=2),
|
||||||
* type="array",
|
* @OA\Property(property="name", type="string", nullable=true, example="본체")
|
||||||
* @OA\Items(ref="#/components/schemas/ProductCategory")
|
* ),
|
||||||
* )
|
* @OA\Property(
|
||||||
* )
|
* property="children",
|
||||||
* }
|
* type="array",
|
||||||
* )
|
* description="자식 노드 목록(없으면 빈 배열)",
|
||||||
* ),
|
* @OA\Items(ref="#/components/schemas/BomTreeNode")
|
||||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
* )
|
||||||
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
* )
|
||||||
|
*
|
||||||
|
* BOM 카테고리 사용/추천 항목
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="BomCategoryStat",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="category_id", type="integer", nullable=true, example=1),
|
||||||
|
* @OA\Property(property="category_name", type="string", example="기본"),
|
||||||
|
* @OA\Property(property="count", type="integer", example=5, description="해당 카테고리를 사용하는 BOM 항목 수(빈도)")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* BOM 전체 교체 저장용 스키마
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="BomReplaceItem",
|
||||||
|
* type="object",
|
||||||
|
* required={"ref_type","ref_id","quantity"},
|
||||||
|
* @OA\Property(property="ref_type", type="string", enum={"MATERIAL","PRODUCT"}, example="MATERIAL", description="참조 타입"),
|
||||||
|
* @OA\Property(property="ref_id", type="integer", example=201, description="참조 ID (materials.id 또는 products.id)"),
|
||||||
|
* @OA\Property(property="quantity", type="number", format="float", example=2, description="수량(0 이상, 소수 허용)"),
|
||||||
|
* @OA\Property(property="sort_order", type="integer", nullable=true, example=0, description="정렬 순서(선택)")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="BomReplaceCategory",
|
||||||
|
* type="object",
|
||||||
|
* required={"items"},
|
||||||
|
* @OA\Property(property="id", type="integer", nullable=true, example=1, description="프론트 임시 카테고리 ID(선택)"),
|
||||||
|
* @OA\Property(property="name", type="string", nullable=true, example="기본", description="프론트 카테고리명(선택)"),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="items",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/BomReplaceItem")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="BomReplaceRequest",
|
||||||
|
* type="object",
|
||||||
|
* required={"categories"},
|
||||||
|
* @OA\Property(
|
||||||
|
* property="categories",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/BomReplaceCategory")
|
||||||
|
* ),
|
||||||
|
* example={
|
||||||
|
* "categories": {
|
||||||
|
* {
|
||||||
|
* "id": 1,
|
||||||
|
* "name": "기본",
|
||||||
|
* "items": {
|
||||||
|
* { "ref_type": "MATERIAL", "ref_id": 201, "quantity": 2, "sort_order": 0 },
|
||||||
|
* { "ref_type": "PRODUCT", "ref_id": 301, "quantity": 1 }
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* "id": 2,
|
||||||
|
* "name": "옵션",
|
||||||
|
* "items": {
|
||||||
|
* { "ref_type": "MATERIAL", "ref_id": 202, "quantity": 5 }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="BomReplaceResult",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="deleted_count", type="integer", example=5, description="삭제된 기존 항목 수"),
|
||||||
|
* @OA\Property(property="inserted_count", type="integer", example=7, description="신규로 삽입된 항목 수")
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
class ProductApi
|
class ProductApi
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 카테고리 목록 조회 (기존)
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/product/category",
|
||||||
|
* summary="제품 카테고리 목록 조회",
|
||||||
|
* description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.",
|
||||||
|
* tags={"Products"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="카테고리 목록 조회 성공",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/ProductCategory")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function productCategoryIndex() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제품 목록/검색
|
* 제품 목록/검색
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
@@ -64,7 +165,6 @@ class ProductApi
|
|||||||
*/
|
*/
|
||||||
public function productsIndex() {}
|
public function productsIndex() {}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제품 생성
|
* 제품 생성
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
@@ -85,7 +185,6 @@ public function productsIndex() {}
|
|||||||
*/
|
*/
|
||||||
public function productsStore() {}
|
public function productsStore() {}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제품 단건
|
* 제품 단건
|
||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
@@ -355,4 +454,192 @@ public function bomSummary() {}
|
|||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
public function bomValidate() {}
|
public function bomValidate() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOM 전체 교체 저장
|
||||||
|
* 프론트에서 보낸 현재 BOM 상태로 기존 구성을 모두 삭제 후 재등록합니다.
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/products/{id}/bom",
|
||||||
|
* tags={"Products-BOM"},
|
||||||
|
* summary="BOM 구성 저장(구성 전체 교체)",
|
||||||
|
* description="기존 BOM을 모두 삭제하고, 전달된 categories/items 기준으로 다시 저장합니다.",
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="상위(모델) 제품 ID",
|
||||||
|
* @OA\Schema(type="integer", example=123)
|
||||||
|
* ),
|
||||||
|
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomReplaceRequest")),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="저장 성공",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
* @OA\Property(property="message", type="string", example="BOM 항목이 저장되었습니다."),
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/BomReplaceResult")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="존재하지 않는 URI 또는 데이터", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bomReplace() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제품별 사용 중인 BOM 카테고리 목록
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/products/{id}/bom/categories",
|
||||||
|
* tags={"Products-BOM"},
|
||||||
|
* summary="해당 제품에서 사용 중인 BOM 카테고리 목록",
|
||||||
|
* description="product_components 테이블에서 해당 제품(parent_product_id)의 카테고리(id/name)를 집계하여 반환합니다.",
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="상위(모델) 제품 ID",
|
||||||
|
* @OA\Schema(type="integer", example=123)
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/BomCategoryStat")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* example={
|
||||||
|
* "success": true,
|
||||||
|
* "message": "조회 성공",
|
||||||
|
* "data": {
|
||||||
|
* {"category_id":1, "category_name":"기본", "count":5},
|
||||||
|
* {"category_id":2, "category_name":"옵션", "count":3}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bomCategoriesForProduct() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 전역 카테고리 추천(히스토리 기반)
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/products/bom/categories",
|
||||||
|
* tags={"Products-BOM"},
|
||||||
|
* summary="자주 사용된 BOM 카테고리 추천",
|
||||||
|
* description="테넌트 전체 product_components 데이터를 집계해 카테고리 사용 빈도가 높은 순으로 반환합니다. q로 부분 검색 가능합니다.",
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="q",
|
||||||
|
* in="query",
|
||||||
|
* required=false,
|
||||||
|
* description="카테고리명 부분 검색",
|
||||||
|
* @OA\Schema(type="string", example="기")
|
||||||
|
* ),
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="limit",
|
||||||
|
* in="query",
|
||||||
|
* required=false,
|
||||||
|
* description="최대 항목 수(기본 20)",
|
||||||
|
* @OA\Schema(type="integer", example=20)
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="array",
|
||||||
|
* @OA\Items(ref="#/components/schemas/BomCategoryStat")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* example={
|
||||||
|
* "success": true,
|
||||||
|
* "message": "조회 성공",
|
||||||
|
* "data": {
|
||||||
|
* {"category_id":1, "category_name":"기본", "count":127},
|
||||||
|
* {"category_id":2, "category_name":"옵션", "count":88},
|
||||||
|
* {"category_id":null, "category_name":"패키지", "count":12}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bomCategoriesSuggest() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제품 BOM 트리 조회
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/products/{id}/bom/tree",
|
||||||
|
* tags={"Products-BOM"},
|
||||||
|
* summary="제품 BOM 트리 조회(재귀)",
|
||||||
|
* description="특정 제품의 하위 구성(제품/자재)을 재귀적으로 트리 형태로 반환합니다. depth로 최대 깊이를 제한합니다(기본 10).",
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", minimum=1), description="제품 ID"),
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="depth",
|
||||||
|
* in="query",
|
||||||
|
* required=false,
|
||||||
|
* description="재귀 깊이(루트=0). 기본 10",
|
||||||
|
* @OA\Schema(type="integer", minimum=0, maximum=50, default=10, example=5)
|
||||||
|
* ),
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="조회 성공",
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
* @OA\Property(
|
||||||
|
* property="data",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(
|
||||||
|
* property="product",
|
||||||
|
* type="object",
|
||||||
|
* @OA\Property(property="id", type="integer"),
|
||||||
|
* @OA\Property(property="code", type="string"),
|
||||||
|
* @OA\Property(property="name", type="string"),
|
||||||
|
* @OA\Property(property="unit", type="string", nullable=true),
|
||||||
|
* @OA\Property(property="category_id", type="integer", nullable=true),
|
||||||
|
* @OA\Property(property="product_type", type="string")
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="tree", ref="#/components/schemas/BomTreeNode")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=404, description="대상 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||||
|
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bomTree() {}
|
||||||
}
|
}
|
||||||
|
|||||||
14
config/products.php
Normal file
14
config/products.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
// 기본값: PRODUCT 만 "더 내려갈 수 있는" 타입으로 간주
|
||||||
|
'product_like_types' => [
|
||||||
|
// 전체 기본
|
||||||
|
'*' => ['PRODUCT'],
|
||||||
|
|
||||||
|
// 테넌트 별 오버라이드 예시
|
||||||
|
// '1' => ['PRODUCT', 'MODULE'],
|
||||||
|
// '42' => ['PRODUCT', 'ASSEMBLY', 'KIT'],
|
||||||
|
],
|
||||||
|
// 재귀 기본 최대 깊이
|
||||||
|
'default_tree_depth' => 10,
|
||||||
|
];
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1) 기존 제약/인덱스 해제 (있으면 제거)
|
||||||
|
$dropIndexes = [
|
||||||
|
'uq_component_row',
|
||||||
|
'product_components_tenant_id_child_product_id_index',
|
||||||
|
'product_components_tenant_id_parent_product_id_index',
|
||||||
|
];
|
||||||
|
foreach ($dropIndexes as $idx) {
|
||||||
|
try { DB::statement("ALTER TABLE `product_components` DROP INDEX `$idx`"); } catch (\Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dropFks = [
|
||||||
|
'product_components_child_product_id_foreign',
|
||||||
|
'product_components_material_id_foreign',
|
||||||
|
'product_components_parent_product_id_foreign',
|
||||||
|
];
|
||||||
|
foreach ($dropFks as $fk) {
|
||||||
|
try { DB::statement("ALTER TABLE `product_components` DROP FOREIGN KEY `$fk`"); } catch (\Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 데이터 손실 허용 → TRUNCATE 로 비우고 진행
|
||||||
|
try { DB::statement("TRUNCATE TABLE `product_components`"); } catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
// 3) 컬럼 추가/수정
|
||||||
|
Schema::table('product_components', function (Blueprint $table) {
|
||||||
|
// 프론트 카테고리 메타(선택 저장)
|
||||||
|
if (!Schema::hasColumn('product_components', 'category_id')) {
|
||||||
|
$table->unsignedBigInteger('category_id')->nullable()->after('parent_product_id')
|
||||||
|
->comment('프론트 카테고리 ID(선택)');
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('product_components', 'category_name')) {
|
||||||
|
$table->string('category_name', 100)->nullable()->after('category_id')
|
||||||
|
->comment('프론트 카테고리명(선택)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통합 참조키 ref_id 추가
|
||||||
|
if (!Schema::hasColumn('product_components', 'ref_id')) {
|
||||||
|
$table->unsignedBigInteger('ref_id')->nullable()->after('ref_type')
|
||||||
|
->comment('참조 ID (materials.id 또는 products.id)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ref_type: ENUM → VARCHAR(20)
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
MODIFY COLUMN `ref_type` VARCHAR(20) NOT NULL
|
||||||
|
COMMENT '참조 타입: MATERIAL | PRODUCT'
|
||||||
|
");
|
||||||
|
|
||||||
|
// quantity 정밀도 확장
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
MODIFY COLUMN `quantity` DECIMAL(18,6) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '수량(소수 허용, 0 이상)'
|
||||||
|
");
|
||||||
|
|
||||||
|
// ref_id NOT NULL 전환 (TRUNCATE 했으므로 바로 가능)
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
MODIFY COLUMN `ref_id` BIGINT UNSIGNED NOT NULL
|
||||||
|
COMMENT '참조 ID (materials.id 또는 products.id)'
|
||||||
|
");
|
||||||
|
|
||||||
|
// 불필요 컬럼 제거
|
||||||
|
Schema::table('product_components', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('product_components', 'child_product_id')) {
|
||||||
|
$table->dropColumn('child_product_id');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('product_components', 'material_id')) {
|
||||||
|
$table->dropColumn('material_id');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('product_components', 'is_default')) {
|
||||||
|
$table->dropColumn('is_default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) 인덱스 재구성 (FK 최소화 정책, 조회 성능 중심)
|
||||||
|
Schema::table('product_components', function (Blueprint $table) {
|
||||||
|
$table->index(['tenant_id', 'parent_product_id'], 'idx_tenant_parent');
|
||||||
|
$table->index(['tenant_id', 'ref_type', 'ref_id'], 'idx_tenant_ref');
|
||||||
|
$table->index(['tenant_id', 'category_id'], 'idx_tenant_category');
|
||||||
|
$table->index(['tenant_id', 'sort_order'], 'idx_tenant_sort');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 인덱스 제거
|
||||||
|
foreach (['idx_tenant_parent','idx_tenant_ref','idx_tenant_category','idx_tenant_sort'] as $idx) {
|
||||||
|
try { DB::statement("ALTER TABLE `product_components` DROP INDEX `$idx`"); } catch (\Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 손실 허용: TRUNCATE 후 원형에 가깝게 복원
|
||||||
|
try { DB::statement("TRUNCATE TABLE `product_components`"); } catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
// 컬럼 복원
|
||||||
|
Schema::table('product_components', function (Blueprint $table) {
|
||||||
|
// child_product_id, material_id, is_default 복원
|
||||||
|
if (!Schema::hasColumn('product_components', 'child_product_id')) {
|
||||||
|
$table->unsignedBigInteger('child_product_id')->nullable()->after('ref_type')->comment('하위 제품/부품 ID');
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('product_components', 'material_id')) {
|
||||||
|
$table->unsignedBigInteger('material_id')->nullable()->after('child_product_id')->comment('자재 ID');
|
||||||
|
}
|
||||||
|
if (!Schema::hasColumn('product_components', 'is_default')) {
|
||||||
|
$table->tinyInteger('is_default')->default(0)->after('sort_order')->comment('기본 BOM 여부(1/0)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ref_type: VARCHAR → ENUM
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
MODIFY COLUMN `ref_type` ENUM('PRODUCT','MATERIAL') NOT NULL DEFAULT 'PRODUCT'
|
||||||
|
COMMENT '참조 대상 타입(PRODUCT=제품, MATERIAL=자재)'
|
||||||
|
");
|
||||||
|
|
||||||
|
// quantity 정밀도 원복
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
MODIFY COLUMN `quantity` DECIMAL(18,4) NOT NULL DEFAULT 1.0000
|
||||||
|
");
|
||||||
|
|
||||||
|
// ref_id 제거
|
||||||
|
if (Schema::hasColumn('product_components', 'ref_id')) {
|
||||||
|
Schema::table('product_components', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('ref_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// category 메타 제거
|
||||||
|
Schema::table('product_components', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('product_components', 'category_name')) {
|
||||||
|
$table->dropColumn('category_name');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('product_components', 'category_id')) {
|
||||||
|
$table->dropColumn('category_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 원래 인덱스/제약 복원 (FK 최소화 정책이지만 down에서는 원형 회귀)
|
||||||
|
try {
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
ADD CONSTRAINT `uq_component_row`
|
||||||
|
UNIQUE (`tenant_id`,`parent_product_id`,`ref_type`,`child_product_id`,`material_id`,`sort_order`)
|
||||||
|
");
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
ADD CONSTRAINT `product_components_child_product_id_foreign`
|
||||||
|
FOREIGN KEY (`child_product_id`) REFERENCES `products`(`id`)
|
||||||
|
");
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
ADD CONSTRAINT `product_components_material_id_foreign`
|
||||||
|
FOREIGN KEY (`material_id`) REFERENCES `materials`(`id`) ON DELETE SET NULL
|
||||||
|
");
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE `product_components`
|
||||||
|
ADD CONSTRAINT `product_components_parent_product_id_foreign`
|
||||||
|
FOREIGN KEY (`parent_product_id`) REFERENCES `products`(`id`) ON DELETE CASCADE
|
||||||
|
");
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::statement("
|
||||||
|
CREATE INDEX `product_components_tenant_id_child_product_id_index`
|
||||||
|
ON `product_components`(`tenant_id`,`child_product_id`)
|
||||||
|
");
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::statement("
|
||||||
|
CREATE INDEX `product_components_tenant_id_parent_product_id_index`
|
||||||
|
ON `product_components`(`tenant_id`,`parent_product_id`)
|
||||||
|
");
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -38,6 +38,11 @@
|
|||||||
'fetched' => 'BOM 항목을 조회했습니다.',
|
'fetched' => 'BOM 항목을 조회했습니다.',
|
||||||
'bulk_upsert' => 'BOM 항목이 저장되었습니다.',
|
'bulk_upsert' => 'BOM 항목이 저장되었습니다.',
|
||||||
'reordered' => 'BOM 정렬이 변경되었습니다.',
|
'reordered' => 'BOM 정렬이 변경되었습니다.',
|
||||||
|
'fetch' => 'BOM 항목 조회',
|
||||||
|
'creat' => 'BOM 항목 등록',
|
||||||
|
'update' => 'BOM 항목 수정',
|
||||||
|
'delete' => 'BOM 항목 삭제',
|
||||||
|
'restore' => 'BOM 항목 복구',
|
||||||
],
|
],
|
||||||
|
|
||||||
'category' => [
|
'category' => [
|
||||||
|
|||||||
@@ -307,10 +307,16 @@
|
|||||||
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
|
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
|
||||||
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
|
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
|
||||||
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
|
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
|
||||||
|
|
||||||
|
// 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'); // 해당 제품에서 사용 중
|
||||||
});
|
});
|
||||||
|
|
||||||
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
|
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
|
||||||
Route::prefix('products/{id}/bom')->group(function () {
|
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::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::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::patch ('/items/{item}', [ProductBomItemController::class, 'update'])->name('v1.products.bom.items.update'); // 단건 수정
|
||||||
@@ -320,6 +326,8 @@
|
|||||||
// (선택) 합계/검증
|
// (선택) 합계/검증
|
||||||
Route::get ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary');
|
Route::get ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary');
|
||||||
Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate');
|
Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate');
|
||||||
|
|
||||||
|
Route::get('/tree', [ProductBomItemController::class, 'tree']);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user