승인 단계 삭제 복구 흐름 구현하고 API 정렬 문서 추가

This commit is contained in:
JiWoong Sul
2025-10-01 15:51:01 +09:00
parent 5578bf443f
commit 67fc319c3c
16 changed files with 671 additions and 38 deletions

View File

@@ -21,6 +21,7 @@ Commits follow the existing Superport convention: Korean imperative summaries wi
## Architecture & Environment Notes
Initialize environments via `.env.development` / `.env.production` and load them through `Environment.initialize()` before bootstrapping DI. New data sources should expose repository interfaces in `domain/` and rely on the shared `ApiClient` instance. Do not use mock data in the application; always call the real backend (staging/production as appropriate). If an endpoint is not available, mark the feature as disabled behind a feature flag rather than mocking.
- Frontend behaviour/data models must strictly follow the deployed backend contract. (프론트엔드는 백엔드 API 계약을 절대 우선으로 준수해야 하며, 누락된 기능은 백엔드 수정 요청 후 진행한다.)
---
@@ -34,6 +35,12 @@ Initialize environments via `.env.development` / `.env.production` and load them
---
## Notification Policy
- Every task completion must trigger a notification via the configured `notify.py` workflow so users are consistently alerted.
---
## SRP & Clean Architecture Enforcement
Apply these principles repo-wide. Use the checklist below during reviews.

View File

@@ -0,0 +1,56 @@
# 백엔드 수정 요청서 (Master/Transaction API 확장)
## 1. 배경
- 프론트엔드에서 인벤토리/승인 플로우를 실데이터에 맞춰 구현하기 위해서는 백엔드가 스펙(`stock_approval_system_api_v4.md`)상의 모든 마스터와 재고 트랜잭션 API를 제공해야 한다.
- 현 시점에는 `vendors`, `uoms`, `transaction_types`, `transaction_statuses`, `approval_statuses`, `approval_actions`, `warehouses` 엔드포인트까지만 구현되어 있으며, Flutter 화면은 아직 mock 데이터를 사용 중이다.
- 백엔드 코드를 직접 수정할 수 없는 상황이므로, 필요한 변경 사항을 명확히 정리해 전담 팀/담당자에게 전달한다.
## 2. 요청 범위
### 2.1 기본 경로 정렬
- 모든 REST 엔드포인트는 `/api/v1` prefix 하위로 노출되어야 하며, 기존 구현(vendors/uoms/transaction-types/transaction-statuses/approval-statuses/approval-actions/warehouses)도 동일한 경로를 유지해야 한다.
- OpenAPI/스펙 문서에는 버전 프리픽스가 명확히 표기되어야 하며, 변경 시 프론트엔드가 사용하는 베이스 URL(`Environment.baseUrl`)과 일치하도록 공지한다.
### 2.2 마스터 데이터 API 확대
- 대상 테이블: `customers`, `products`, `employees`, `groups`, `menus`, `group_menu_permissions`, `approval_templates`, `approval_steps`(정의), `zipcodes` 검색용 API 등
- 요구 사항
- `/api/v1/<resource>` 패턴으로 목록/상세/생성/수정/삭제/복구 CRUD 일관성 유지
- 목록 API는 검색(q), 활성/비활성 필터, soft-delete 필터, 정렬(sort/order), 페이지네이션(page/page_size) 지원
- 관계형 데이터는 `find_also_related` 패턴으로 DTO에 포함 (예: 고객→zipcode, 그룹→permissions, 직원→group)
- 프론트엔드 Remote Repository(`lib/features/masters/**/data/repositories`)와 엔티티 스키마를 맞추기 위해 스펙 필드명 그대로 응답
### 2.3 결재(Approval) 도메인 확장
- 리소스: `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates`
- 요구 사항
- 리스트/상세 API는 `include=steps,histories` 등 프론트가 사용하는 확장 파라미터를 지원해야 한다.
- 단계 배정(`POST /approvals/{id}/steps`), 단계 재배치(`PATCH /approvals/{id}/steps`), 단계 액션 수행(`POST /approval-steps/{id}/actions`)을 스펙대로 구현
- 승인 가능 여부 조회(`GET /approvals/{id}/can-proceed`) 및 복구(`/approvals/{id}/restore`) 포함
- 응답에는 Domain DTO(`ApprovalDto`, `ApprovalActionDto` 등)에서 필요로 하는 필드가 누락되지 않도록 검증
### 2.4 재고 트랜잭션 API 설계 및 구현
- 리소스: `stock_transactions` (입고/출고/대여), `transaction_lines`, `transaction_approvals` 등 스펙 정의 테이블
- 요구 사항
- 목록 필터: 상태, 창고, 고객/거래처, 기간(처리일/반납예정일 등), 포함(include=lines, approval_history 등)
- 상세 응답: 헤더 정보 + 라인아이템 + 승인 이력/로그 전달
- 상태 전이/승인 플로우 API (`submit`, `approve`, `reject`, `cancel`)와 재고 처리 결과 반영
- soft-delete 및 복구 정책 정의 (필요 시 논의)
- SeaORM 트랜잭션을 이용해 헤더/라인/로그 동시 저장
### 2.5 공통 고려사항
- DTO/응답 구조는 `stock_approval_system_api_v4.md`와 동기화하고, 변경 시 문서도 업데이트
- `script/run_api_tests.sh`에 각 리소스의 CRUD 및 상태 전이 스텝을 추가해 회귀 테스트 가능하도록 보완
- 샘플 데이터(`migration/002_sample_data.sql`)는 필수 참조 데이터만 유지하고, 대량 더미는 옵션 플래그로 분리
## 3. 선행 작업 및 의존성
- 데이터 모델 검증: 스펙과 현재 DB 스키마 일치 여부 확인, 필요한 경우 추가 마이그레이션 작성
- 인증/권한: 그룹-메뉴 권한 매핑을 API 보호 미들웨어에 적용 (추후 프론트엔드 권한 제어와 연동)
- 로깅/관측성: 주요 재고 트랜잭션 이벤트를 tracing 로그로 남겨 운영 대응
## 4. 수용 기준 (Acceptance Criteria)
- 모든 신규/확장된 엔드포인트에 대해 Actix 라우트, 도메인 DTO, SeaORM 리포지토리, 에러 매핑이 완비되어야 한다.
- `cargo check`, `cargo test`, `script/run_api_tests.sh`가 통과해야 하며, 샘플 DB로 기본 CRUD 시나리오가 동작할 것.
- README `Next Steps` 섹션 업데이트와 변경된 API 스펙 커밋이 포함되어야 한다.
## 5. 후속 조치
- 본 문서 확인 후 백엔드 담당자가 작업 범위/일정을 산출
- 작업 완료 시 프론트엔드 팀에 API mock 제거 및 실연동 착수 일정 공유
- 필요 시 추가 논의 사항을 본 문서 하단에 코멘트 형태로 기록

