feat: [item-management] 품목 삭제 및 이력 조회 기능 추가
- 삭제: soft delete, 사용 중 품목 삭제 차단 (BOM/수주/견적/입고/LOT/작업지시 참조 체크)
- 이력: audit_logs 기반 생성/수정/삭제 이력 조회 모달
- 상세 패널에 이력/삭제 액션 버튼 추가
- API: DELETE /{id}, GET /{id}/history 엔드포인트 추가
This commit is contained in:
@@ -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 호출)
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,6 +144,20 @@ class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-b
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- 이력 조회 모달 --}}
|
||||
<div id="history-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||
<h3 class="text-base font-semibold text-gray-800">품목 이력</h3>
|
||||
<button onclick="closeHistoryModal()" class="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div id="history-modal-body" class="p-5 min-h-[200px]"></div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 text-right">
|
||||
<button onclick="closeHistoryModal()" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@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 = '<p class="text-gray-400 text-sm text-center py-10">품목을 선택하세요</p>';
|
||||
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 = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
|
||||
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 += `<div class="mb-4 px-4 py-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div class="flex gap-4">
|
||||
<span class="text-gray-500">코드</span>
|
||||
<span class="font-mono font-medium">${data.item.code}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<span class="text-gray-500">생성일</span>
|
||||
<span>${data.item.created_at || '-'}</span>
|
||||
<span class="text-gray-500 ml-4">최종수정</span>
|
||||
<span>${data.item.updated_at || '-'}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 이력 목록
|
||||
if (data.logs.length === 0) {
|
||||
html += '<p class="text-gray-400 text-sm text-center py-6">기록된 이력이 없습니다.</p>';
|
||||
} else {
|
||||
html += '<div class="space-y-2 max-h-96 overflow-y-auto">';
|
||||
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 += `<div class="px-3 py-2 border border-gray-100 rounded-lg">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}">${log.action_label}</span>
|
||||
<span class="text-gray-500 text-xs">${log.created_at}</span>
|
||||
<span class="text-gray-700 text-xs ml-auto">${log.actor}</span>
|
||||
</div>`;
|
||||
|
||||
// 변경 내용 요약 (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 += `<div class="mt-1 text-xs text-gray-500">변경 필드: ${changes.join(', ')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('history error:', err);
|
||||
body.innerHTML = '<p class="text-red-500 text-sm text-center py-6">이력 조회에 실패했습니다.</p>';
|
||||
});
|
||||
};
|
||||
|
||||
window.closeHistoryModal = function() {
|
||||
document.getElementById('history-modal').classList.add('hidden');
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -5,6 +5,26 @@
|
||||
data-item-type="{{ $item->item_type }}"
|
||||
style="display:none;"></div>
|
||||
|
||||
{{-- 액션 버튼 --}}
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button onclick="showItemHistory({{ $item->id }})"
|
||||
class="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
|
||||
title="생성/수정 이력 조회">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
이력
|
||||
</button>
|
||||
<button onclick="confirmDeleteItem({{ $item->id }}, '{{ addslashes($item->name) }}')"
|
||||
class="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
|
||||
title="품목 삭제 (사용 중인 품목은 삭제 불가)">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 기본정보 --}}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user