fix : BOM구성 API, DB 작업
- product_components 컬럼 변경 - BOM 구성, 카테고리리스트, BOM트리(재귀)호출 API 개발
This commit is contained in:
@@ -5,8 +5,11 @@
|
||||
use App\Models\Materials\Material;
|
||||
use App\Models\Products\Product;
|
||||
use App\Models\Products\ProductComponent;
|
||||
use App\Services\Products\ProductComponentResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 제품의 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user