View File

@@ -0,0 +1,40 @@
# Frontend API Alignment Plan
## 1. 현황 요약
- Environment `API_BASE_URL` 기본값은 `http://localhost:8080`이며 버전 prefix(`/api/v1`)는 포함되어 있지 않다.
- Master/Approval/Inventory 모듈 대부분이 `_basePath = '/<resource>'`로 정의되어 있어 실제 백엔드(`/api/v1/...`)와 경로가 불일치한다.
- 입고/출고/대여 화면은 `_mockRecords`, `Inventory*Catalog` 등 정적 데이터를 사용하고 있으며, 서버 응답 모델과 연결되어 있지 않다.
- 승인 도메인(approvals/steps/histories/templates)은 Repository/DTO가 준비되어 있으나 백엔드 미구현 상태라 기능 플래그로 비활성화되어 있다.
## 2. 즉시 처리 가능한 정렬 작업
1. **공통 경로 상수화**
- `lib/core/network/api_client.dart` 또는 별도 헬퍼에 `const apiV1 = '/api/v1';` 정의.
- 모든 Remote Repository의 `_basePath` 앞에 `${ApiRoutes.apiV1}` prefix 적용.
- `WarehouseRepositoryRemote`, `VendorRepositoryRemote`, `UomRepositoryRemote`, `TransactionTypeRepositoryRemote`, `TransactionStatusRepositoryRemote`, `ApprovalStatus/Action` 등 전체 점검.
2. **DI/환경 값 점검**
- `.env.*` 예시 파일에 `API_BASE_URL` 주석으로 "버전 prefix 없음" 명시.
- 필요 시 `Environment.baseUrl``/api/v1`를 포함한 값을 직접 지정해도 되지만, 기존 백엔드 관례에 맞춰 prefix 상수 사용 권장.
3. **기 구현 백엔드 연동**
- 벤더/단위/거래유형/거래상태/결재상태/결재행위/창고 화면에서 `Feature_*` 플래그를 통해 API 호출 활성화.
- 응답 파싱 결과가 UI에 반영되는지 확인하고, 로딩/에러 핸들러 추가.
4. **테스트/검증**
- `flutter analyze`, `flutter test` 실행.
- 가짜 데이터 비활성화 후 빈 응답 대비 UI(Empty state) 정상 동작 확인.
## 3. 백엔드 작업 의존 영역
1. **고객/제품/직원/권한 등 마스터 확장**
- 백엔드 `/api/v1/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions` 구현 후 Remote Repository 활성화.
- DTO 매핑 검증 및 리스트/폼 화면에 실데이터 연동.
2. **승인(Approvals) 플로우**
- `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates` 엔드포인트 제공 시 기능 플래그 해제.
- Step assign/action API 응답 구조가 `ApprovalDto` 기대 필드(steps, actions, histories)를 포함하는지 확인.
3. **재고 트랜잭션 (입고/출고/대여)**
- `/stock-transactions` 및 관련 라인/승인 API가 준비되면 `_mockRecords`, `Inventory*Catalog` 제거.
- 목록 필터/페이지네이션을 API Query와 동기화하고, 상세 모달 입력/수정 흐름을 서버 모델로 전환.
4. **우편번호 검색**
- `/zipcodes`(검색) API 구현 시 `PostalSearchRepositoryRemote` 경로와 파라미터를 확인하고 UI를 연동.
## 4. 릴리즈 절차
- 기능별 Feature Flag를 단계적으로 해제하면서 QA 진행.
- 각 단계에서 `script/run_api_tests.sh`로 백엔드 검증 → 프론트 `flutter test` → 수동 시연 순으로 검증 체계 유지.
- API 변경 사항은 `CHANGELOG``doc/backend_change_requests.md`와 동기화해 추후 회고/인수에 활용.

