diff --git a/app/Http/Controllers/Api/Admin/ItemManagementApiController.php b/app/Http/Controllers/Api/Admin/ItemManagementApiController.php index b83d0657..d88e2199 100644 --- a/app/Http/Controllers/Api/Admin/ItemManagementApiController.php +++ b/app/Http/Controllers/Api/Admin/ItemManagementApiController.php @@ -54,6 +54,26 @@ public function detail(int $id): View ]); } + /** + * 품목 삭제 (Soft Delete, 사용 중 체크) + */ + public function destroy(int $id): JsonResponse + { + $result = $this->service->deleteItem($id); + + return response()->json($result, $result['success'] ? 200 : 422); + } + + /** + * 품목 이력 조회 (audit_logs 기반) + */ + public function history(int $id): JsonResponse + { + $history = $this->service->getItemHistory($id); + + return response()->json($history); + } + /** * 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출) */ diff --git a/app/Services/ItemManagementService.php b/app/Services/ItemManagementService.php index 1ed13b75..38bd2fbe 100644 --- a/app/Services/ItemManagementService.php +++ b/app/Services/ItemManagementService.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Models\Audit\AuditLog; use App\Models\Items\Item; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; class ItemManagementService { @@ -86,6 +88,151 @@ public function getItemDetail(int $itemId): array ]; } + /** + * 품목 사용 현황 조회 (삭제 전 참조 체크) + */ + public function checkItemUsage(int $itemId): array + { + $tenantId = session('selected_tenant_id'); + $usage = []; + + // 다른 품목의 BOM에 포함되어 있는지 (JSON 검색) + $bomParents = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereRaw("JSON_CONTAINS(bom, JSON_OBJECT('child_item_id', ?))", [$itemId]) + ->count(); + if ($bomParents > 0) { + $usage[] = "다른 품목의 BOM 구성품 ({$bomParents}건)"; + } + + // 주문 항목 + $orderItems = DB::table('order_items')->where('item_id', $itemId)->count(); + if ($orderItems > 0) { + $usage[] = "수주 항목 ({$orderItems}건)"; + } + + // 견적 + $quotes = DB::table('quotes')->where('item_id', $itemId)->count(); + if ($quotes > 0) { + $usage[] = "견적 ({$quotes}건)"; + } + + // 자재 입고 + $receipts = DB::table('material_receipts')->where('item_id', $itemId)->count(); + if ($receipts > 0) { + $usage[] = "자재 입고 ({$receipts}건)"; + } + + // LOT + $lots = DB::table('lots')->where('item_id', $itemId)->count(); + if ($lots > 0) { + $usage[] = "LOT ({$lots}건)"; + } + + // 작업지시 + $workOrders = DB::table('work_order_items')->where('item_id', $itemId)->count(); + if ($workOrders > 0) { + $usage[] = "작업지시 ({$workOrders}건)"; + } + + return $usage; + } + + /** + * 품목 삭제 (Soft Delete) + */ + public function deleteItem(int $itemId): array + { + $tenantId = session('selected_tenant_id'); + + $item = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->findOrFail($itemId); + + // 사용 현황 체크 + $usage = $this->checkItemUsage($itemId); + if (! empty($usage)) { + return [ + 'success' => false, + 'error' => '사용 중인 품목은 삭제할 수 없습니다.', + 'usage' => $usage, + ]; + } + + $item->deleted_by = auth()->id(); + $item->save(); + $item->delete(); + + return ['success' => true, 'message' => "품목 '{$item->name}'이(가) 삭제되었습니다."]; + } + + /** + * 품목 이력 조회 (audit_logs + 생성/수정 정보) + */ + public function getItemHistory(int $itemId): array + { + $tenantId = session('selected_tenant_id'); + + $item = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->findOrFail($itemId); + + // audit_logs에서 해당 품목 이력 + $auditLogs = AuditLog::where('tenant_id', $tenantId) + ->where('target_type', 'item') + ->where('target_id', $itemId) + ->orderByDesc('created_at') + ->limit(50) + ->get(); + + // 사용자 ID → 이름 매핑 + $actorIds = $auditLogs->pluck('actor_id')->filter()->unique()->values(); + $actors = []; + if ($actorIds->isNotEmpty()) { + $actors = DB::table('users') + ->whereIn('id', $actorIds) + ->pluck('name', 'id') + ->toArray(); + } + + $logs = $auditLogs->map(function ($log) use ($actors) { + return [ + 'id' => $log->id, + 'action' => $log->action, + 'action_label' => $this->getActionLabel($log->action), + 'actor' => $actors[$log->actor_id] ?? '시스템', + 'created_at' => $log->created_at->format('Y-m-d H:i:s'), + 'before' => $log->before, + 'after' => $log->after, + ]; + }); + + return [ + 'item' => [ + 'id' => $item->id, + 'code' => $item->code, + 'name' => $item->name, + 'created_at' => $item->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $item->updated_at?->format('Y-m-d H:i:s'), + ], + 'logs' => $logs, + ]; + } + + private function getActionLabel(string $action): string + { + return match ($action) { + 'created' => '생성', + 'updated' => '수정', + 'deleted' => '삭제', + 'restored' => '복원', + 'bom_updated' => 'BOM 변경', + 'stock_increase' => '재고 증가', + 'stock_decrease' => '재고 차감', + default => $action, + }; + } + // ── Private ── private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array diff --git a/resources/views/item-management/index.blade.php b/resources/views/item-management/index.blade.php index e9c792c6..85cfec36 100644 --- a/resources/views/item-management/index.blade.php +++ b/resources/views/item-management/index.blade.php @@ -144,6 +144,20 @@ class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-b + + {{-- 이력 조회 모달 --}} + @endsection @push('scripts') @@ -594,5 +608,126 @@ function renderFormulaTree(data, container) { } } })(); + +// ── 품목 삭제 ── +window.confirmDeleteItem = function(itemId, itemName) { + if (!confirm(`"${itemName}" 품목을 삭제하시겠습니까?\n\n※ 다른 곳에서 사용 중인 품목은 삭제할 수 없습니다.`)) return; + + fetch(`/api/admin/items/${itemId}`, { + method: 'DELETE', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + }, + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + alert(data.message); + // 상세 패널 초기화 + 목록 새로고침 + document.getElementById('item-detail').innerHTML = '

품목을 선택하세요

'; + document.getElementById('bom-tree-container').innerHTML = ''; + loadItemList(); + } else { + let msg = data.error || '삭제 실패'; + if (data.usage && data.usage.length > 0) { + msg += '\n\n참조 현황:\n- ' + data.usage.join('\n- '); + } + alert(msg); + } + }) + .catch(err => { + console.error('deleteItem error:', err); + alert('삭제 요청 중 오류가 발생했습니다.'); + }); +}; + +// ── 품목 이력 조회 ── +window.showItemHistory = function(itemId) { + const modal = document.getElementById('history-modal'); + const body = document.getElementById('history-modal-body'); + body.innerHTML = '
'; + modal.classList.remove('hidden'); + + fetch(`/api/admin/items/${itemId}/history`, { + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + }, + }) + .then(res => res.json()) + .then(data => { + let html = ''; + + // 기본 정보 + html += `
+
+ 코드 + ${data.item.code} +
+
+ 생성일 + ${data.item.created_at || '-'} + 최종수정 + ${data.item.updated_at || '-'} +
+
`; + + // 이력 목록 + if (data.logs.length === 0) { + html += '

기록된 이력이 없습니다.

'; + } else { + html += '
'; + data.logs.forEach(log => { + const actionColors = { + '생성': 'bg-green-100 text-green-800', + '수정': 'bg-blue-100 text-blue-800', + '삭제': 'bg-red-100 text-red-800', + 'BOM 변경': 'bg-purple-100 text-purple-800', + '재고 증가': 'bg-teal-100 text-teal-800', + '재고 차감': 'bg-orange-100 text-orange-800', + }; + const color = actionColors[log.action_label] || 'bg-gray-100 text-gray-800'; + + html += `
+
+ ${log.action_label} + ${log.created_at} + ${log.actor} +
`; + + // 변경 내용 요약 (before/after 비교) + if (log.action === 'updated' && log.before && log.after) { + const changes = []; + for (const key of Object.keys(log.after)) { + if (key === 'updated_at' || key === 'updated_by') continue; + const bVal = log.before[key]; + const aVal = log.after[key]; + if (JSON.stringify(bVal) !== JSON.stringify(aVal)) { + if (typeof aVal === 'object') continue; // JSON 필드는 건너뜀 + changes.push(key); + } + } + if (changes.length > 0) { + html += `
변경 필드: ${changes.join(', ')}
`; + } + } + + html += '
'; + }); + html += '
'; + } + + body.innerHTML = html; + }) + .catch(err => { + console.error('history error:', err); + body.innerHTML = '

이력 조회에 실패했습니다.

'; + }); +}; + +window.closeHistoryModal = function() { + document.getElementById('history-modal').classList.add('hidden'); +}; @endpush diff --git a/resources/views/item-management/partials/item-detail.blade.php b/resources/views/item-management/partials/item-detail.blade.php index b7221612..e1d86551 100644 --- a/resources/views/item-management/partials/item-detail.blade.php +++ b/resources/views/item-management/partials/item-detail.blade.php @@ -5,6 +5,26 @@ data-item-type="{{ $item->item_type }}" style="display:none;"> +{{-- 액션 버튼 --}} +
+ + +
+ {{-- 기본정보 --}}
diff --git a/routes/api.php b/routes/api.php index f91a8bce..23231f5f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -927,6 +927,8 @@ Route::get('/', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'index'])->name('index'); Route::get('/{id}/bom-tree', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'bomTree'])->name('bom-tree'); Route::get('/{id}/detail', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'detail'])->name('detail'); + Route::get('/{id}/history', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'history'])->name('history'); + Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'destroy'])->name('destroy'); Route::post('/{id}/calculate-formula', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'calculateFormula'])->name('calculate-formula'); });