From 6b40362392b09120e5048bb10059961c56fa64ce Mon Sep 17 00:00:00 2001 From: kent Date: Mon, 1 Dec 2025 00:43:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[archived-records]=20=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=B3=B5=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - 아카이브 복원 기능: - ArchiveService: 모델별 아카이브 로직 통합 (326줄) - RestoreService: 복원 로직 및 충돌 검사 (319줄) - ArchivedRecordController: restore, checkRestore 메서드 추가 - record_type enum→varchar 마이그레이션 - 복원 버튼 및 충돌 체크 UI (restore-check.blade.php) Phase 2 - 테넌트 필터링: - ArchivedRecord 모델: tenant_id fillable, tenant 관계 추가 - ArchiveService: tenant_id 저장 로직 (determineTenantId) - ArchivedRecordService: 테넌트별 필터링 쿼리 - 목록 UI: ID 컬럼, 대상 테넌트 컬럼 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- WORKFLOW_STATE.md | 125 +++++++ .../Controllers/ArchivedRecordController.php | 65 +++- app/Models/Archives/ArchivedRecord.php | 12 + app/Services/ArchiveService.php | 326 ++++++++++++++++++ app/Services/ArchivedRecordService.php | 22 +- app/Services/RestoreService.php | 319 +++++++++++++++++ app/Services/TenantService.php | 26 +- .../archive-restore-feature-analysis.md | 262 ++++++++++++++ ...rchived_records_record_type_to_varchar.php | 30 ++ .../views/archived-records/index.blade.php | 23 ++ .../archived-records/partials/table.blade.php | 60 +++- .../archived-records/restore-check.blade.php | 172 +++++++++ .../views/archived-records/show.blade.php | 32 ++ routes/web.php | 2 + 14 files changed, 1452 insertions(+), 24 deletions(-) create mode 100644 WORKFLOW_STATE.md create mode 100644 app/Services/ArchiveService.php create mode 100644 app/Services/RestoreService.php create mode 100644 claudedocs/archive-restore-feature-analysis.md create mode 100644 database/migrations/2025_11_30_144617_modify_archived_records_record_type_to_varchar.php create mode 100644 resources/views/archived-records/restore-check.blade.php diff --git a/WORKFLOW_STATE.md b/WORKFLOW_STATE.md new file mode 100644 index 00000000..25850dcf --- /dev/null +++ b/WORKFLOW_STATE.md @@ -0,0 +1,125 @@ +# Workflow 진행 상태 + +**작업명:** Archive & Restore Feature (아카이브 복원 기능) +**시작일:** 2025-11-30 +**분석 문서:** `claudedocs/archive-restore-feature-analysis.md` + +--- + +## 현재 단계: Phase 2 완료 ✅ + +--- + +## 전체 작업 목록 + +- [x] 1단계: 분석 완료 +- [x] 2단계: Phase 1 순차 수정 (8/8 완료) +- [x] 3단계: Phase 1 검증 완료 (Pint) +- [x] 4단계: Phase 2 테넌트 필터링 (5/5 완료) +- [x] 5단계: Phase 2 검증 완료 (Pint) +- [ ] 6단계: 커밋 대기 + +--- + +## Phase 1 - 아카이브 복원 기능 ✅ 완료 + +| # | 작업 | 파일 | 상태 | +|---|------|------|------| +| 1 | 마이그레이션: record_type enum→varchar | `api/database/migrations/2025_11_30_*_modify_archived_records_record_type_to_varchar.php` | ✅ 완료 | +| 2 | ArchiveService 생성 | `mng/app/Services/ArchiveService.php` | ✅ 완료 | +| 3 | RestoreService 생성 | `mng/app/Services/RestoreService.php` | ✅ 완료 | +| 4 | TenantService에 아카이브 로직 적용 | `mng/app/Services/TenantService.php` | ✅ 완료 | +| 5 | UserService에 아카이브 로직 적용 | `mng/app/Services/UserService.php` | ✅ 완료 | +| 6 | ArchivedRecordController에 restore 추가 | `mng/app/Http/Controllers/ArchivedRecordController.php` | ✅ 완료 | +| 7 | 라우트 추가 | `mng/routes/web.php` | ✅ 완료 | +| 8 | UI: 복원 버튼 추가 | `mng/resources/views/archived-records/show.blade.php`, `restore-check.blade.php` | ✅ 완료 | + +--- + +## Phase 2 - 테넌트 필터링 기능 ✅ 완료 + +| # | 저장소 | 파일 | 작업 | 상태 | +|---|--------|------|------|------| +| 1 | **api/** | `database/migrations/2025_12_01_002115_add_tenant_id_to_archived_records.php` | 신규 - DB 마이그레이션 | ✅ 완료 | +| 2 | mng/ | `app/Services/ArchiveService.php` | 수정 - tenant_id 저장 로직 | ✅ 완료 | +| 3 | mng/ | `app/Services/ArchivedRecordService.php` | 수정 - 테넌트 필터링 | ✅ 완료 | +| 4 | mng/ | `app/Models/Archives/ArchivedRecord.php` | 수정 - fillable, 관계 추가 | ✅ 완료 | +| 5 | mng/ | `resources/views/archived-records/partials/table.blade.php` | 수정 - 대상 테넌트 표시 | ✅ 완료 | + +--- + +## 생성/수정된 파일 목록 (전체) + +### api/ 저장소 +- `database/migrations/2025_12_01_002115_add_tenant_id_to_archived_records.php` (신규) + +### mng/ 저장소 + +#### 신규 생성 +- `app/Services/ArchiveService.php` (326줄) +- `app/Services/RestoreService.php` (319줄) +- `resources/views/archived-records/restore-check.blade.php` +- `database/migrations/2025_11_30_144617_modify_archived_records_record_type_to_varchar.php` + +#### 수정 +- `app/Services/TenantService.php` (ArchiveService DI, forceDeleteTenant 수정) +- `app/Services/UserService.php` (ArchiveService DI, forceDeleteUser 수정) +- `app/Services/ArchivedRecordService.php` (tenant_id 필드 추가, 필터링) +- `app/Models/Archives/ArchivedRecord.php` (tenant_id fillable, tenant 관계) +- `app/Http/Controllers/ArchivedRecordController.php` (restore, checkRestore 메서드) +- `routes/web.php` (restore-check, restore 라우트) +- `resources/views/archived-records/show.blade.php` (복원 버튼, 알림 메시지) +- `resources/views/archived-records/index.blade.php` (알림 메시지) +- `resources/views/archived-records/partials/table.blade.php` (대상 테넌트 컬럼) + +--- + +## Phase 2 주요 변경 내용 + +### 1. tenant_id 컬럼 추가 +- archived_records 테이블에 tenant_id 컬럼 추가 +- FK 제약조건 없음 (삭제된 테넌트 참조 가능) +- 인덱스 추가 + +### 2. tenant_id 결정 로직 +- **테넌트 삭제**: tenant_id = 삭제되는 테넌트의 ID (자기 자신) +- **사용자 삭제**: tenant_id = session('selected_tenant_id') +- **부서/메뉴/역할 삭제**: 해당 레코드의 tenant_id + +### 3. UI 변경 +- 목록에 "대상 테넌트" 컬럼 추가 +- 존재하는 테넌트: 인디고 뱃지로 표시 +- 삭제된 테넌트: 회색 뱃지로 "(삭제됨 ID: N)" 표시 +- 테넌트 없음: "-" 표시 + +### 4. 필터링 +- ArchivedRecordService에 tenant_id 필터 추가 +- filters['tenant_id']로 테넌트별 필터링 가능 + +--- + +## 남은 작업 + +### 6단계: 커밋 (대기) +- api/ 저장소: 마이그레이션 커밋 +- mng/ 저장소: 모든 변경사항 커밋 +- 사용자 승인 후 진행 + +--- + +## 중단 시 재개 가이드 + +1. 이 파일(`WORKFLOW_STATE.md`) 확인 +2. 현재 상태: Phase 2 완료, 커밋 대기 +3. 커밋 요청 시 변경 사항 요약 제공 후 커밋 + +--- + +## 참고 파일 + +- 분석 문서: `claudedocs/archive-restore-feature-analysis.md` +- ArchivedRecord 모델: `app/Models/Archives/ArchivedRecord.php` +- ArchivedRecordRelation 모델: `app/Models/Archives/ArchivedRecordRelation.php` +- ArchivedRecordService: `app/Services/ArchivedRecordService.php` +- ArchiveService: `app/Services/ArchiveService.php` +- RestoreService: `app/Services/RestoreService.php` \ No newline at end of file diff --git a/app/Http/Controllers/ArchivedRecordController.php b/app/Http/Controllers/ArchivedRecordController.php index 8c62f68b..e4947920 100644 --- a/app/Http/Controllers/ArchivedRecordController.php +++ b/app/Http/Controllers/ArchivedRecordController.php @@ -3,12 +3,15 @@ namespace App\Http\Controllers; use App\Services\ArchivedRecordService; +use App\Services\RestoreService; +use Illuminate\Http\RedirectResponse; use Illuminate\View\View; class ArchivedRecordController extends Controller { public function __construct( - private readonly ArchivedRecordService $archivedRecordService + private readonly ArchivedRecordService $archivedRecordService, + private readonly RestoreService $restoreService ) {} /** @@ -43,4 +46,64 @@ public function show(string $batchId): View return view('archived-records.show', compact('records', 'batchInfo')); } + + /** + * 배치 복원 가능 여부 확인 + */ + public function checkRestore(string $batchId): View + { + $records = $this->archivedRecordService->getRecordsByBatchId($batchId); + + if ($records->isEmpty()) { + abort(404, '아카이브 레코드를 찾을 수 없습니다.'); + } + + // 복원 가능 여부 확인 + $restoreCheck = $this->restoreService->canRestoreBatch($batchId); + + // batch 정보 추출 + $batchInfo = [ + 'batch_id' => $batchId, + 'batch_description' => $records->first()->batch_description, + 'deleted_by' => $records->first()->deletedByUser?->name ?? '-', + 'deleted_at' => $records->first()->deleted_at, + 'record_count' => $records->count(), + ]; + + return view('archived-records.restore-check', compact('records', 'batchInfo', 'restoreCheck')); + } + + /** + * 배치 복원 실행 + */ + public function restore(string $batchId): RedirectResponse + { + // 슈퍼관리자 권한 확인 + if (! auth()->user()?->is_super_admin) { + return redirect() + ->route('archived-records.show', $batchId) + ->with('error', '슈퍼관리자만 복원할 수 있습니다.'); + } + + // 복원 가능 여부 확인 + $restoreCheck = $this->restoreService->canRestoreBatch($batchId); + + if (! $restoreCheck['can_restore']) { + return redirect() + ->route('archived-records.show', $batchId) + ->with('error', '복원할 수 없습니다: '.implode(', ', $restoreCheck['issues'])); + } + + try { + $restoredModels = $this->restoreService->restoreBatch($batchId); + + return redirect() + ->route('archived-records.index') + ->with('success', "복원 완료: {$restoredModels->count()}개 레코드가 복원되었습니다."); + } catch (\Exception $e) { + return redirect() + ->route('archived-records.show', $batchId) + ->with('error', '복원 중 오류 발생: '.$e->getMessage()); + } + } } diff --git a/app/Models/Archives/ArchivedRecord.php b/app/Models/Archives/ArchivedRecord.php index 280ec754..a5a3f1e4 100644 --- a/app/Models/Archives/ArchivedRecord.php +++ b/app/Models/Archives/ArchivedRecord.php @@ -2,6 +2,7 @@ namespace App\Models\Archives; +use App\Models\Tenants\Tenant; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -15,6 +16,7 @@ class ArchivedRecord extends Model 'batch_id', 'batch_description', 'record_type', + 'tenant_id', 'original_id', 'main_data', 'schema_version', @@ -27,9 +29,19 @@ class ArchivedRecord extends Model 'main_data' => 'array', 'deleted_at' => 'datetime', 'original_id' => 'integer', + 'tenant_id' => 'integer', 'deleted_by' => 'integer', ]; + /** + * 관계: 대상 테넌트 + * NOTE: 삭제된 테넌트를 참조할 수 있어 FK 없음 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + /** * 관계: 삭제한 사용자 */ diff --git a/app/Services/ArchiveService.php b/app/Services/ArchiveService.php new file mode 100644 index 00000000..505a5fb1 --- /dev/null +++ b/app/Services/ArchiveService.php @@ -0,0 +1,326 @@ + 'tenant', + User::class => 'user', + Department::class => 'department', + Menu::class => 'menu', + Role::class => 'role', + // 추후 확장 + // Board::class => 'board', + // Project::class => 'project', + // Issue::class => 'issue', + // Task::class => 'task', + ]; + + /** + * record_type → 모델 클래스 역매핑 + */ + private const MODEL_CLASS_MAP = [ + 'tenant' => Tenant::class, + 'user' => User::class, + 'department' => Department::class, + 'menu' => Menu::class, + 'role' => Role::class, + ]; + + /** + * 단일 모델 아카이브 + * + * @param Model $model 아카이브할 모델 + * @param array $relationNames 함께 아카이브할 관계명 (예: ['users', 'departments']) + * @param string|null $batchId 배치 ID (동일 배치로 묶을 경우) + * @param string|null $description 배치 설명 + * @param int|null $tenantId 대상 테넌트 ID (명시적 지정) + */ + public function archiveModel( + Model $model, + array $relationNames = [], + ?string $batchId = null, + ?string $description = null, + ?int $tenantId = null + ): ArchivedRecord { + $batchId = $batchId ?? (string) Str::uuid(); + $recordType = $this->getRecordType($model); + + // 기본 설명 생성 + if (! $description) { + $description = $this->generateDescription($model, $recordType); + } + + // tenant_id 결정 + $tenantId = $tenantId ?? $this->determineTenantId($model, $recordType); + + return DB::transaction(function () use ($model, $relationNames, $batchId, $description, $recordType, $tenantId) { + // 1. 메인 레코드 아카이브 + $archivedRecord = ArchivedRecord::create([ + 'batch_id' => $batchId, + 'batch_description' => $description, + 'record_type' => $recordType, + 'tenant_id' => $tenantId, + 'original_id' => $model->getKey(), + 'main_data' => $model->toArray(), + 'schema_version' => 'v1.0', + 'deleted_by' => auth()->id(), + 'deleted_at' => now(), + 'created_by' => auth()->id(), + ]); + + // 2. 관계 데이터 아카이브 + foreach ($relationNames as $relationName) { + $this->archiveRelation($archivedRecord, $model, $relationName); + } + + return $archivedRecord; + }); + } + + /** + * 배치 아카이브 (여러 모델을 하나의 배치로) + * + * @param Collection $models 아카이브할 모델 컬렉션 + * @param string $description 배치 설명 + * @param array $relationNames 각 모델에서 아카이브할 관계명 + * @return string 배치 ID + */ + public function archiveBatch( + Collection $models, + string $description, + array $relationNames = [] + ): string { + $batchId = (string) Str::uuid(); + + DB::transaction(function () use ($models, $description, $relationNames, $batchId) { + foreach ($models as $model) { + $this->archiveModel($model, $relationNames, $batchId, $description); + } + }); + + return $batchId; + } + + /** + * 테넌트와 관련 데이터 전체 아카이브 (테넌트 삭제 전용) + */ + public function archiveTenantWithRelations(Tenant $tenant): string + { + $batchId = (string) Str::uuid(); + $description = "테넌트 삭제: {$tenant->company_name} (ID: {$tenant->id})"; + + DB::transaction(function () use ($tenant, $batchId, $description) { + // 1. 메인 테넌트 아카이브 (tenant_id = 자기 자신) + $archivedRecord = ArchivedRecord::create([ + 'batch_id' => $batchId, + 'batch_description' => $description, + 'record_type' => 'tenant', + 'tenant_id' => $tenant->id, + 'original_id' => $tenant->id, + 'main_data' => $tenant->toArray(), + 'schema_version' => 'v1.0', + 'deleted_by' => auth()->id(), + 'deleted_at' => now(), + 'created_by' => auth()->id(), + ]); + + // 2. 관련 사용자 아카이브 (user_tenants 피벗 포함) + $users = $tenant->users()->get(); + if ($users->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'users', + 'data' => $users->map(fn ($user) => [ + 'user' => $user->toArray(), + 'pivot' => $user->pivot?->toArray(), + ])->toArray(), + 'record_count' => $users->count(), + 'created_by' => auth()->id(), + ]); + } + + // 3. 부서 아카이브 + $departments = $tenant->departments()->withTrashed()->get(); + if ($departments->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'departments', + 'data' => $departments->toArray(), + 'record_count' => $departments->count(), + 'created_by' => auth()->id(), + ]); + } + + // 4. 메뉴 아카이브 + $menus = $tenant->menus()->withTrashed()->get(); + if ($menus->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'menus', + 'data' => $menus->toArray(), + 'record_count' => $menus->count(), + 'created_by' => auth()->id(), + ]); + } + + // 5. 역할 아카이브 + $roles = $tenant->roles()->withTrashed()->get(); + if ($roles->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'roles', + 'data' => $roles->toArray(), + 'record_count' => $roles->count(), + 'created_by' => auth()->id(), + ]); + } + }); + + return $batchId; + } + + /** + * 사용자 아카이브 (사용자 삭제 전용) + */ + public function archiveUser(User $user): string + { + $batchId = (string) Str::uuid(); + $description = "사용자 삭제: {$user->name} ({$user->email})"; + + // tenant_id = 현재 선택된 테넌트 + $tenantId = session('selected_tenant_id'); + + DB::transaction(function () use ($user, $batchId, $description, $tenantId) { + // 1. 메인 사용자 아카이브 + $archivedRecord = ArchivedRecord::create([ + 'batch_id' => $batchId, + 'batch_description' => $description, + 'record_type' => 'user', + 'tenant_id' => $tenantId, + 'original_id' => $user->id, + 'main_data' => $user->toArray(), + 'schema_version' => 'v1.0', + 'deleted_by' => auth()->id(), + 'deleted_at' => now(), + 'created_by' => auth()->id(), + ]); + + // 2. 테넌트 관계 아카이브 + $tenants = $user->tenants()->get(); + if ($tenants->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'user_tenants', + 'data' => $tenants->map(fn ($tenant) => [ + 'tenant_id' => $tenant->id, + 'tenant_name' => $tenant->company_name, + 'pivot' => $tenant->pivot?->toArray(), + ])->toArray(), + 'record_count' => $tenants->count(), + 'created_by' => auth()->id(), + ]); + } + }); + + return $batchId; + } + + /** + * 모델에서 record_type 추출 + */ + public function getRecordType(Model $model): string + { + $class = get_class($model); + + return self::RECORD_TYPE_MAP[$class] ?? Str::snake(class_basename($class)); + } + + /** + * record_type에서 모델 클래스 추출 + */ + public function getModelClass(string $recordType): ?string + { + return self::MODEL_CLASS_MAP[$recordType] ?? null; + } + + /** + * 관계 데이터 아카이브 + */ + private function archiveRelation(ArchivedRecord $archivedRecord, Model $model, string $relationName): void + { + if (! method_exists($model, $relationName)) { + return; + } + + $relation = $model->{$relationName}(); + + // withTrashed가 있으면 soft deleted 포함 + if (method_exists($relation, 'withTrashed')) { + $relatedData = $relation->withTrashed()->get(); + } else { + $relatedData = $relation->get(); + } + + if ($relatedData->isEmpty()) { + return; + } + + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => $relationName, + 'data' => $relatedData->toArray(), + 'record_count' => $relatedData->count(), + 'created_by' => auth()->id(), + ]); + } + + /** + * 기본 설명 생성 + */ + private function generateDescription(Model $model, string $recordType): string + { + $name = $model->name ?? $model->company_name ?? $model->title ?? "ID: {$model->getKey()}"; + + return match ($recordType) { + 'tenant' => "테넌트 삭제: {$name}", + 'user' => "사용자 삭제: {$name}", + 'department' => "부서 삭제: {$name}", + 'menu' => "메뉴 삭제: {$name}", + 'role' => "역할 삭제: {$name}", + default => "{$recordType} 삭제: {$name}", + }; + } + + /** + * tenant_id 결정 로직 + * - tenant 삭제: 삭제되는 테넌트의 ID (자기 자신) + * - user 삭제: session('selected_tenant_id') (현재 선택된 테넌트) + * - department/menu/role 삭제: 해당 레코드의 tenant_id + */ + private function determineTenantId(Model $model, string $recordType): ?int + { + return match ($recordType) { + 'tenant' => $model->getKey(), + 'user' => session('selected_tenant_id'), + 'department', 'menu', 'role' => $model->tenant_id ?? session('selected_tenant_id'), + default => session('selected_tenant_id'), + }; + } +} diff --git a/app/Services/ArchivedRecordService.php b/app/Services/ArchivedRecordService.php index 1ca5712c..4b6fd430 100644 --- a/app/Services/ArchivedRecordService.php +++ b/app/Services/ArchivedRecordService.php @@ -24,15 +24,27 @@ public function getArchivedRecordsBatched(array $filters = [], int $perPage = 15 DB::raw("COALESCE(batch_id, CONCAT('legacy_', id)) as batch_id"), DB::raw("COALESCE(batch_description, CONCAT(record_type, ' 삭제 (ID: ', original_id, ')')) as batch_description"), DB::raw('MIN(id) as id'), + DB::raw('MIN(tenant_id) as tenant_id'), DB::raw('GROUP_CONCAT(DISTINCT record_type) as record_types'), DB::raw('COUNT(*) as record_count'), DB::raw('MIN(deleted_by) as deleted_by'), DB::raw('MIN(deleted_at) as deleted_at'), - ]) - ->groupBy( - DB::raw("COALESCE(batch_id, CONCAT('legacy_', id))"), - DB::raw("COALESCE(batch_description, CONCAT(record_type, ' 삭제 (ID: ', original_id, ')'))") - ); + // 대상 정보 추출 (테넌트명, 사용자명 등) + DB::raw("MAX(JSON_UNQUOTE(JSON_EXTRACT(main_data, '$.company_name'))) as target_company_name"), + DB::raw("MAX(JSON_UNQUOTE(JSON_EXTRACT(main_data, '$.name'))) as target_name"), + DB::raw("MAX(JSON_UNQUOTE(JSON_EXTRACT(main_data, '$.email'))) as target_email"), + DB::raw("MAX(JSON_UNQUOTE(JSON_EXTRACT(main_data, '$.code'))) as target_code"), + ]); + + // 테넌트 필터링 (서브쿼리에서 적용) + if (! empty($filters['tenant_id'])) { + $subQuery->where('tenant_id', $filters['tenant_id']); + } + + $subQuery->groupBy( + DB::raw("COALESCE(batch_id, CONCAT('legacy_', id))"), + DB::raw("COALESCE(batch_description, CONCAT(record_type, ' 삭제 (ID: ', original_id, ')'))") + ); // 2. 외부 쿼리: 서브쿼리를 감싸서 정확한 count 계산 $query = DB::table(DB::raw("({$subQuery->toSql()}) as grouped")) diff --git a/app/Services/RestoreService.php b/app/Services/RestoreService.php new file mode 100644 index 00000000..5f56c53f --- /dev/null +++ b/app/Services/RestoreService.php @@ -0,0 +1,319 @@ + Tenant::class, + 'user' => User::class, + 'department' => Department::class, + 'menu' => Menu::class, + 'role' => Role::class, + ]; + + /** + * record_type → 테이블명 매핑 + */ + private const TABLE_NAME_MAP = [ + 'tenant' => 'tenants', + 'user' => 'users', + 'department' => 'departments', + 'menu' => 'menus', + 'role' => 'roles', + ]; + + /** + * 단일 아카이브 레코드 복원 + * + * @return Model 복원된 모델 + * + * @throws \Exception 복원 실패 시 + */ + public function restoreRecord(ArchivedRecord $record): Model + { + $modelClass = $this->getModelClass($record->record_type); + + if (! $modelClass) { + throw new \Exception("지원하지 않는 레코드 타입: {$record->record_type}"); + } + + return DB::transaction(function () use ($record, $modelClass) { + // 1. 메인 데이터 복원 + $mainData = $record->main_data; + + // ID, timestamps 제거 (새로 생성되도록) + unset($mainData['id'], $mainData['created_at'], $mainData['updated_at'], $mainData['deleted_at']); + + // soft delete 관련 필드 초기화 + $mainData['deleted_by'] = null; + + // 복원자 정보 추가 + $mainData['created_by'] = auth()->id(); + $mainData['updated_by'] = auth()->id(); + + // 모델 생성 + $model = $modelClass::create($mainData); + + // 2. 아카이브 레코드 삭제 + $record->relations()->delete(); + $record->delete(); + + return $model; + }); + } + + /** + * 배치 전체 복원 (테넌트 + 관련 데이터) + * + * @return Collection 복원된 모델들 + */ + public function restoreBatch(string $batchId): Collection + { + $records = ArchivedRecord::where('batch_id', $batchId) + ->with('relations') + ->get(); + + if ($records->isEmpty()) { + throw new \Exception("배치를 찾을 수 없습니다: {$batchId}"); + } + + $restoredModels = collect(); + + DB::transaction(function () use ($records, &$restoredModels) { + foreach ($records as $record) { + // 테넌트 복원의 경우 특별 처리 + if ($record->record_type === 'tenant') { + $restoredModels->push($this->restoreTenantWithRelations($record)); + } elseif ($record->record_type === 'user') { + $restoredModels->push($this->restoreUserWithRelations($record)); + } else { + $restoredModels->push($this->restoreRecord($record)); + } + } + }); + + return $restoredModels; + } + + /** + * 테넌트와 관련 데이터 복원 + */ + public function restoreTenantWithRelations(ArchivedRecord $record): Tenant + { + if ($record->record_type !== 'tenant') { + throw new \Exception('테넌트 타입의 레코드가 아닙니다.'); + } + + return DB::transaction(function () use ($record) { + // 1. 테넌트 복원 + $tenantData = $record->main_data; + unset($tenantData['id'], $tenantData['created_at'], $tenantData['updated_at'], $tenantData['deleted_at']); + $tenantData['deleted_by'] = null; + $tenantData['created_by'] = auth()->id(); + + $tenant = Tenant::create($tenantData); + + // 2. 관련 데이터 복원 + foreach ($record->relations as $relation) { + $this->restoreRelationData($tenant, $relation); + } + + // 3. 아카이브 삭제 + $record->relations()->delete(); + $record->delete(); + + return $tenant; + }); + } + + /** + * 사용자와 관련 데이터 복원 + */ + public function restoreUserWithRelations(ArchivedRecord $record): User + { + if ($record->record_type !== 'user') { + throw new \Exception('사용자 타입의 레코드가 아닙니다.'); + } + + return DB::transaction(function () use ($record) { + // 1. 사용자 복원 + $userData = $record->main_data; + unset($userData['id'], $userData['created_at'], $userData['updated_at'], $userData['deleted_at']); + $userData['deleted_by'] = null; + $userData['created_by'] = auth()->id(); + + $user = User::create($userData); + + // 2. 테넌트 관계 복원 + foreach ($record->relations as $relation) { + if ($relation->table_name === 'user_tenants') { + foreach ($relation->data as $tenantData) { + // 테넌트가 존재하는지 확인 + $tenantId = $tenantData['tenant_id'] ?? null; + if ($tenantId && Tenant::find($tenantId)) { + $pivotData = $tenantData['pivot'] ?? []; + unset($pivotData['user_id'], $pivotData['tenant_id']); + $user->tenants()->attach($tenantId, $pivotData); + } + } + } + } + + // 3. 아카이브 삭제 + $record->relations()->delete(); + $record->delete(); + + return $user; + }); + } + + /** + * 관계 데이터 복원 + */ + private function restoreRelationData(Tenant $tenant, ArchivedRecordRelation $relation): void + { + $tableName = $relation->table_name; + $data = $relation->data; + + switch ($tableName) { + case 'users': + // 사용자 복원은 별도 처리 (user_tenants 피벗 포함) + foreach ($data as $item) { + $userData = $item['user'] ?? $item; + $pivotData = $item['pivot'] ?? []; + + // 이미 존재하는 사용자인지 확인 + $existingUser = User::where('email', $userData['email'])->first(); + if ($existingUser) { + // 기존 사용자에 테넌트 관계만 추가 + if (! $existingUser->tenants()->where('tenants.id', $tenant->id)->exists()) { + unset($pivotData['user_id'], $pivotData['tenant_id']); + $existingUser->tenants()->attach($tenant->id, $pivotData); + } + } + // 새 사용자 생성은 보안상 하지 않음 (비밀번호 문제) + } + break; + + case 'departments': + foreach ($data as $deptData) { + unset($deptData['id'], $deptData['created_at'], $deptData['updated_at'], $deptData['deleted_at']); + $deptData['tenant_id'] = $tenant->id; + $deptData['created_by'] = auth()->id(); + Department::create($deptData); + } + break; + + case 'menus': + // 메뉴는 parent_id 순서 고려 필요 (부모 먼저) + $sortedMenus = collect($data)->sortBy('parent_id')->values(); + $menuIdMap = []; // 원본 ID → 새 ID 매핑 + + foreach ($sortedMenus as $menuData) { + $originalId = $menuData['id']; + unset($menuData['id'], $menuData['created_at'], $menuData['updated_at'], $menuData['deleted_at']); + $menuData['tenant_id'] = $tenant->id; + $menuData['created_by'] = auth()->id(); + + // parent_id 매핑 + if (! empty($menuData['parent_id']) && isset($menuIdMap[$menuData['parent_id']])) { + $menuData['parent_id'] = $menuIdMap[$menuData['parent_id']]; + } else { + $menuData['parent_id'] = null; + } + + $newMenu = Menu::create($menuData); + $menuIdMap[$originalId] = $newMenu->id; + } + break; + + case 'roles': + foreach ($data as $roleData) { + unset($roleData['id'], $roleData['created_at'], $roleData['updated_at'], $roleData['deleted_at']); + $roleData['tenant_id'] = $tenant->id; + $roleData['created_by'] = auth()->id(); + Role::create($roleData); + } + break; + } + } + + /** + * record_type에서 모델 클래스 가져오기 + */ + private function getModelClass(string $recordType): ?string + { + return self::MODEL_CLASS_MAP[$recordType] ?? null; + } + + /** + * 복원 가능 여부 확인 + */ + public function canRestore(ArchivedRecord $record): array + { + $issues = []; + + // 1. 모델 클래스 지원 여부 + if (! $this->getModelClass($record->record_type)) { + $issues[] = "지원하지 않는 레코드 타입입니다: {$record->record_type}"; + } + + // 2. 테넌트 복원 시, 동일 코드 존재 여부 확인 + if ($record->record_type === 'tenant') { + $code = $record->main_data['code'] ?? null; + if ($code && Tenant::where('code', $code)->exists()) { + $issues[] = "동일한 테넌트 코드가 이미 존재합니다: {$code}"; + } + } + + // 3. 사용자 복원 시, 동일 이메일 존재 여부 확인 + if ($record->record_type === 'user') { + $email = $record->main_data['email'] ?? null; + if ($email && User::where('email', $email)->exists()) { + $issues[] = "동일한 이메일의 사용자가 이미 존재합니다: {$email}"; + } + } + + return [ + 'can_restore' => empty($issues), + 'issues' => $issues, + ]; + } + + /** + * 배치 내 모든 레코드의 복원 가능 여부 확인 + */ + public function canRestoreBatch(string $batchId): array + { + $records = ArchivedRecord::where('batch_id', $batchId)->get(); + $allIssues = []; + + foreach ($records as $record) { + $check = $this->canRestore($record); + if (! $check['can_restore']) { + $allIssues = array_merge($allIssues, $check['issues']); + } + } + + return [ + 'can_restore' => empty($allIssues), + 'issues' => array_unique($allIssues), + 'record_count' => $records->count(), + ]; + } +} diff --git a/app/Services/TenantService.php b/app/Services/TenantService.php index d67f8505..2ec0de7a 100644 --- a/app/Services/TenantService.php +++ b/app/Services/TenantService.php @@ -9,6 +9,10 @@ class TenantService { + public function __construct( + private readonly ArchiveService $archiveService + ) {} + /** * 테넌트 목록 조회 (페이지네이션) */ @@ -111,18 +115,28 @@ public function restoreTenant(int $id): bool /** * 테넌트 영구 삭제 (슈퍼관리자 전용) + * + * 1. 테넌트와 관련 데이터를 아카이브에 저장 + * 2. 관련 데이터 삭제 + * 3. 테넌트 영구 삭제 */ public function forceDeleteTenant(int $id): bool { $tenant = Tenant::withTrashed()->findOrFail($id); - // 관련 데이터 먼저 삭제 - $tenant->users()->detach(); // user_tenants 관계 삭제 - $tenant->departments()->forceDelete(); // 부서 영구 삭제 - $tenant->menus()->forceDelete(); // 메뉴 영구 삭제 - $tenant->roles()->forceDelete(); // 역할 영구 삭제 + return DB::transaction(function () use ($tenant) { + // 1. 아카이브에 저장 (복원 가능하도록) + $this->archiveService->archiveTenantWithRelations($tenant); - return $tenant->forceDelete(); + // 2. 관련 데이터 삭제 + $tenant->users()->detach(); // user_tenants 관계 삭제 + $tenant->departments()->forceDelete(); // 부서 영구 삭제 + $tenant->menus()->forceDelete(); // 메뉴 영구 삭제 + $tenant->roles()->forceDelete(); // 역할 영구 삭제 + + // 3. 테넌트 영구 삭제 + return $tenant->forceDelete(); + }); } /** diff --git a/claudedocs/archive-restore-feature-analysis.md b/claudedocs/archive-restore-feature-analysis.md new file mode 100644 index 00000000..6d311b63 --- /dev/null +++ b/claudedocs/archive-restore-feature-analysis.md @@ -0,0 +1,262 @@ +# Archive & Restore Feature Analysis + +**날짜:** 2025-11-30 +**작업자:** Claude Code +**요청:** 영구 삭제 데이터 복원 기능 구현 + +--- + +## 1. 요청 내용 + +- `https://mng.sam.kr/archived-records` 에서 영구 삭제된 데이터를 복원할 수 있는 기능 +- 삭제/복구 프로세스 정립: + - 일반 관리자/테넌트: Soft Delete + - 슈퍼관리자: 영구 삭제 가능 → archived_records에 저장 + - 영구 삭제 데이터: 복원 가능해야 함 +- UI 개선: 작업 설명 컬럼 개선 + +--- + +## 2. 현재 상태 분석 + +### 2.1 forceDelete 사용 서비스 (8개) + +모든 서비스가 **아카이브 없이** 바로 영구 삭제: + +| 서비스 | 메서드 | 삭제 대상 | 파일 위치 | +|--------|--------|----------|-----------| +| `TenantService` | `forceDeleteTenant()` | 테넌트 + 부서/메뉴/역할 | `app/Services/TenantService.php:115` | +| `UserService` | `forceDeleteUser()` | 사용자 | `app/Services/UserService.php:232` | +| `DepartmentService` | `forceDeleteDepartment()` | 부서 | `app/Services/DepartmentService.php:171` | +| `MenuService` | `forceDeleteMenu()` | 메뉴 | `app/Services/MenuService.php:281` | +| `BoardService` | `forceDeleteBoard()` | 게시판 | `app/Services/BoardService.php:141` | +| `ProjectService` | `forceDeleteProject()` | 프로젝트 | `app/Services/ProjectManagement/ProjectService.php:134` | +| `IssueService` | `forceDeleteIssue()` | 이슈 | `app/Services/ProjectManagement/IssueService.php:160` | +| `TaskService` | `forceDeleteTask()` | 작업 | `app/Services/ProjectManagement/TaskService.php:168` | + +### 2.2 현재 DB 스키마 + +**archived_records 테이블:** +``` +id bigint PK +batch_id char(36) -- UUID, 그룹핑용 +batch_description varchar(255) -- 배치 설명 +record_type varchar(50) -- ✅ varchar로 변경됨 (기존 enum) +original_id bigint -- 원본 레코드 ID +main_data json -- 원본 데이터 (JSON) +schema_version varchar(50) -- 스키마 버전 +deleted_by bigint FK -- 삭제자 +deleted_at timestamp -- 삭제 시간 +notes text -- 메모 +created_at, updated_at, created_by, updated_by +``` + +**archived_record_relations 테이블:** +``` +id bigint PK +archived_record_id bigint FK -- archived_records.id +table_name varchar(100) -- 관련 테이블명 +data json -- 관련 데이터 (JSON) +record_count int -- 레코드 수 +created_at, updated_at, created_by, updated_by +``` + +### 2.3 문제점 + +1. **아카이브 생성 코드 없음**: `ArchivedRecord::create()` 호출하는 곳이 없음 +2. ✅ ~~**record_type enum 제한**~~: varchar로 변경 완료 +3. **복원 기능 없음**: RestoreService 미존재 +4. **데이터 유실**: forceDelete 시 데이터가 완전히 삭제됨 + +--- + +## 3. 구현 계획 + +### Phase 1: 인프라 구축 (이번 작업) + +#### 3.1 마이그레이션 ✅ 완료 +- `record_type` enum → varchar(50) 변경 완료 + +#### 3.2 ArchiveService 생성 +```php +class ArchiveService { + // 단일 모델 아카이브 + public function archiveModel(Model $model, array $relations = [], ?string $batchId = null): ArchivedRecord + + // 배치 아카이브 (여러 모델) + public function archiveBatch(Collection $models, string $description, array $relations = []): string + + // 모델별 record_type 매핑 + private function getRecordType(Model $model): string +} +``` + +#### 3.3 RestoreService 생성 +```php +class RestoreService { + // 단일 레코드 복원 + public function restoreRecord(ArchivedRecord $record): Model + + // 배치 전체 복원 + public function restoreBatch(string $batchId): Collection + + // 관계 데이터 복원 + private function restoreRelations(ArchivedRecord $record): void +} +``` + +#### 3.4 기존 서비스 수정 (TenantService, UserService 먼저) + +#### 3.5 UI 개선 +- 복원 버튼 추가 +- 라우트 추가 +- 컨트롤러 메서드 추가 + +--- + +## 4. 수정 대상 파일 + +| # | 파일 | 작업 | 상태 | +|---|------|------|------| +| 1 | `database/migrations/2025_11_30_*_modify_archived_records_record_type_to_varchar.php` | 신규 | ✅ 완료 | +| 2 | `app/Services/ArchiveService.php` | 신규 | 🔄 진행 중 | +| 3 | `app/Services/RestoreService.php` | 신규 | ⏳ 대기 | +| 4 | `app/Services/TenantService.php` | 수정 | ⏳ 대기 | +| 5 | `app/Services/UserService.php` | 수정 | ⏳ 대기 | +| 6 | `app/Http/Controllers/ArchivedRecordController.php` | 수정 | ⏳ 대기 | +| 7 | `routes/web.php` | 수정 | ⏳ 대기 | +| 8 | `resources/views/archived-records/show.blade.php` | 수정 | ⏳ 대기 | + +--- + +## 5. record_type 매핑 + +```php +$recordTypeMap = [ + Tenant::class => 'tenant', + User::class => 'user', + Department::class => 'department', + Menu::class => 'menu', + Role::class => 'role', + Board::class => 'board', + Project::class => 'project', + Issue::class => 'issue', + Task::class => 'task', +]; +``` + +--- + +## 6. 복원 로직 흐름 + +``` +1. ArchivedRecord 조회 (batch_id 또는 id) +2. main_data에서 원본 데이터 추출 +3. 원본 테이블에 INSERT (새 ID 할당) +4. relations 복원 (ArchivedRecordRelation) +5. ArchivedRecord 삭제 +6. 트랜잭션 커밋 +``` + +--- + +## 7. 주의 사항 + +- **FK 제약**: 복원 시 관계 테이블 순서 중요 (부모 먼저) +- **ID 할당**: 복원 시 새 ID 할당 (original_id는 참조용) +- **tenant_id 무결성**: Multi-tenant 데이터 복원 시 tenant_id 검증 +- **트랜잭션**: 복원 실패 시 롤백 필수 + +--- + +## 8. Phase 2: 테넌트 필터링 기능 추가 (신규 요청) + +### 8.1 요청 내용 + +1. **대상 테넌트 필드 추가**: + - 테넌트 삭제 시: 어떤 테넌트인지 표시 + - 사용자 삭제 시: 어떤 테넌트 소속인지 표시 +2. **상단 테넌트 선택 필터링**: 현재 선택된 테넌트의 아카이브만 표시 + +### 8.2 현재 문제점 + +- `archived_records` 테이블에 `tenant_id` 컬럼 없음 +- 사용자 삭제 시 소속 테넌트 정보 저장 안 됨 +- 테넌트 선택 필터링 불가 + +### 8.3 해결 방안 + +#### 방안 A: tenant_id 컬럼 추가 (권장) +``` +장점: +- 직접 필터링 가능 (성능 좋음) +- 명확한 테넌트 소속 관계 +- 인덱스 활용 가능 + +단점: +- 마이그레이션 필요 +- 기존 데이터 처리 필요 (main_data에서 추출) +``` + +#### 방안 B: main_data에서 JSON 추출 (현재 방식) +``` +장점: +- DB 스키마 변경 없음 + +단점: +- JSON 추출 쿼리 복잡 +- 성능 저하 (인덱스 불가) +- 사용자의 경우 tenant_id가 main_data에 없을 수 있음 +``` + +### 8.4 권장 방안: A (tenant_id 컬럼 추가) + +#### 수정 대상 파일 + +| # | 저장소 | 파일 | 작업 | +|---|--------|------|------| +| 1 | **api/** | `database/migrations/2025_12_01_*_add_tenant_id_to_archived_records.php` | 신규 - DB 마이그레이션 | +| 2 | mng/ | `app/Services/ArchiveService.php` | 수정 - tenant_id 저장 로직 | +| 3 | mng/ | `app/Services/ArchivedRecordService.php` | 수정 - 테넌트 필터링 | +| 4 | mng/ | `app/Models/Archives/ArchivedRecord.php` | 수정 - fillable, 관계 추가 | +| 5 | mng/ | `resources/views/archived-records/partials/table.blade.php` | 수정 - 대상 테넌트 표시 | + +> **NOTE**: DB 마이그레이션은 `api/` 저장소에서 관리됨. mng/에서는 모델과 서비스만 수정. + +#### 마이그레이션 내용 (api/) +```php +// api/database/migrations/2025_12_01_*_add_tenant_id_to_archived_records.php +Schema::table('archived_records', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('record_type'); + $table->foreign('tenant_id')->references('id')->on('tenants')->nullOnDelete(); + $table->index('tenant_id'); +}); +``` + +#### tenant_id 결정 로직 +``` +- 테넌트 삭제: tenant_id = 삭제되는 테넌트의 ID (자기 자신) +- 사용자 삭제: tenant_id = session('selected_tenant_id') (현재 선택된 테넌트) +- 부서/메뉴/역할 삭제: tenant_id = 해당 레코드의 tenant_id +``` + +#### 기존 데이터 처리 +```sql +-- 테넌트 타입: main_data에서 id 추출 +UPDATE archived_records +SET tenant_id = JSON_UNQUOTE(JSON_EXTRACT(main_data, '$.id')) +WHERE record_type = 'tenant' AND tenant_id IS NULL; + +-- 사용자 타입: main_data에 tenant_id가 없으므로 NULL 유지 +-- (또는 user_tenants 관계에서 추출 - 복잡) +``` + +### 8.5 UI 변경 + +#### 목록 테이블 컬럼 +| 작업 설명 | 대상 테넌트 | 대상 정보 | 레코드 타입 | ... | + +#### 필터링 +- 상단 테넌트 선택 시 `session('selected_tenant_id')` 기준 필터링 +- 슈퍼관리자: 전체 보기 가능 +- 일반 관리자: 소속 테넌트만 보기 diff --git a/database/migrations/2025_11_30_144617_modify_archived_records_record_type_to_varchar.php b/database/migrations/2025_11_30_144617_modify_archived_records_record_type_to_varchar.php new file mode 100644 index 00000000..762c2cdb --- /dev/null +++ b/database/migrations/2025_11_30_144617_modify_archived_records_record_type_to_varchar.php @@ -0,0 +1,30 @@ +삭제된 데이터 백업 + + @if(session('success')) +
+
+ + + + {{ session('success') }} +
+
+ @endif + + @if(session('error')) +
+
+ + + + {{ session('error') }} +
+
+ @endif +
diff --git a/resources/views/archived-records/partials/table.blade.php b/resources/views/archived-records/partials/table.blade.php index d8ba8b33..a03cf554 100644 --- a/resources/views/archived-records/partials/table.blade.php +++ b/resources/views/archived-records/partials/table.blade.php @@ -2,25 +2,61 @@ - - - - - - + + + + + + + + + @forelse($records as $record) + @php + $types = explode(',', $record->record_types ?? ''); + $primaryType = $types[0] ?? ''; + // 대상 정보 결정 (테넌트 > 사용자) + $targetName = $record->target_company_name ?? $record->target_name ?? '-'; + $targetSub = ''; + if ($primaryType === 'tenant' && $record->target_code) { + $targetSub = "코드: {$record->target_code}"; + } elseif ($primaryType === 'user' && $record->target_email) { + $targetSub = $record->target_email; + } + // 대상 테넌트 조회 + $targetTenant = $record->tenant_id ? \App\Models\Tenants\Tenant::find($record->tenant_id) : null; + $targetTenantName = $targetTenant?->company_name ?? ($record->tenant_id ? "(삭제됨 ID: {$record->tenant_id})" : '-'); + @endphp - + + + @empty -
작업 설명레코드 타입레코드 수삭제자삭제일시작업ID작업 설명대상 테넌트대상 정보레코드 타입레코드 수삭제자삭제일시작업
+ + {{ $record->id }} +
{{ $record->batch_description ?? '삭제 작업' }}
-
ID: {{ Str::limit($record->batch_id, 8, '...') }}
+
+ @if($targetTenant) + + {{ $targetTenantName }} + + @elseif($record->tenant_id) + + 삭제됨 (ID: {{ $record->tenant_id }}) + + @else + - + @endif + +
{{ $targetName }}
+ @if($targetSub) +
{{ $targetSub }}
+ @endif
- @php - $types = explode(',', $record->record_types ?? ''); - @endphp
@foreach($types as $type) @if($type === 'tenant') @@ -60,7 +96,7 @@ class="text-blue-600 hover:text-blue-900 transition"
+
diff --git a/resources/views/archived-records/restore-check.blade.php b/resources/views/archived-records/restore-check.blade.php new file mode 100644 index 00000000..3a654461 --- /dev/null +++ b/resources/views/archived-records/restore-check.blade.php @@ -0,0 +1,172 @@ +@extends('layouts.app') + +@section('title', '데이터 복원 확인') + +@section('content') + +
+
+ + + + + +