View File

@@ -194,6 +194,7 @@ class ApprovalStepDto {
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
});
final int? id;
@@ -203,6 +204,7 @@ class ApprovalStepDto {
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
return ApprovalStepDto(
@@ -217,6 +219,10 @@ class ApprovalStepDto {
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
decidedAt: _parseDate(json['decided_at']),
note: json['note'] as String?,
isDeleted:
json['is_deleted'] as bool? ??
(json['deleted_at'] != null ||
(json['is_active'] is bool && !(json['is_active'] as bool))),
);
}
@@ -229,6 +235,7 @@ class ApprovalStepDto {
assignedAt: assignedAt,
decidedAt: decidedAt,
note: note,
isDeleted: isDeleted,
);
}

View File

@@ -103,6 +103,7 @@ class ApprovalStep {
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
});
final int? id;
@@ -112,6 +113,29 @@ class ApprovalStep {
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
ApprovalStep copyWith({
int? id,
int? stepOrder,
ApprovalApprover? approver,
ApprovalStatus? status,
DateTime? assignedAt,
DateTime? decidedAt,
String? note,
bool? isDeleted,
}) {
return ApprovalStep(
id: id ?? this.id,
stepOrder: stepOrder ?? this.stepOrder,
approver: approver ?? this.approver,
status: status ?? this.status,
assignedAt: assignedAt ?? this.assignedAt,
decidedAt: decidedAt ?? this.decidedAt,
note: note ?? this.note,
isDeleted: isDeleted ?? this.isDeleted,
);
}
}
class ApprovalApprover {

View File

@@ -173,7 +173,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
actions: [
ShadButton(
leading: const Icon(lucide.LucideIcons.plus, size: 16),
onPressed: () {},
onPressed: _openCreateApprovalDialog,
child: const Text('신규 결재'),
),
],
@@ -357,6 +357,116 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
);
}
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
Future<void> _openCreateApprovalDialog() async {
final transactionController = TextEditingController();
final noteController = TextEditingController();
var submitted = false;
final shouldShowToast = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final shadTheme = ShadTheme.of(context);
final errorVisible =
submitted && transactionController.text.trim().isEmpty;
return SuperportDialog(
title: '신규 결재 등록',
description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.',
constraints: const BoxConstraints(maxWidth: 480),
actions: [
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('닫기'),
),
ShadButton(
key: const ValueKey('approval_create_submit'),
onPressed: () {
final trimmed = transactionController.text.trim();
setState(() => submitted = true);
if (trimmed.isEmpty) {
return;
}
Navigator.of(dialogContext).pop(true);
},
child: const Text('임시 저장'),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
key: const ValueKey('approval_create_transaction'),
controller: transactionController,
placeholder: const Text('예: 2404-TRX-001'),
onChanged: (_) => setState(() {}),
),
if (errorVisible)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'트랜잭션 ID를 입력해야 결재 생성이 가능합니다.',
style: shadTheme.textTheme.small.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('비고 (선택)', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_create_note'),
controller: noteController,
minHeight: 120,
maxHeight: 220,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: shadTheme.colorScheme.mutedForeground.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'API 연동 준비 중',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.',
),
],
),
),
],
),
);
},
);
},
);
transactionController.dispose();
noteController.dispose();
if (shouldShowToast == true && mounted) {
SuperportToast.info(
context,
'결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.',
);
}
}
void _applyFilters() {
_controller.updateQuery(_searchController.text.trim());
if (_dateRange != null) {

View File

@@ -82,4 +82,24 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
(raw is Map<String, dynamic> ? raw : const <String, dynamic>{});
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
/// 결재 단계를 비활성화한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
/// 비활성화된 결재 단계를 복구한다.
@override
Future<ApprovalStepRecord> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
options: Options(responseType: ResponseType.json),
);
final raw = response.data;
final data =
(raw?['data'] as Map<String, dynamic>?) ??
(raw is Map<String, dynamic> ? raw : const <String, dynamic>{});
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
}

