diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php new file mode 100644 index 00000000..af1df2fb --- /dev/null +++ b/app/Http/Controllers/AuditLogController.php @@ -0,0 +1,109 @@ +with(['tenant', 'actor']) + ->orderByDesc('created_at'); + + // 대상 타입 필터 (기본: Stock) + $targetType = $request->input('target_type', 'Stock'); + if ($targetType) { + $query->where('target_type', $targetType); + } + + // 액션 필터 + if ($request->filled('action')) { + $query->where('action', $request->action); + } + + // 테넌트 필터 + if ($request->filled('tenant_id')) { + $query->where('tenant_id', $request->tenant_id); + } + + // 날짜 범위 필터 + if ($request->filled('from')) { + $query->where('created_at', '>=', $request->from.' 00:00:00'); + } + if ($request->filled('to')) { + $query->where('created_at', '<=', $request->to.' 23:59:59'); + } + + // 검색 (LOT 번호, 참조 ID) + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->whereRaw("JSON_EXTRACT(after, '$.lot_no') LIKE ?", ["%{$search}%"]) + ->orWhereRaw("JSON_EXTRACT(after, '$.reference_id') = ?", [$search]); + }); + } + + // 통계 + $stats = $this->getStats($targetType); + + // 페이지네이션 + $logs = $query->paginate(50)->withQueryString(); + + // 테넌트 목록 (필터용) + $tenants = Tenant::orderBy('company_name')->get(['id', 'company_name as name']); + + // 액션 목록 + $actions = AuditLog::STOCK_ACTIONS; + + return view('audit-logs.index', compact('logs', 'stats', 'tenants', 'actions', 'targetType')); + } + + /** + * 감사 로그 상세 + */ + public function show(int $id): View + { + $log = AuditLog::with(['tenant', 'actor'])->findOrFail($id); + + return view('audit-logs.show', compact('log')); + } + + /** + * 통계 조회 + */ + private function getStats(?string $targetType): array + { + $baseQuery = AuditLog::query(); + + if ($targetType) { + $baseQuery->where('target_type', $targetType); + } + + $total = (clone $baseQuery)->count(); + + $byAction = (clone $baseQuery) + ->selectRaw('action, COUNT(*) as count') + ->groupBy('action') + ->pluck('count', 'action') + ->toArray(); + + // 오늘 기록 수 + $today = (clone $baseQuery) + ->whereDate('created_at', today()) + ->count(); + + return [ + 'total' => $total, + 'today' => $today, + 'by_action' => $byAction, + ]; + } +} diff --git a/app/Models/Audit/AuditLog.php b/app/Models/Audit/AuditLog.php new file mode 100644 index 00000000..67895371 --- /dev/null +++ b/app/Models/Audit/AuditLog.php @@ -0,0 +1,123 @@ + 'array', + 'after' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * 재고 관련 액션 목록 + */ + public const STOCK_ACTIONS = [ + 'stock_increase' => '재고 증가', + 'stock_decrease' => '재고 차감', + 'stock_reserve' => '재고 예약', + 'stock_release' => '예약 해제', + ]; + + /** + * 참조 타입 라벨 + */ + public const REFERENCE_TYPES = [ + 'receiving' => '입고', + 'work_order' => '작업지시', + 'work_order_input' => '자재투입', + 'shipment' => '출하', + 'order' => '수주', + 'order_confirm' => '수주확정', + 'order_cancel' => '수주취소', + ]; + + /** + * 테넌트 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 수행자 + */ + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + /** + * 액션 라벨 + */ + public function getActionLabelAttribute(): string + { + return self::STOCK_ACTIONS[$this->action] ?? $this->action; + } + + /** + * 참조 타입 라벨 + */ + public function getReasonLabelAttribute(): ?string + { + $reason = $this->after['reason'] ?? null; + + return $reason ? (self::REFERENCE_TYPES[$reason] ?? $reason) : null; + } + + /** + * 수량 변화 + */ + public function getQtyChangeAttribute(): ?float + { + return $this->after['qty_change'] ?? null; + } + + /** + * LOT 번호 + */ + public function getLotNoAttribute(): ?string + { + return $this->after['lot_no'] ?? null; + } + + /** + * 참조 ID + */ + public function getReferenceIdAttribute(): ?int + { + return $this->after['reference_id'] ?? null; + } + + /** + * 참조 타입 + */ + public function getReferenceTypeAttribute(): ?string + { + return $this->after['reference_type'] ?? null; + } +} diff --git a/resources/views/audit-logs/index.blade.php b/resources/views/audit-logs/index.blade.php new file mode 100644 index 00000000..79587f53 --- /dev/null +++ b/resources/views/audit-logs/index.blade.php @@ -0,0 +1,224 @@ +@extends('layouts.app') + +@section('title', '감사 로그') + +@section('content') + +
+

감사 로그

