2025-08-25 17:46:34 +09:00
< ? php
namespace App\Services ;
use App\Models\Materials\Material ;
use App\Models\Products\Product ;
use App\Models\Products\ProductComponent ;
2025-08-29 16:22:05 +09:00
use App\Services\Products\ProductComponentResolver ;
2025-11-06 17:45:49 +09:00
use Illuminate\Support\Arr ;
2025-08-25 17:46:34 +09:00
use Illuminate\Support\Facades\DB ;
use Illuminate\Support\Facades\Validator ;
2025-08-29 16:22:05 +09:00
use Illuminate\Validation\ValidationException ;
2025-08-25 17:46:34 +09:00
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException ;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException ;
class ProductBomService extends Service
{
/**
* 목록 : 제품 / 자재를 통합 반환
* - 반환 형태 예 :
* [
* { " id " : 10 , " ref_type " : " PRODUCT " , " ref_id " : 3 , " code " : " P-003 " , " name " : " 모듈A " , " quantity " : " 2.0000 " , " sort_order " : 1 , " is_default " : 1 },
* { " id " : 11 , " ref_type " : " MATERIAL " , " ref_id " : 5 , " code " : " M-005 " , " name " : " 알루미늄판 " , " unit " : " EA " , " quantity " : " 4.0000 " , " sort_order " : 2 }
* ]
*/
public function index ( int $parentProductId , array $params )
{
$tenantId = $this -> tenantId ();
// 부모 제품 유효성
$this -> assertProduct ( $tenantId , $parentProductId );
$items = ProductComponent :: query ()
-> where ( 'tenant_id' , $tenantId )
-> where ( 'parent_product_id' , $parentProductId )
-> orderBy ( 'sort_order' )
-> get ();
// 리졸브(제품/자재)
2025-11-06 17:45:49 +09:00
$productIds = $items -> where ( 'ref_type' , 'PRODUCT' ) -> pluck ( 'child_product_id' ) -> filter () -> unique () -> values ();
2025-08-25 17:46:34 +09:00
$materialIds = $items -> where ( 'ref_type' , 'MATERIAL' ) -> pluck ( 'material_id' ) -> filter () -> unique () -> values ();
2025-11-06 17:45:49 +09:00
$products = $productIds -> isNotEmpty ()
? Product :: query () -> where ( 'tenant_id' , $tenantId ) -> whereIn ( 'id' , $productIds ) -> get ([ 'id' , 'code' , 'name' , 'product_type' , 'category_id' ]) -> keyBy ( 'id' )
2025-08-25 17:46:34 +09:00
: collect ();
$materials = $materialIds -> isNotEmpty ()
2025-11-06 17:45:49 +09:00
? Material :: query () -> where ( 'tenant_id' , $tenantId ) -> whereIn ( 'id' , $materialIds ) -> get ([ 'id' , 'material_code as code' , 'name' , 'unit' , 'category_id' ]) -> keyBy ( 'id' )
2025-08-25 17:46:34 +09:00
: collect ();
return $items -> map ( function ( $row ) use ( $products , $materials ) {
$base = [
2025-11-06 17:45:49 +09:00
'id' => ( int ) $row -> id ,
'ref_type' => $row -> ref_type ,
'quantity' => $row -> quantity ,
'sort_order' => ( int ) $row -> sort_order ,
'is_default' => ( int ) $row -> is_default ,
2025-08-25 17:46:34 +09:00
];
if ( $row -> ref_type === 'PRODUCT' ) {
$p = $products -> get ( $row -> child_product_id );
2025-11-06 17:45:49 +09:00
2025-08-25 17:46:34 +09:00
return $base + [
2025-11-06 17:45:49 +09:00
'ref_id' => ( int ) $row -> child_product_id ,
'code' => $p ? -> code ,
'name' => $p ? -> name ,
'product_type' => $p ? -> product_type ,
'category_id' => $p ? -> category_id ,
];
2025-08-25 17:46:34 +09:00
} else { // MATERIAL
$m = $materials -> get ( $row -> material_id );
2025-11-06 17:45:49 +09:00
2025-08-25 17:46:34 +09:00
return $base + [
2025-11-06 17:45:49 +09:00
'ref_id' => ( int ) $row -> material_id ,
'code' => $m ? -> code ,
'name' => $m ? -> name ,
'unit' => $m ? -> unit ,
'category_id' => $m ? -> category_id ,
];
2025-08-25 17:46:34 +09:00
}
}) -> 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 ();
2025-11-06 17:45:49 +09:00
$userId = $this -> apiUserId ();
2025-08-25 17:46:34 +09:00
$this -> assertProduct ( $tenantId , $parentProductId );
2025-11-06 17:45:49 +09:00
if ( ! is_array ( $items ) || empty ( $items )) {
2025-08-25 17:46:34 +09:00
throw new BadRequestHttpException ( __ ( 'error.empty_items' ));
}
2025-11-06 17:45:49 +09:00
$created = 0 ;
$updated = 0 ;
2025-08-25 17:46:34 +09:00
DB :: transaction ( function () use ( $tenantId , $userId , $parentProductId , $items , & $created , & $updated ) {
foreach ( $items as $it ) {
$payload = $this -> validateItem ( $it );
// ref 확인 & 자기참조 방지
2025-11-06 17:45:49 +09:00
$this -> assertReference ( $tenantId , $parentProductId , $payload [ 'ref_type' ], ( int ) $payload [ 'ref_id' ]);
2025-08-25 17:46:34 +09:00
2025-11-06 17:45:49 +09:00
if ( ! empty ( $it [ 'id' ])) {
2025-08-25 17:46:34 +09:00
$pc = ProductComponent :: query ()
-> where ( 'tenant_id' , $tenantId )
-> where ( 'parent_product_id' , $parentProductId )
2025-11-06 17:45:49 +09:00
-> find (( int ) $it [ 'id' ]);
if ( ! $pc ) {
throw new BadRequestHttpException ( __ ( 'error.not_found' ));
}
2025-08-25 17:46:34 +09:00
// ref 변경 허용 시: 충돌 검사
[ $childProductId , $materialId ] = $this -> splitRef ( $payload );
$pc -> update ([
2025-11-06 17:45:49 +09:00
'ref_type' => $payload [ 'ref_type' ],
2025-08-25 17:46:34 +09:00
'child_product_id' => $childProductId ,
2025-11-06 17:45:49 +09:00
'material_id' => $materialId ,
'quantity' => $payload [ 'quantity' ],
'sort_order' => $payload [ 'sort_order' ] ? ? $pc -> sort_order ,
'is_default' => $payload [ 'is_default' ] ? ? $pc -> is_default ,
'updated_by' => $userId ,
2025-08-25 17:46:34 +09:00
]);
$updated ++ ;
} else {
// 신규
[ $childProductId , $materialId ] = $this -> splitRef ( $payload );
ProductComponent :: create ([
2025-11-06 17:45:49 +09:00
'tenant_id' => $tenantId ,
2025-08-25 17:46:34 +09:00
'parent_product_id' => $parentProductId ,
2025-11-06 17:45:49 +09:00
'ref_type' => $payload [ 'ref_type' ],
'child_product_id' => $childProductId ,
'material_id' => $materialId ,
'quantity' => $payload [ 'quantity' ],
'sort_order' => $payload [ 'sort_order' ] ? ? 0 ,
'is_default' => $payload [ 'is_default' ] ? ? 0 ,
'created_by' => $userId ,
2025-08-25 17:46:34 +09:00
]);
$created ++ ;
}
}
});
return compact ( 'created' , 'updated' );
}
// 단건 수정
public function update ( int $parentProductId , int $itemId , array $data )
{
$tenantId = $this -> tenantId ();
2025-11-06 17:45:49 +09:00
$userId = $this -> apiUserId ();
2025-08-25 17:46:34 +09:00
$this -> assertProduct ( $tenantId , $parentProductId );
$pc = ProductComponent :: query ()
-> where ( 'tenant_id' , $tenantId )
-> where ( 'parent_product_id' , $parentProductId )
-> find ( $itemId );
2025-11-06 17:45:49 +09:00
if ( ! $pc ) {
throw new BadRequestHttpException ( __ ( 'error.not_found' ));
}
2025-08-25 17:46:34 +09:00
$v = Validator :: make ( $data , [
2025-11-06 17:45:49 +09:00
'ref_type' => 'sometimes|in:PRODUCT,MATERIAL' ,
'ref_id' => 'sometimes|integer' ,
'quantity' => 'sometimes|numeric|min:0.0001' ,
2025-08-25 17:46:34 +09:00
'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 ;
2025-11-06 17:45:49 +09:00
$refId = isset ( $payload [ 'ref_id' ])
? ( int ) $payload [ 'ref_id' ]
: ( $pc -> ref_type === 'PRODUCT' ? ( int ) $pc -> child_product_id : ( int ) $pc -> material_id );
2025-08-25 17:46:34 +09:00
$this -> assertReference ( $tenantId , $parentProductId , $refType , $refId );
[ $childProductId , $materialId ] = $this -> splitRef ([ 'ref_type' => $refType , 'ref_id' => $refId ]);
$pc -> ref_type = $refType ;
$pc -> child_product_id = $childProductId ;
$pc -> material_id = $materialId ;
}
2025-11-06 17:45:49 +09:00
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' ];
}
2025-08-25 17:46:34 +09:00
$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 );
2025-11-06 17:45:49 +09:00
if ( ! $pc ) {
throw new BadRequestHttpException ( __ ( 'error.not_found' ));
}
2025-08-25 17:46:34 +09:00
$pc -> delete ();
}
// 정렬 변경
public function reorder ( int $parentProductId , array $items ) : void
{
$tenantId = $this -> tenantId ();
$this -> assertProduct ( $tenantId , $parentProductId );
2025-11-06 17:45:49 +09:00
if ( ! is_array ( $items )) {
throw new BadRequestHttpException ( __ ( 'error.invalid_payload' ));
}
2025-08-25 17:46:34 +09:00
DB :: transaction ( function () use ( $tenantId , $parentProductId , $items ) {
foreach ( $items as $row ) {
2025-11-06 17:45:49 +09:00
if ( ! isset ( $row [ 'id' ], $row [ 'sort_order' ])) {
continue ;
}
2025-08-25 17:46:34 +09:00
ProductComponent :: query ()
-> where ( 'tenant_id' , $tenantId )
-> where ( 'parent_product_id' , $parentProductId )
2025-11-06 17:45:49 +09:00
-> where ( 'id' , ( int ) $row [ 'id' ])
-> update ([ 'sort_order' => ( int ) $row [ 'sort_order' ]]);
2025-08-25 17:46:34 +09:00
}
});
}
// 요약(간단 합계/건수)
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 ();
2025-11-06 17:45:49 +09:00
$cnt = $items -> count ();
$cntP = $items -> where ( 'ref_type' , 'PRODUCT' ) -> count ();
$cntM = $items -> where ( 'ref_type' , 'MATERIAL' ) -> count ();
$qtySum = ( string ) $items -> sum ( 'quantity' );
2025-08-25 17:46:34 +09:00
return [
2025-11-06 17:45:49 +09:00
'count' => $cnt ,
2025-08-25 17:46:34 +09:00
'count_product' => $cntP ,
2025-11-06 17:45:49 +09:00
'count_material' => $cntM ,
'quantity_sum' => $qtySum ,
2025-08-25 17:46:34 +09:00
];
}
// 유효성 검사(중복/자기참조/음수 등)
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' ];
}
2025-11-06 17:45:49 +09:00
$key = $row -> ref_type . ':' . ( $row -> ref_type === 'PRODUCT' ? $row -> child_product_id : $row -> material_id );
2025-08-25 17:46:34 +09:00
if ( isset ( $seen [ $key ])) {
$errors [] = [ 'id' => $row -> id , 'error' => 'DUPLICATE_ITEM' ];
} else {
$seen [ $key ] = true ;
}
// 자기참조
2025-11-06 17:45:49 +09:00
if ( $row -> ref_type === 'PRODUCT' && ( int ) $row -> child_product_id === ( int ) $parentProductId ) {
2025-08-25 17:46:34 +09:00
$errors [] = [ 'id' => $row -> id , 'error' => 'SELF_REFERENCE' ];
}
}
return [
2025-11-06 17:45:49 +09:00
'valid' => count ( $errors ) === 0 ,
2025-08-25 17:46:34 +09:00
'errors' => $errors ,
];
}
// ---------- helpers ----------
private function validateItem ( array $it ) : array
{
$v = Validator :: make ( $it , [
2025-11-06 17:45:49 +09:00
'id' => 'nullable|integer' ,
'ref_type' => 'required|in:PRODUCT,MATERIAL' ,
'ref_id' => 'required|integer' ,
'quantity' => 'required|numeric|min:0.0001' ,
2025-08-25 17:46:34 +09:00
'sort_order' => 'nullable|integer|min:0' ,
'is_default' => 'nullable|in:0,1' ,
]);
2025-11-06 17:45:49 +09:00
2025-08-25 17:46:34 +09:00
return $v -> validate ();
}
private function splitRef ( array $payload ) : array
{
// returns [child_product_id, material_id]
if ( $payload [ 'ref_type' ] === 'PRODUCT' ) {
2025-11-06 17:45:49 +09:00
return [( int ) $payload [ 'ref_id' ], null ];
2025-08-25 17:46:34 +09:00
}
2025-11-06 17:45:49 +09:00
return [ null , ( int ) $payload [ 'ref_id' ]];
2025-08-25 17:46:34 +09:00
}
private function assertProduct ( int $tenantId , int $productId ) : void
{
$exists = Product :: query () -> where ( 'tenant_id' , $tenantId ) -> where ( 'id' , $productId ) -> exists ();
2025-11-06 17:45:49 +09:00
if ( ! $exists ) {
2025-08-25 17:46:34 +09:00
// 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 ();
2025-11-06 17:45:49 +09:00
if ( ! $ok ) {
throw new BadRequestHttpException ( __ ( 'error.not_found' ));
}
2025-08-25 17:46:34 +09:00
} else {
$ok = Material :: query () -> where ( 'tenant_id' , $tenantId ) -> where ( 'id' , $refId ) -> exists ();
2025-11-06 17:45:49 +09:00
if ( ! $ok ) {
throw new BadRequestHttpException ( __ ( 'error.not_found' ));
}
2025-08-25 17:46:34 +09:00
}
}
2025-08-29 16:22:05 +09:00
/**
* 특정 제품의 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 ();
2025-11-06 17:45:49 +09:00
$userId = $this -> apiUserId ();
2025-08-29 16:22:05 +09:00
// 0) ====== 빈 카테고리 제거 ======
$rawCats = Arr :: get ( $payload , 'categories' , []);
$normalized = [];
foreach (( array ) $rawCats as $cat ) {
2025-11-06 17:45:49 +09:00
$catId = Arr :: get ( $cat , 'id' );
2025-08-29 16:22:05 +09:00
$catName = Arr :: get ( $cat , 'name' );
$items = array_values ( array_filter (( array ) Arr :: get ( $cat , 'items' , []), function ( $it ) {
$type = Arr :: get ( $it , 'ref_type' );
2025-11-06 17:45:49 +09:00
$id = ( int ) Arr :: get ( $it , 'ref_id' );
$qty = Arr :: get ( $it , 'quantity' );
2025-08-29 16:22:05 +09:00
2025-11-06 17:45:49 +09:00
return in_array ( $type , [ 'MATERIAL' , 'PRODUCT' ], true )
2025-08-29 16:22:05 +09:00
&& $id > 0
&& is_numeric ( $qty );
}));
if ( count ( $items ) === 0 ) {
continue ; // 아이템 없으면 skip
}
$normalized [] = [
2025-11-06 17:45:49 +09:00
'id' => $catId ,
'name' => $catName ,
2025-08-29 16:22:05 +09:00
'items' => $items ,
];
}
// 🔕 전부 비었으면: 기존 BOM 전체 삭제 후 성공
if ( count ( $normalized ) === 0 ) {
$deleted = ProductComponent :: where ( 'tenant_id' , $tenantId )
-> where ( 'parent_product_id' , $productId )
-> delete ();
return [
2025-11-06 17:45:49 +09:00
'deleted_count' => $deleted ,
2025-08-29 16:22:05 +09:00
'inserted_count' => 0 ,
2025-11-06 17:45:49 +09:00
'message' => '모든 BOM 항목이 비어 기존 데이터를 삭제했습니다.' ,
2025-08-29 16:22:05 +09:00
];
}
// 1) ====== 검증 ======
$v = Validator :: make (
[ 'categories' => $normalized ],
[
2025-11-06 17:45:49 +09:00
'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' ],
2025-08-29 16:22:05 +09:00
]
);
if ( $v -> fails ()) {
throw new ValidationException ( $v , null , __ ( 'error.validation_failed' ));
}
// 2) ====== 플랫 레코드 생성 (note 제거) ======
$rows = [];
2025-11-06 17:45:49 +09:00
$now = now ();
2025-08-29 16:22:05 +09:00
foreach ( $normalized as $cat ) {
2025-11-06 17:45:49 +09:00
$catId = Arr :: get ( $cat , 'id' );
2025-08-29 16:22:05 +09:00
$catName = Arr :: get ( $cat , 'name' );
foreach ( $cat [ 'items' ] as $idx => $item ) {
$rows [] = [
2025-11-06 17:45:49 +09:00
'tenant_id' => $tenantId ,
2025-08-29 16:22:05 +09:00
'parent_product_id' => $productId ,
2025-11-06 17:45:49 +09:00
'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 ,
2025-08-29 16:22:05 +09:00
];
}
}
// 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 ) {
2025-11-06 17:45:49 +09:00
$ok = ProductComponent :: insert ( $chunk );
2025-08-29 16:22:05 +09:00
$inserted += $ok ? count ( $chunk ) : 0 ;
}
return [
2025-11-06 17:45:49 +09:00
'deleted_count' => $deleted ,
2025-08-29 16:22:05 +09:00
'inserted_count' => $inserted ,
2025-11-06 17:45:49 +09:00
'message' => 'BOM 저장 성공' ,
2025-08-29 16:22:05 +09:00
];
});
}
/** 제품별: 현재 BOM에 쓰인 카테고리 */
public function listCategoriesForProduct ( int $productId ) : array
{
2025-11-06 17:45:49 +09:00
if ( $productId <= 0 ) {
throw new BadRequestHttpException ( __ ( 'error.bad_request' ));
}
2025-08-29 16:22:05 +09:00
$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 );
}
2025-08-25 17:46:34 +09:00
}