View File

@@ -23,4 +23,10 @@ abstract class ApprovalStepRepository {
/// 결재 단계를 수정한다.
Future<ApprovalStepRecord> update(int id, ApprovalStepInput input);
/// 결재 단계를 삭제(비활성화)한다.
Future<void> delete(int id);
/// 삭제된 결재 단계를 복구한다.
Future<ApprovalStepRecord> restore(int id);
}

View File

@@ -158,4 +158,64 @@ class ApprovalStepController extends ChangeNotifier {
notifyListeners();
}
}
/// 결재 단계를 삭제(비활성화)하고 목록 상태를 반영한다.
Future<bool> deleteStep(int id) async {
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
await _repository.delete(id);
if (_result != null) {
final items = _result!.items
.map((record) {
final stepId = record.step.id;
if (stepId != null && stepId == id) {
return record.copyWith(
step: record.step.copyWith(isDeleted: true),
);
}
return record;
})
.toList(growable: false);
_result = _result!.copyWith(items: items);
}
return true;
} catch (e) {
_errorMessage = e.toString();
return false;
} finally {
_isSaving = false;
notifyListeners();
}
}
/// 삭제된 결재 단계를 복구하고 최신 데이터를 반환한다.
Future<ApprovalStepRecord?> restoreStep(int id) async {
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
final record = await _repository.restore(id);
if (_result != null) {
final items = _result!.items
.map((item) {
final stepId = item.step.id;
if (stepId != null && stepId == id) {
return record;
}
return item;
})
.toList(growable: false);
_result = _result!.copyWith(items: items);
}
return record;
} catch (e) {
_errorMessage = e.toString();
return null;
} finally {
_isSaving = false;
notifyListeners();
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/constants/app_sections.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/components/superport_dialog.dart';
@@ -14,6 +15,8 @@ import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
const String _stepResourcePath = '/approvals/steps';
/// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다.
class ApprovalStepPage extends StatelessWidget {
const ApprovalStepPage({super.key});
@@ -132,19 +135,23 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
AppBreadcrumbItem(label: '결재 단계'),
],
actions: [
ShadButton(
key: const ValueKey('approval_step_create'),
onPressed: (_controller.isLoading || isSaving)
? null
: _openCreateStepForm,
leading: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(lucide.LucideIcons.plus, size: 16),
child: Text(isSaving ? '저장 중...' : '단계 추가'),
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.create,
child: ShadButton(
key: const ValueKey('approval_step_create'),
onPressed: (_controller.isLoading || isSaving)
? null
: _openCreateStepForm,
leading: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(lucide.LucideIcons.plus, size: 16),
child: Text(isSaving ? '저장 중...' : '단계 추가'),
),
),
],
toolbar: FilterBar(
@@ -287,6 +294,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
},
children: records.map((record) {
final step = record.step;
final isDeleted = step.isDeleted;
return [
ShadTableCell(
child: Text(step.id?.toString() ?? '-'),
@@ -313,30 +321,78 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
child: Wrap(
spacing: 8,
children: [
ShadButton.outline(
key: ValueKey(
'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
step.id == null ||
_controller.isLoading ||
isSaving
? null
: () => _openDetail(record),
child: const Text('상세'),
),
if (step.id != null)
ShadButton(
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.view,
child: ShadButton.outline(
key: ValueKey(
'step_edit_${step.id}_${step.stepOrder}',
'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || isSaving
step.id == null ||
_controller.isLoading ||
isSaving
? null
: () => _openEditStepForm(record),
child: const Text('수정'),
: () => _openDetail(record),
child: const Text('상세'),
),
),
if (step.id != null && !isDeleted)
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.edit,
child: ShadButton(
key: ValueKey(
'step_edit_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading ||
isSaving
? null
: () =>
_openEditStepForm(record),
child: const Text('수정'),
),
),
if (step.id != null && !isDeleted)
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.delete,
child: ShadButton.destructive(
key: ValueKey(
'step_delete_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading ||
isSaving
? null
: () => _confirmDeleteStep(
record,
),
child: const Text('삭제'),
),
),
if (step.id != null && isDeleted)
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.restore,
child: ShadButton.outline(
key: ValueKey(
'step_restore_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading ||
isSaving
? null
: () => _confirmRestoreStep(
record,
),
child: const Text('복구'),
),
),
],
),
@@ -554,6 +610,92 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
);
}
Future<void> _confirmDeleteStep(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장되지 않은 단계는 삭제할 수 없습니다.')));
return;
}
final confirmed = await SuperportDialog.show<bool>(
context: context,
dialog: SuperportDialog(
title: '결재 단계 삭제',
description:
'결재번호 ${record.approvalNo}${record.step.stepOrder}단계를 삭제하시겠습니까? 삭제 후 복구할 수 있습니다.',
actions: [
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ShadButton.destructive(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('삭제'),
),
],
),
);
if (confirmed != true) {
return;
}
final success = await _controller.deleteStep(stepId);
if (!mounted) {
return;
}
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${record.approvalNo} 단계가 삭제되었습니다.')),
);
}
}
Future<void> _confirmRestoreStep(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('단계 식별자가 없어 복구할 수 없습니다.')));
return;
}
final confirmed = await SuperportDialog.show<bool>(
context: context,
dialog: SuperportDialog(
title: '결재 단계 복구',
description:
'결재번호 ${record.approvalNo}${record.step.stepOrder}단계를 복구하시겠습니까?',
actions: [
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ShadButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('복구'),
),
],
),
);
if (confirmed != true) {
return;
}
final restored = await _controller.restoreStep(stepId);
if (!mounted) {
return;
}
if (restored != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${restored.approvalNo} 단계가 복구되었습니다.')),
);
}
}
String _formatDate(DateTime date) {
return _dateFormat.format(date.toLocal());
}

