fix : BOM구성 API, DB 작업

- product_components 컬럼 변경
- BOM 구성, 카테고리리스트, BOM트리(재귀)호출 API 개발
This commit is contained in:
2025-08-29 16:22:05 +09:00
parent 028af8fbfa
commit 622c4905fa
10 changed files with 973 additions and 37 deletions

View File

@@ -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);
}
}

View 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;
}
}