데이터 복원 확인

+
+
+ + + @if($restoreCheck['can_restore']) +
+
+ + + +
+

복원 가능

+

이 데이터는 복원할 수 있습니다. 아래 내용을 확인 후 복원을 진행해 주세요.

+
+
+
+ @else +
+
+ + + +
+

복원 불가

+

다음 이유로 복원할 수 없습니다:

+
    + @foreach($restoreCheck['issues'] as $issue) +
  • {{ $issue }}
  • + @endforeach +
+
+
+
+ @endif + + +
+
+

복원할 데이터 요약

+
+
+
+
+
작업 설명
+
{{ $batchInfo['batch_description'] ?? '삭제 작업' }}
+
+
+
복원 대상 레코드 수
+
+ + {{ $restoreCheck['record_count'] ?? $batchInfo['record_count'] }}건 + +
+
+
+
삭제자
+
{{ $batchInfo['deleted_by'] }}
+
+
+
삭제일시
+
{{ $batchInfo['deleted_at']?->format('Y-m-d H:i:s') ?? '-' }}
+
+
+
+
+ + +
+
+

복원될 레코드 목록

+
+
+ + + + + + + + + + + + @foreach($records as $index => $record) + @php + $mainData = is_array($record->main_data) ? $record->main_data : json_decode($record->main_data, true); + $displayName = $mainData['name'] ?? $mainData['company_name'] ?? $mainData['title'] ?? $mainData['email'] ?? '-'; + @endphp + + + + + + + + @endforeach + +
#타입원본 ID주요 정보관련 데이터
{{ $index + 1 }} + + {{ $record->record_type_label }} + + {{ $record->original_id }}{{ $displayName }} + @if($record->relations->isNotEmpty()) + {{ $record->relations->count() }}개 테이블 + @else + - + @endif +
+
+
+ + +
+
+ + + +
+