View File

@@ -221,4 +221,70 @@ void main() {
expect(result, isNull);
expect(controller.errorMessage, isNotNull);
});
test('deleteStep 성공 시 단계가 비활성화된다', () async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
statusId: any(named: 'statusId'),
approverId: any(named: 'approverId'),
approvalId: any(named: 'approvalId'),
),
).thenAnswer((_) async => createResult([sampleRecord]));
when(() => repository.delete(any())).thenAnswer((_) async {});
await controller.fetch();
final success = await controller.deleteStep(sampleRecord.step.id!);
expect(success, isTrue);
expect(controller.result?.items.first.step.isDeleted, isTrue);
});
test('deleteStep 실패 시 false를 반환하고 에러를 기록한다', () async {
when(() => repository.delete(any())).thenThrow(Exception('fail'));
final success = await controller.deleteStep(999);
expect(success, isFalse);
expect(controller.errorMessage, isNotNull);
});
test('restoreStep 성공 시 단계가 활성화된다', () async {
final deletedRecord = sampleRecord.copyWith(
step: sampleRecord.step.copyWith(isDeleted: true),
);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
statusId: any(named: 'statusId'),
approverId: any(named: 'approverId'),
approvalId: any(named: 'approvalId'),
),
).thenAnswer((_) async => createResult([deletedRecord]));
when(() => repository.restore(any())).thenAnswer((_) async => sampleRecord);
await controller.fetch();
final restored = await controller.restoreStep(sampleRecord.step.id!);
expect(restored, isNotNull);
expect(controller.result?.items.first.step.isDeleted, isFalse);
});
test('restoreStep 실패 시 null을 반환하고 에러를 기록한다', () async {
when(() => repository.restore(any())).thenThrow(Exception('fail'));
final restored = await controller.restoreStep(100);
expect(restored, isNull);
expect(controller.errorMessage, isNotNull);
});
}

