- 사용자 초대 API: role 문자열 지원 추가 (React 호환) - 알림 설정 API: 그룹 기반 계층 구조 구현 - notification_setting_groups 테이블 추가 - notification_setting_group_items 테이블 추가 - notification_setting_group_states 테이블 추가 - GET/PUT /api/v1/settings/notifications 엔드포인트 추가 - Pint 코드 스타일 정리
463 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|