Files
sam-api/app/Http/Controllers/Api/V1/ItemsBomController.php
hskwon a27b1b2091 feat: Phase 5.1-1 사용자 초대 + Phase 5.2 알림 설정 API 연동
- 사용자 초대 API: role 문자열 지원 추가 (React 호환)
- 알림 설정 API: 그룹 기반 계층 구조 구현
  - notification_setting_groups 테이블 추가
  - notification_setting_group_items 테이블 추가
  - notification_setting_group_states 테이블 추가
  - GET/PUT /api/v1/settings/notifications 엔드포인트 추가
- Pint 코드 스타일 정리
2025-12-22 17:42:59 +09:00

463 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Items\Item;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Items BOM Controller (ID-based)
*
* items.bom JSON 필드 기반 BOM 관리 컨트롤러
* BOM 구조: [{child_item_id, quantity}, ...]
*/
class ItemsBomController extends Controller
{
/**
* GET /api/v1/items/bom
* BOM이 있는 전체 품목 목록 조회 (item_id 없이)
*/
public function listAll(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$tenantId = app('tenant_id');
$perPage = (int) ($request->input('per_page', $request->input('size', 20)));
$itemType = $request->input('item_type');
$query = Item::query()
->where('tenant_id', $tenantId)
->whereNotNull('bom')
->where('bom', '!=', '[]')
->where('bom', '!=', 'null');
// item_type 필터 (선택)
if ($itemType) {
$query->where('item_type', strtoupper($itemType));
}
$items = $query->orderBy('id')
->paginate($perPage, ['id', 'code', 'name', 'item_type', 'unit', 'bom']);
// BOM 개수 추가
$items->getCollection()->transform(function ($item) {
$bom = $item->bom ?? [];
return [
'id' => $item->id,
'code' => $item->code,
'name' => $item->name,
'item_type' => $item->item_type,
'unit' => $item->unit,
'bom_count' => count($bom),
'bom' => $this->expandBomItems($bom),
];
});
return $items;
}, __('message.bom.fetch'));
}
/**
* GET /api/v1/items/{id}/bom
* BOM 라인 목록 조회 (flat list)
*/
public function index(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id) {
$item = $this->getItem($id);
$bom = $item->bom ?? [];
// child_item 정보 확장
return $this->expandBomItems($bom);
}, __('message.bom.fetch'));
}
/**
* GET /api/v1/items/{id}/bom/tree
* BOM 트리 구조 조회 (계층적)
*/
public function tree(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$maxDepth = (int) ($request->input('depth', 3));
return $this->buildBomTree($item, $maxDepth, 1);
}, __('message.bom.fetch'));
}
/**
* POST /api/v1/items/{id}/bom
* BOM 라인 추가 (bulk upsert)
*/
public function store(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$inputItems = $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 라인 수정 (lineId = child_item_id)
*/
public function update(int $id, int $lineId, Request $request)
{
return ApiResponse::handle(function () use ($id, $lineId, $request) {
$item = $this->getItem($id);
$bom = $item->bom ?? [];
$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 라인 삭제 (lineId = child_item_id)
*/
public function destroy(int $id, int $lineId)
{
return ApiResponse::handle(function () use ($id, $lineId) {
$item = $this->getItem($id);
$bom = $item->bom ?? [];
$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'));
}
/**
* GET /api/v1/items/{id}/bom/summary
* BOM 요약 정보
*/
public function summary(int $id)
{
return ApiResponse::handle(function () use ($id) {
$item = $this->getItem($id);
$bom = $item->bom ?? [];
$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'));
}
/**
* GET /api/v1/items/{id}/bom/validate
* BOM 유효성 검사
*/
public function validate(int $id)
{
return ApiResponse::handle(function () use ($id) {
$item = $this->getItem($id);
$bom = $item->bom ?? [];
$tenantId = app('tenant_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'));
}
/**
* POST /api/v1/items/{id}/bom/replace
* BOM 전체 교체
*/
public function replace(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$inputItems = $request->input('items', []);
$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'));
}
/**
* POST /api/v1/items/{id}/bom/reorder
* BOM 정렬 변경
*/
public function reorder(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$orderedIds = $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'));
}
/**
* GET /api/v1/items/{id}/bom/categories
* 해당 품목의 BOM에서 사용 중인 카테고리 목록
*/
public function listCategories(int $id)
{
return ApiResponse::handle(function () use ($id) {
$item = $this->getItem($id);
$bom = $item->bom ?? [];
$tenantId = app('tenant_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 ====================
/**
* 품목 조회 및 tenant 소유권 검증
*
* @throws NotFoundHttpException
*/
private function getItem(int $id): Item
{
$tenantId = app('tenant_id');
$item = Item::query()
->where('tenant_id', $tenantId)
->find($id);
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;
}
}