View File

@@ -6,6 +6,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart';
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart';
@@ -16,13 +17,19 @@ class _MockApprovalStepRepository extends Mock
implements ApprovalStepRepository {}
Widget _buildApp(Widget child) {
final manager = PermissionManager(
overrides: {'/approvals/steps': PermissionAction.values.toSet()},
);
return MaterialApp(
home: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
home: PermissionScope(
manager: manager,
child: ShadTheme(
data: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
child: Scaffold(body: child),
),
child: Scaffold(body: child),
),
);
}
@@ -263,4 +270,92 @@ void main() {
findsOneWidget,
);
});
testWidgets('삭제 버튼 확인 후 저장소 삭제를 호출한다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
repository = _MockApprovalStepRepository();
GetIt.I.registerLazySingleton<ApprovalStepRepository>(() => repository);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
statusId: any(named: 'statusId'),
approverId: any(named: 'approverId'),
approvalId: any(named: 'approvalId'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalStepRecord>(
items: [record],
page: 1,
pageSize: 20,
total: 1,
),
);
when(() => repository.delete(any())).thenAnswer((_) async {});
await tester.pumpWidget(_buildApp(const ApprovalStepPage()));
await tester.pump();
await tester.pumpAndSettle();
final deleteFinder = find.byKey(const ValueKey('step_delete_501_1'));
final deleteButton = tester.widget<ShadButton>(deleteFinder);
deleteButton.onPressed?.call();
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '삭제').last);
await tester.pump();
await tester.pumpAndSettle();
verify(() => repository.delete(501)).called(1);
});
testWidgets('복구 버튼 확인 후 저장소 복구를 호출한다', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
repository = _MockApprovalStepRepository();
GetIt.I.registerLazySingleton<ApprovalStepRepository>(() => repository);
final deletedRecord = record.copyWith(
step: record.step.copyWith(isDeleted: true),
);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
statusId: any(named: 'statusId'),
approverId: any(named: 'approverId'),
approvalId: any(named: 'approvalId'),
),
).thenAnswer(
(_) async => PaginatedResult<ApprovalStepRecord>(
items: [deletedRecord],
page: 1,
pageSize: 20,
total: 1,
),
);
when(() => repository.restore(any())).thenAnswer((_) async => record);
await tester.pumpWidget(_buildApp(const ApprovalStepPage()));
await tester.pump();
await tester.pumpAndSettle();
final restoreFinder = find.byKey(const ValueKey('step_restore_501_1'));
final restoreButton = tester.widget<ShadButton>(restoreFinder);
restoreButton.onPressed?.call();
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ShadButton, '복구').last);
await tester.pump();
await tester.pumpAndSettle();
verify(() => repository.restore(501)).called(1);
});
}