feat: [archived-records] 아카이브 복원 기능 및 테넌트 필터링 구현
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 <noreply@anthropic.com>
This commit is contained in:
125
WORKFLOW_STATE.md
Normal file
125
WORKFLOW_STATE.md
Normal file
@@ -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`
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계: 삭제한 사용자
|
||||
*/
|
||||
|
||||
326
app/Services/ArchiveService.php
Normal file
326
app/Services/ArchiveService.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Archives\ArchivedRecord;
|
||||
use App\Models\Archives\ArchivedRecordRelation;
|
||||
use App\Models\Commons\Menu;
|
||||
use App\Models\Department;
|
||||
use App\Models\Role;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArchiveService
|
||||
{
|
||||
/**
|
||||
* 모델 클래스 → record_type 매핑
|
||||
*/
|
||||
private const RECORD_TYPE_MAP = [
|
||||
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',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
319
app/Services/RestoreService.php
Normal file
319
app/Services/RestoreService.php
Normal file
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Archives\ArchivedRecord;
|
||||
use App\Models\Archives\ArchivedRecordRelation;
|
||||
use App\Models\Commons\Menu;
|
||||
use App\Models\Department;
|
||||
use App\Models\Role;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RestoreService
|
||||
{
|
||||
/**
|
||||
* record_type → 모델 클래스 매핑
|
||||
*/
|
||||
private const MODEL_CLASS_MAP = [
|
||||
'tenant' => 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
262
claudedocs/archive-restore-feature-analysis.md
Normal file
262
claudedocs/archive-restore-feature-analysis.md
Normal file
@@ -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')` 기준 필터링
|
||||
- 슈퍼관리자: 전체 보기 가능
|
||||
- 일반 관리자: 소속 테넌트만 보기
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* record_type을 enum에서 varchar로 변경하여 확장성 확보
|
||||
* 기존 값: 'tenant', 'user'
|
||||
* 확장 예정: 'department', 'menu', 'role', 'board', 'project', 'issue', 'task' 등
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// MySQL에서 enum을 varchar로 변경
|
||||
// 기존 데이터는 그대로 유지됨
|
||||
DB::statement("ALTER TABLE archived_records MODIFY COLUMN record_type VARCHAR(50) NOT NULL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// varchar를 다시 enum으로 변경 (기존 값만)
|
||||
DB::statement("ALTER TABLE archived_records MODIFY COLUMN record_type ENUM('tenant', 'user') NOT NULL");
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,29 @@
|
||||
<h1 class="text-2xl font-bold text-gray-800">삭제된 데이터 백업</h1>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
@if(session('success'))
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-green-800">{{ session('success') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="text-red-800">{{ session('error') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form id="filterForm" class="flex flex-wrap gap-4">
|
||||
|
||||
@@ -2,25 +2,61 @@
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">작업 설명</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 120px;">레코드 타입</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 80px;">레코드 수</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 120px;">삭제자</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 160px;">삭제일시</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 80px;">작업</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 60px;">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">작업 설명</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 150px;">대상 테넌트</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 180px;">대상 정보</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 100px;">레코드 타입</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 70px;">레코드 수</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 100px;">삭제자</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 140px;">삭제일시</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider" style="width: 60px;">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@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
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<td class="px-4 py-4 text-center text-sm text-gray-500 font-mono">
|
||||
{{ $record->id }}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-900">
|
||||
<div class="font-medium">{{ $record->batch_description ?? '삭제 작업' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">ID: {{ Str::limit($record->batch_id, 8, '...') }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-900">
|
||||
@if($targetTenant)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{{ $targetTenantName }}
|
||||
</span>
|
||||
@elseif($record->tenant_id)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
|
||||
삭제됨 (ID: {{ $record->tenant_id }})
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="font-medium text-gray-900">{{ $targetName }}</div>
|
||||
@if($targetSub)
|
||||
<div class="text-xs text-gray-500 mt-0.5">{{ $targetSub }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@php
|
||||
$types = explode(',', $record->record_types ?? '');
|
||||
@endphp
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach($types as $type)
|
||||
@if($type === 'tenant')
|
||||
@@ -60,7 +96,7 @@ class="text-blue-600 hover:text-blue-900 transition"
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center text-gray-500">
|
||||
<td colspan="9" class="px-6 py-12 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
|
||||
172
resources/views/archived-records/restore-check.blade.php
Normal file
172
resources/views/archived-records/restore-check.blade.php
Normal file
@@ -0,0 +1,172 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '데이터 복원 확인')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{ route('archived-records.show', $batchInfo['batch_id']) }}"
|
||||
class="text-gray-500 hover:text-gray-700 transition">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">데이터 복원 확인</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복원 가능 여부 확인 -->
|
||||
@if($restoreCheck['can_restore'])
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-6 h-6 text-green-600 mr-3 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-800">복원 가능</h3>
|
||||
<p class="text-green-700 mt-1">이 데이터는 복원할 수 있습니다. 아래 내용을 확인 후 복원을 진행해 주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-6 h-6 text-red-600 mr-3 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-red-800">복원 불가</h3>
|
||||
<p class="text-red-700 mt-1">다음 이유로 복원할 수 없습니다:</p>
|
||||
<ul class="list-disc list-inside mt-2 text-red-700">
|
||||
@foreach($restoreCheck['issues'] as $issue)
|
||||
<li>{{ $issue }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 작업 요약 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800">복원할 데이터 요약</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">작업 설명</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-medium">{{ $batchInfo['batch_description'] ?? '삭제 작업' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">복원 대상 레코드 수</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ $restoreCheck['record_count'] ?? $batchInfo['record_count'] }}건
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">삭제자</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $batchInfo['deleted_by'] }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">삭제일시</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $batchInfo['deleted_at']?->format('Y-m-d H:i:s') ?? '-' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복원될 레코드 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800">복원될 레코드 목록</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">타입</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">원본 ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">주요 정보</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">관련 데이터</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@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
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $index + 1 }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{{ $record->record_type === 'tenant' ? 'bg-blue-100 text-blue-800' : '' }}
|
||||
{{ $record->record_type === 'user' ? 'bg-green-100 text-green-800' : '' }}
|
||||
{{ $record->record_type === 'department' ? 'bg-yellow-100 text-yellow-800' : '' }}
|
||||
{{ $record->record_type === 'role' ? 'bg-purple-100 text-purple-800' : '' }}
|
||||
{{ $record->record_type === 'menu' ? 'bg-pink-100 text-pink-800' : '' }}
|
||||
">
|
||||
{{ $record->record_type_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $record->original_id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">{{ $displayName }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@if($record->relations->isNotEmpty())
|
||||
{{ $record->relations->count() }}개 테이블
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 복원 주의사항 -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-6 h-6 text-yellow-600 mr-3 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-yellow-800">복원 전 주의사항</h3>
|
||||
<ul class="list-disc list-inside mt-2 text-yellow-700 space-y-1">
|
||||
<li>복원된 데이터는 <strong>새로운 ID</strong>가 할당됩니다.</li>
|
||||
<li>테넌트 복원 시 관련 부서, 메뉴, 역할이 함께 복원됩니다.</li>
|
||||
<li>사용자 복원 시 테넌트 관계가 복원됩니다. (해당 테넌트가 존재하는 경우)</li>
|
||||
<li>복원 완료 후 이 아카이브 레코드는 삭제됩니다.</li>
|
||||
<li>복원 작업은 트랜잭션으로 처리되며, 오류 시 롤백됩니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex justify-end gap-4">
|
||||
<a href="{{ route('archived-records.show', $batchInfo['batch_id']) }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
@if($restoreCheck['can_restore'])
|
||||
<form action="{{ route('archived-records.restore', $batchInfo['batch_id']) }}" method="POST"
|
||||
onsubmit="return confirm('정말로 이 데이터를 복원하시겠습니까?\n\n복원된 데이터는 새로운 ID가 할당되며, 아카이브 레코드는 삭제됩니다.');">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium text-sm rounded-lg transition">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
복원 실행
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -14,8 +14,40 @@ class="text-gray-500 hover:text-gray-700 transition">
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">삭제된 데이터 상세</h1>
|
||||
</div>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<a href="{{ route('archived-records.restore-check', $batchInfo['batch_id']) }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium text-sm rounded-lg transition">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
데이터 복원
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
@if(session('success'))
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-green-800">{{ session('success') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="text-red-800">{{ session('error') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 작업 요약 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
|
||||
@@ -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 화면만)
|
||||
|
||||
Reference in New Issue
Block a user