+
+ 재고 변동 이력을 추적합니다 +
+
+ + +
+
+
전체 기록
+
{{ number_format($stats['total']) }}
+
+
+
오늘 기록
+
{{ number_format($stats['today']) }}
+
+ @foreach($stats['by_action'] as $action => $count) +
+
{{ $actions[$action] ?? $action }}
+
+ {{ number_format($count) }} +
+
+ @endforeach +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 초기화 + +
+
+
+ + +
+ + + + + + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
시간액션LOT 번호수량사유참조테넌트수행자
+ {{ $log->created_at->format('m/d H:i:s') }} + + @php + $actionColors = [ + 'stock_increase' => 'bg-green-100 text-green-800', + 'stock_decrease' => 'bg-red-100 text-red-800', + 'stock_reserve' => 'bg-yellow-100 text-yellow-800', + 'stock_release' => 'bg-blue-100 text-blue-800', + ]; + @endphp + + {{ $log->action_label }} + + + {{ $log->lot_no ?? '-' }} + + @if($log->qty_change > 0) + +{{ number_format($log->qty_change, 2) }} + @elseif($log->qty_change < 0) + {{ number_format($log->qty_change, 2) }} + @else + - + @endif + + {{ $log->reason_label ?? '-' }} + + @if($log->reference_id) + #{{ $log->reference_id }} + @else + - + @endif + + @if($log->tenant) + {{ Str::limit($log->tenant->company_name, 10) }} + @else + - + @endif + + @if($log->actor) + {{ $log->actor->name ?? $log->actor->email }} + @else + 시스템 + @endif + + + + + + + +
+ 감사 로그가 없습니다. +
+ + + @if($logs->hasPages()) +
+ {{ $logs->links() }} +
+ @endif +
+ + +@endsection \ No newline at end of file diff --git a/resources/views/audit-logs/show.blade.php b/resources/views/audit-logs/show.blade.php new file mode 100644 index 00000000..eef8c9f9 --- /dev/null +++ b/resources/views/audit-logs/show.blade.php @@ -0,0 +1,193 @@ +@extends('layouts.app') + +@section('title', '감사 로그 상세') + +@section('content') + +
+
+ + + + + +

감사 로그 상세 #{{ $log->id }}

+
+
+ {{ $log->created_at->format('Y-m-d H:i:s') }} +
+
+ + +
+

기본 정보

+ +
+ +
+ + @php + $actionColors = [ + 'stock_increase' => 'bg-green-100 text-green-800', + 'stock_decrease' => 'bg-red-100 text-red-800', + 'stock_reserve' => 'bg-yellow-100 text-yellow-800', + 'stock_release' => 'bg-blue-100 text-blue-800', + ]; + @endphp + + {{ $log->action_label }} + +
+ + +
+ +

{{ $log->target_type }}

+
+ + +
+ +

#{{ $log->target_id }}

+
+ + +
+ +

{{ $log->tenant?->company_name ?? '-' }}

+
+ + +
+ +

{{ $log->lot_no ?? '-' }}

+
+ + +
+ +

+ @if($log->qty_change > 0) + +{{ number_format($log->qty_change, 2) }} + @elseif($log->qty_change < 0) + {{ number_format($log->qty_change, 2) }} + @else + - + @endif +

+
+ + +
+ +

{{ $log->reason_label ?? '-' }}

+
+ + +
+ +

+ @if($log->reference_id) + #{{ $log->reference_id }} + @if($log->reference_type) + ({{ $log->reference_type }}) + @endif + @else + - + @endif +

+
+
+
+ + +
+

수행자 정보

+ +
+
+ +

{{ $log->actor?->name ?? '시스템' }}

+
+
+ +

{{ $log->actor?->email ?? '-' }}

+
+
+ +

{{ $log->ip ?? '-' }}

+
+
+ +

{{ Str::limit($log->ua, 50) ?? '-' }}

+
+
+
+ + +
+ +
+

변경 전 (Before)

+
+
{{ json_encode($log->before, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '(데이터 없음)' }}
+
+
+ + +
+

변경 후 (After)

+
+
{{ json_encode($log->after, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '(데이터 없음)' }}
+
+
+
+ + +@if($log->before && $log->after) +
+

변경 내용 요약

+ +
+ + + + + + + + + + @php + $allKeys = array_unique(array_merge(array_keys($log->before ?? []), array_keys($log->after ?? []))); + @endphp + @foreach($allKeys as $key) + @php + $beforeVal = $log->before[$key] ?? null; + $afterVal = $log->after[$key] ?? null; + $changed = $beforeVal !== $afterVal; + @endphp + + + + + + @endforeach + +
필드변경 전변경 후
{{ $key }} + @if(is_array($beforeVal)) + {{ json_encode($beforeVal, JSON_UNESCAPED_UNICODE) }} + @else + {{ $beforeVal ?? '-' }} + @endif + + @if(is_array($afterVal)) + {{ json_encode($afterVal, JSON_UNESCAPED_UNICODE) }} + @else + {{ $afterVal ?? '-' }} + @endif +
+
+
+@endif +@endsection \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index f9fe79c5..4def704a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\ApiLogController; use App\Http\Controllers\ArchivedRecordController; +use App\Http\Controllers\AuditLogController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\BoardController; use App\Http\Controllers\CustomerCenterController; @@ -219,6 +220,12 @@ Route::post('/{batchId}/restore', [ArchivedRecordController::class, 'restore'])->name('restore'); }); + // 감사 로그 (Blade 화면만) + Route::prefix('audit-logs')->name('audit-logs.')->group(function () { + Route::get('/', [AuditLogController::class, 'index'])->name('index'); + Route::get('/{id}', [AuditLogController::class, 'show'])->name('show'); + }); + // 프로젝트 관리 (Blade 화면만) Route::prefix('project-management')->name('pm.')->group(function () { // 대시보드