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:
2025-12-01 00:43:58 +09:00
parent 023b199313
commit 6b40362392
14 changed files with 1452 additions and 24 deletions

125
WORKFLOW_STATE.md Normal file
View 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`

View File

@@ -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());
}
}
}

View File

@@ -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');
}
/**
* 관계: 삭제한 사용자
*/

View 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'),
};
}
}

View File

@@ -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"))

View 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(),
];
}
}

View File

@@ -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();
});
}
/**

View 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')` 기준 필터링
- 슈퍼관리자: 전체 보기 가능
- 일반 관리자: 소속 테넌트만 보기

View File

@@ -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");
}
};

View File

@@ -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">

View File

@@ -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" />

View 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

View File

@@ -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">

View File

@@ -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 화면만)