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') + +
| 시간 | +액션 | +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 + | ++ + + + | +
|
+
+
+
+
+
+
+
+ 변경 전 (Before)+
+
+ {{ json_encode($log->before, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}' }}
+
+
+ 변경 후 (After)+
+
+ {{ json_encode($log->after, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}' }}
+
+ IP: {{ $log->ip ?? '-' }}
+ Target: {{ $log->target_type }}#{{ $log->target_id }}
+
+ |
+ ||||||||
| + 감사 로그가 없습니다. + | +||||||||
{{ $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) ?? '-' }}
+{{ json_encode($log->before, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '(데이터 없음)' }}
+ {{ json_encode($log->after, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '(데이터 없음)' }}
+ | 필드 | +변경 전 | +변경 후 | +
|---|---|---|
| {{ $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 + | +