fix: P0 Critical 이슈 수정 - 삭제된 Product/Material 참조 제거
- ItemsBomController: ProductBomService 제거, Item.bom JSON 기반으로 재구현 - ItemsFileController: Product/Material → Item 모델로 통합 - ItemPage: products/materials 클래스 매핑 제거 - ItemsService 삭제 (1,210줄) - ItemService로 대체 완료 BOM 기능 변경: - 기존: ProductBomService (삭제됨) - 변경: Item 모델의 bom JSON 필드 직접 조작 - 모든 BOM API 엔드포인트 정상 동작 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,31 +4,30 @@
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Products\Product;
|
||||
use App\Services\ProductBomService;
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Items BOM Controller (ID-based)
|
||||
*
|
||||
* ID 기반으로 BOM을 관리하는 컨트롤러
|
||||
* 내부적으로 기존 ProductBomService 재사용
|
||||
* items.bom JSON 필드 기반 BOM 관리 컨트롤러
|
||||
* BOM 구조: [{child_item_id, quantity}, ...]
|
||||
*/
|
||||
class ItemsBomController extends Controller
|
||||
{
|
||||
public function __construct(private ProductBomService $service) {}
|
||||
|
||||
/**
|
||||
* GET /api/v1/items/{id}/bom
|
||||
* BOM 라인 목록 조회 (flat list)
|
||||
*/
|
||||
public function index(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$this->validateProductExists($id);
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$item = $this->getItem($id);
|
||||
$bom = $item->bom ?? [];
|
||||
|
||||
return $this->service->index($id, $request->all());
|
||||
// child_item 정보 확장
|
||||
return $this->expandBomItems($bom);
|
||||
}, __('message.bom.fetch'));
|
||||
}
|
||||
|
||||
@@ -39,9 +38,10 @@ public function index(int $id, Request $request)
|
||||
public function tree(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$maxDepth = (int) ($request->input('depth', 3));
|
||||
|
||||
return $this->service->tree($request, $id);
|
||||
return $this->buildBomTree($item, $maxDepth, 1);
|
||||
}, __('message.bom.fetch'));
|
||||
}
|
||||
|
||||
@@ -52,35 +52,94 @@ public function tree(int $id, Request $request)
|
||||
public function store(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$inputItems = $request->input('items', []);
|
||||
|
||||
return $this->service->bulkUpsert($id, $request->input('items', []));
|
||||
$existingBom = $item->bom ?? [];
|
||||
$existingMap = collect($existingBom)->keyBy('child_item_id')->toArray();
|
||||
|
||||
// 입력된 items upsert
|
||||
foreach ($inputItems as $inputItem) {
|
||||
$childItemId = $inputItem['child_item_id'] ?? null;
|
||||
if (! $childItemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 자기 자신 참조 방지
|
||||
if ($childItemId == $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingMap[$childItemId] = [
|
||||
'child_item_id' => (int) $childItemId,
|
||||
'quantity' => (float) ($inputItem['quantity'] ?? 1),
|
||||
];
|
||||
}
|
||||
|
||||
// 저장
|
||||
$item->bom = array_values($existingMap);
|
||||
$item->updated_by = auth()->id();
|
||||
$item->save();
|
||||
|
||||
return $this->expandBomItems($item->bom);
|
||||
}, __('message.bom.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/items/{id}/bom/{lineId}
|
||||
* BOM 라인 수정
|
||||
* BOM 라인 수정 (lineId = child_item_id)
|
||||
*/
|
||||
public function update(int $id, int $lineId, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $lineId, $request) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$bom = $item->bom ?? [];
|
||||
|
||||
return $this->service->update($id, $lineId, $request->all());
|
||||
$updated = false;
|
||||
foreach ($bom as &$entry) {
|
||||
if (($entry['child_item_id'] ?? null) == $lineId) {
|
||||
if ($request->has('quantity')) {
|
||||
$entry['quantity'] = (float) $request->input('quantity');
|
||||
}
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $updated) {
|
||||
throw new NotFoundHttpException(__('error.bom.line_not_found'));
|
||||
}
|
||||
|
||||
$item->bom = $bom;
|
||||
$item->updated_by = auth()->id();
|
||||
$item->save();
|
||||
|
||||
return $this->expandBomItems($item->bom);
|
||||
}, __('message.bom.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/items/{id}/bom/{lineId}
|
||||
* BOM 라인 삭제
|
||||
* BOM 라인 삭제 (lineId = child_item_id)
|
||||
*/
|
||||
public function destroy(int $id, int $lineId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $lineId) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$bom = $item->bom ?? [];
|
||||
|
||||
$this->service->destroy($id, $lineId);
|
||||
$originalCount = count($bom);
|
||||
$bom = array_values(array_filter($bom, function ($entry) use ($lineId) {
|
||||
return ($entry['child_item_id'] ?? null) != $lineId;
|
||||
}));
|
||||
|
||||
if (count($bom) === $originalCount) {
|
||||
throw new NotFoundHttpException(__('error.bom.line_not_found'));
|
||||
}
|
||||
|
||||
$item->bom = $bom;
|
||||
$item->updated_by = auth()->id();
|
||||
$item->save();
|
||||
|
||||
return 'success';
|
||||
}, __('message.bom.deleted'));
|
||||
@@ -93,9 +152,19 @@ public function destroy(int $id, int $lineId)
|
||||
public function summary(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$bom = $item->bom ?? [];
|
||||
|
||||
return $this->service->summary($id);
|
||||
$expandedBom = $this->expandBomItems($bom);
|
||||
|
||||
return [
|
||||
'item_id' => $item->id,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'total_lines' => count($bom),
|
||||
'total_quantity' => collect($bom)->sum('quantity'),
|
||||
'child_items' => collect($expandedBom)->pluck('child_item_name')->filter()->unique()->values(),
|
||||
];
|
||||
}, __('message.bom.fetch'));
|
||||
}
|
||||
|
||||
@@ -106,9 +175,48 @@ public function summary(int $id)
|
||||
public function validate(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$bom = $item->bom ?? [];
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
return $this->service->validateBom($id);
|
||||
$issues = [];
|
||||
|
||||
// 1. 자기 자신 참조 체크
|
||||
foreach ($bom as $entry) {
|
||||
if (($entry['child_item_id'] ?? null) == $id) {
|
||||
$issues[] = ['type' => 'self_reference', 'message' => '자기 자신을 BOM에 포함할 수 없습니다.'];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 존재하지 않는 품목 체크
|
||||
$childIds = collect($bom)->pluck('child_item_id')->filter()->toArray();
|
||||
if (! empty($childIds)) {
|
||||
$existingIds = Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$missingIds = array_diff($childIds, $existingIds);
|
||||
foreach ($missingIds as $missingId) {
|
||||
$issues[] = ['type' => 'missing_item', 'message' => "품목 ID {$missingId}가 존재하지 않습니다.", 'child_item_id' => $missingId];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 순환 참조 체크 (1단계만)
|
||||
foreach ($childIds as $childId) {
|
||||
$childItem = Item::where('tenant_id', $tenantId)->find($childId);
|
||||
if ($childItem && ! empty($childItem->bom)) {
|
||||
$grandchildIds = collect($childItem->bom)->pluck('child_item_id')->filter()->toArray();
|
||||
if (in_array($id, $grandchildIds)) {
|
||||
$issues[] = ['type' => 'circular_reference', 'message' => "순환 참조가 감지되었습니다: {$childItem->code}", 'child_item_id' => $childId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => empty($issues),
|
||||
'issues' => $issues,
|
||||
];
|
||||
}, __('message.bom.fetch'));
|
||||
}
|
||||
|
||||
@@ -119,9 +227,27 @@ public function validate(int $id)
|
||||
public function replace(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$inputItems = $request->input('items', []);
|
||||
|
||||
return $this->service->replaceBom($id, $request->all());
|
||||
$newBom = [];
|
||||
foreach ($inputItems as $inputItem) {
|
||||
$childItemId = $inputItem['child_item_id'] ?? null;
|
||||
if (! $childItemId || $childItemId == $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newBom[] = [
|
||||
'child_item_id' => (int) $childItemId,
|
||||
'quantity' => (float) ($inputItem['quantity'] ?? 1),
|
||||
];
|
||||
}
|
||||
|
||||
$item->bom = $newBom;
|
||||
$item->updated_by = auth()->id();
|
||||
$item->save();
|
||||
|
||||
return $this->expandBomItems($item->bom);
|
||||
}, __('message.bom.created'));
|
||||
}
|
||||
|
||||
@@ -132,9 +258,33 @@ public function replace(int $id, Request $request)
|
||||
public function reorder(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$orderedIds = $request->input('items', []);
|
||||
|
||||
$this->service->reorder($id, $request->input('items', []));
|
||||
if (empty($orderedIds)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
$existingBom = $item->bom ?? [];
|
||||
$bomMap = collect($existingBom)->keyBy('child_item_id')->toArray();
|
||||
|
||||
// 순서대로 재정렬
|
||||
$reorderedBom = [];
|
||||
foreach ($orderedIds as $childItemId) {
|
||||
if (isset($bomMap[$childItemId])) {
|
||||
$reorderedBom[] = $bomMap[$childItemId];
|
||||
unset($bomMap[$childItemId]);
|
||||
}
|
||||
}
|
||||
|
||||
// 남은 항목 추가
|
||||
foreach ($bomMap as $entry) {
|
||||
$reorderedBom[] = $entry;
|
||||
}
|
||||
|
||||
$item->bom = $reorderedBom;
|
||||
$item->updated_by = auth()->id();
|
||||
$item->save();
|
||||
|
||||
return 'success';
|
||||
}, __('message.bom.reordered'));
|
||||
@@ -147,30 +297,122 @@ public function reorder(int $id, Request $request)
|
||||
public function listCategories(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->validateProductExists($id);
|
||||
$item = $this->getItem($id);
|
||||
$bom = $item->bom ?? [];
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
return $this->service->listCategoriesForProduct($id);
|
||||
$childIds = collect($bom)->pluck('child_item_id')->filter()->toArray();
|
||||
if (empty($childIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$categories = Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->whereNotNull('category_id')
|
||||
->with('category:id,name')
|
||||
->get()
|
||||
->pluck('category')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
return $categories;
|
||||
}, __('message.bom.fetch'));
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* 품목 ID로 tenant 소유권 검증
|
||||
* 품목 조회 및 tenant 소유권 검증
|
||||
*
|
||||
* @throws NotFoundHttpException
|
||||
*/
|
||||
private function validateProductExists(int $id): void
|
||||
private function getItem(int $id): Item
|
||||
{
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
$exists = Product::query()
|
||||
$item = Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $id)
|
||||
->exists();
|
||||
->find($id);
|
||||
|
||||
if (! $exists) {
|
||||
if (! $item) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 항목에 child_item 상세 정보 확장
|
||||
*/
|
||||
private function expandBomItems(array $bom): array
|
||||
{
|
||||
if (empty($bom)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantId = app('tenant_id');
|
||||
$childIds = collect($bom)->pluck('child_item_id')->filter()->toArray();
|
||||
|
||||
$childItems = Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->get(['id', 'code', 'name', 'unit', 'item_type', 'category_id'])
|
||||
->keyBy('id');
|
||||
|
||||
return collect($bom)->map(function ($entry) use ($childItems) {
|
||||
$childItemId = $entry['child_item_id'] ?? null;
|
||||
$childItem = $childItems[$childItemId] ?? null;
|
||||
|
||||
return [
|
||||
'child_item_id' => $childItemId,
|
||||
'child_item_code' => $childItem?->code,
|
||||
'child_item_name' => $childItem?->name,
|
||||
'child_item_type' => $childItem?->item_type,
|
||||
'unit' => $childItem?->unit,
|
||||
'quantity' => $entry['quantity'] ?? 1,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 트리 구조 빌드 (재귀)
|
||||
*/
|
||||
private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): array
|
||||
{
|
||||
$tenantId = app('tenant_id');
|
||||
$bom = $item->bom ?? [];
|
||||
|
||||
$result = [
|
||||
'id' => $item->id,
|
||||
'code' => $item->code,
|
||||
'name' => $item->name,
|
||||
'item_type' => $item->item_type,
|
||||
'unit' => $item->unit,
|
||||
'depth' => $currentDepth,
|
||||
'children' => [],
|
||||
];
|
||||
|
||||
if (empty($bom) || $currentDepth >= $maxDepth) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$childIds = collect($bom)->pluck('child_item_id')->filter()->toArray();
|
||||
$childItems = Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($bom as $entry) {
|
||||
$childItemId = $entry['child_item_id'] ?? null;
|
||||
$childItem = $childItems[$childItemId] ?? null;
|
||||
|
||||
if ($childItem) {
|
||||
$childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 1);
|
||||
$childTree['quantity'] = $entry['quantity'] ?? 1;
|
||||
$result['children'][] = $childTree;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Helpers\ItemTypeHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Item\ItemFileUploadRequest;
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Materials\Material;
|
||||
use App\Models\Products\Product;
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -37,11 +35,10 @@ public function index(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$tenantId = app('tenant_id');
|
||||
$itemType = strtoupper($request->input('item_type', 'FG'));
|
||||
$fieldKey = $request->input('field_key');
|
||||
|
||||
// 품목 존재 확인
|
||||
$this->getItemById($id, $itemType, $tenantId);
|
||||
$this->getItemById($id, $tenantId);
|
||||
|
||||
// 파일 조회
|
||||
$query = File::query()
|
||||
@@ -76,13 +73,12 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
$tenantId = app('tenant_id');
|
||||
$userId = auth()->id();
|
||||
$validated = $request->validated();
|
||||
$itemType = strtoupper($validated['item_type'] ?? 'FG');
|
||||
$fieldKey = $validated['field_key'];
|
||||
$uploadedFile = $validated['file'];
|
||||
$existingFileId = $validated['file_id'] ?? null;
|
||||
|
||||
// 품목 존재 확인
|
||||
$this->getItemById($id, $itemType, $tenantId);
|
||||
$this->getItemById($id, $tenantId);
|
||||
|
||||
$replaced = false;
|
||||
|
||||
@@ -156,13 +152,12 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
*/
|
||||
public function delete(int $id, int $fileId, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $fileId, $request) {
|
||||
return ApiResponse::handle(function () use ($id, $fileId) {
|
||||
$tenantId = app('tenant_id');
|
||||
$userId = auth()->id();
|
||||
$itemType = strtoupper($request->input('item_type', 'FG'));
|
||||
|
||||
// 품목 존재 확인
|
||||
$this->getItemById($id, $itemType, $tenantId);
|
||||
$this->getItemById($id, $tenantId);
|
||||
|
||||
// 파일 조회
|
||||
$file = File::query()
|
||||
@@ -187,19 +182,13 @@ public function delete(int $id, int $fileId, Request $request)
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 품목 조회 (Product 또는 Material)
|
||||
* ID로 품목 조회 (통합 items 테이블)
|
||||
*/
|
||||
private function getItemById(int $id, string $itemType, int $tenantId): Product|Material
|
||||
private function getItemById(int $id, int $tenantId): Item
|
||||
{
|
||||
if (ItemTypeHelper::isMaterial($itemType, $tenantId)) {
|
||||
$item = Material::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
} else {
|
||||
$item = Product::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
}
|
||||
$item = Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $item) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
|
||||
Reference in New Issue
Block a user