복원 전 주의사항

+
    +
  • 복원된 데이터는 새로운 ID가 할당됩니다.
  • +
  • 테넌트 복원 시 관련 부서, 메뉴, 역할이 함께 복원됩니다.
  • +
  • 사용자 복원 시 테넌트 관계가 복원됩니다. (해당 테넌트가 존재하는 경우)
  • +
  • 복원 완료 후 이 아카이브 레코드는 삭제됩니다.
  • +
  • 복원 작업은 트랜잭션으로 처리되며, 오류 시 롤백됩니다.
  • +
+
+
+
+ + +
+ + 취소 + + @if($restoreCheck['can_restore']) + + @csrf + + + @endif +
+@endsection \ No newline at end of file diff --git a/resources/views/archived-records/show.blade.php b/resources/views/archived-records/show.blade.php index 26755a52..ad2c0d2c 100644 --- a/resources/views/archived-records/show.blade.php +++ b/resources/views/archived-records/show.blade.php @@ -14,8 +14,40 @@ class="text-gray-500 hover:text-gray-700 transition">

삭제된 데이터 상세

+ @if(auth()->user()?->is_super_admin) + + + + + 데이터 복원 + + @endif + + @if(session('success')) +
+
+ + + + {{ session('success') }} +
+
+ @endif + + @if(session('error')) +
+
+ + + + {{ session('error') }} +
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 705d0076..437e95b7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -104,6 +104,8 @@ Route::prefix('archived-records')->name('archived-records.')->group(function () { Route::get('/', [ArchivedRecordController::class, 'index'])->name('index'); Route::get('/{batchId}', [ArchivedRecordController::class, 'show'])->name('show'); + Route::get('/{batchId}/restore-check', [ArchivedRecordController::class, 'checkRestore'])->name('restore-check'); + Route::post('/{batchId}/restore', [ArchivedRecordController::class, 'restore'])->name('restore'); }); // 프로젝트 관리 (Blade 화면만)