feat(approvals): Approval Flow v2 프런트엔드 전면 개편

- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
This commit is contained in:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -0,0 +1,561 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/approve_approval_use_case.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
void main() {
const runFlow = bool.fromEnvironment('STAGING_RUN_APPROVAL_FLOW');
const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_APPROVAL_FLOW');
if (!runFlow) {
testWidgets(
'approval flow e2e (환경 변수 설정 필요: STAGING_RUN_APPROVAL_FLOW=true)',
(tester) async {
tester.printToConsole(
'통합 테스트를 실행하려면 STAGING_RUN_APPROVAL_FLOW=true 를 설정하세요.',
);
},
skip: true,
);
return;
}
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final missingConfigs = <String>[];
if (!useFakeFlow) {
missingConfigs.add('STAGING_USE_FAKE_APPROVAL_FLOW=true');
}
if (missingConfigs.isNotEmpty) {
testWidgets(
'approval flow e2e (환경 변수 설정 필요: ${missingConfigs.join(', ')})',
(tester) async {
tester.printToConsole(
'결재 통합 테스트를 실행하려면 다음 변수를 설정하세요: ${missingConfigs.join(', ')}',
);
},
skip: true,
);
return;
}
testWidgets('inventory inbound → approval submit → approver hand-off', (
tester,
) async {
const transactionTypeId = 501;
const transactionStatusId = 10;
const warehouseId = 30;
const requesterId = 91;
const firstApproverId = 201;
const secondApproverId = 202;
final stockRepository = _FakeStockTransactionRepository(
transactionTypeId: transactionTypeId,
initialStatusId: transactionStatusId,
warehouseId: warehouseId,
employeeId: requesterId,
);
final approvalRepository = _FakeApprovalRepository();
final templateRepository = _FakeApprovalTemplateRepository();
final controller = ApprovalController(
approvalRepository: approvalRepository,
templateRepository: templateRepository,
);
final approveUseCase = ApproveApprovalUseCase(
repository: approvalRepository,
);
final now = DateTime.now();
final transactionInput = StockTransactionCreateInput(
transactionTypeId: transactionTypeId,
transactionStatusId: transactionStatusId,
warehouseId: warehouseId,
transactionDate: now,
createdById: requesterId,
note: 'integration-test ${now.toIso8601String()}',
lines: [
TransactionLineCreateInput(
lineNo: 1,
productId: 7001,
quantity: 5,
unitPrice: 1200,
),
],
customers: [TransactionCustomerCreateInput(customerId: 4001)],
approval: StockTransactionApprovalInput(
requestedById: requesterId,
steps: [
ApprovalStepAssignmentItem(stepOrder: 1, approverId: firstApproverId),
ApprovalStepAssignmentItem(
stepOrder: 2,
approverId: secondApproverId,
),
],
note: '입고 결재 테스트',
),
);
final createdTransaction = await stockRepository.create(transactionInput);
expect(createdTransaction.id, isNotNull);
tester.printToConsole('created transaction: ${createdTransaction.id}');
final approvalSubmission = ApprovalSubmissionInput(
transactionId: createdTransaction.id,
statusId: 1,
requesterId: requesterId,
note: transactionInput.approval.note,
steps: transactionInput.approval.steps,
);
final submittedApproval = await approvalRepository.submit(
approvalSubmission,
);
expect(submittedApproval.id, isNotNull);
tester.printToConsole('submitted approval: ${submittedApproval.id}');
await controller.fetch();
final approvals = controller.result?.items ?? const [];
expect(approvals, isNotEmpty);
expect(approvals.first.id, submittedApproval.id);
await controller.selectApproval(submittedApproval.id!);
expect(controller.selected?.currentStep?.stepOrder, 1);
final firstFlow = await approveUseCase(
ApprovalDecisionInput(
approvalId: submittedApproval.id!,
actorId: firstApproverId,
),
);
expect(firstFlow.currentStep?.stepOrder, 2);
tester.printToConsole('first approval completed, moved to step 2');
await controller.selectApproval(firstFlow.id!);
expect(controller.selected?.currentStep?.stepOrder, 2);
expect(controller.canProceedSelected, isTrue);
final finalFlow = await approveUseCase(
ApprovalDecisionInput(
approvalId: firstFlow.id!,
actorId: secondApproverId,
),
);
expect(finalFlow.currentStep, isNull);
expect(finalFlow.status.isTerminal, isTrue);
tester.printToConsole('approval completed by final approver');
await controller.selectApproval(finalFlow.id!);
expect(controller.selected?.status.isTerminal, isTrue);
expect(controller.canProceedSelected, isFalse);
});
}
class _FakeStockTransactionRepository implements StockTransactionRepository {
_FakeStockTransactionRepository({
required this.transactionTypeId,
required this.initialStatusId,
required this.warehouseId,
required this.employeeId,
});
final int transactionTypeId;
final int initialStatusId;
final int warehouseId;
final int employeeId;
int _sequence = 1;
final Map<int, StockTransaction> _transactions = {};
@override
Future<StockTransaction> create(StockTransactionCreateInput input) async {
final id = _sequence++;
final transaction = StockTransaction(
id: id,
transactionNo: 'TRX-${id.toString().padLeft(6, '0')}',
transactionDate: input.transactionDate,
type: StockTransactionType(id: transactionTypeId, name: '입고'),
status: StockTransactionStatus(id: initialStatusId, name: '작성중'),
warehouse: StockTransactionWarehouse(
id: warehouseId,
code: 'WH-$warehouseId',
name: '테스트 창고',
),
createdBy: StockTransactionEmployee(
id: employeeId,
employeeNo: 'EMP-$employeeId',
name: '작성자',
),
note: input.note,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
lines: input.lines
.map(
(line) => StockTransactionLine(
id: line.lineNo,
lineNo: line.lineNo,
product: StockTransactionProduct(
id: line.productId,
code: 'PRD-${line.productId}',
name: '테스트 품목',
),
quantity: line.quantity,
unitPrice: line.unitPrice,
note: line.note,
),
)
.toList(growable: false),
customers: input.customers
.map(
(customer) => StockTransactionCustomer(
id: customer.customerId,
customer: StockTransactionCustomerSummary(
id: customer.customerId,
code: 'CUST-${customer.customerId}',
name: '거래처',
),
note: customer.note,
),
)
.toList(growable: false),
);
_transactions[id] = transaction;
return transaction;
}
@override
Future<StockTransaction> submit(int id) async {
final transaction = _require(id);
final updated = transaction.copyWith(
status: StockTransactionStatus(id: transaction.status.id, name: '제출'),
updatedAt: DateTime.now(),
);
_transactions[id] = updated;
return updated;
}
StockTransaction _require(int id) {
final transaction = _transactions[id];
if (transaction == null) {
throw StateError('트랜잭션($id)을 찾을 수 없습니다.');
}
return transaction;
}
@override
Future<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
}) async {
return PaginatedResult(
items: _transactions.values.toList(growable: false),
page: 1,
pageSize: _transactions.length,
total: _transactions.length,
);
}
@override
Future<StockTransaction> fetchDetail(int id, {List<String>? include}) async {
return _require(id);
}
@override
Future<StockTransaction> update(int id, StockTransactionUpdateInput input) =>
throw UnimplementedError();
@override
Future<void> delete(int id) => throw UnimplementedError();
@override
Future<StockTransaction> restore(int id) => throw UnimplementedError();
@override
Future<StockTransaction> complete(int id) => throw UnimplementedError();
@override
Future<StockTransaction> approve(int id) => throw UnimplementedError();
@override
Future<StockTransaction> reject(int id) => throw UnimplementedError();
@override
Future<StockTransaction> cancel(int id) => throw UnimplementedError();
}
class _FakeApprovalRepository implements ApprovalRepository {
int _sequence = 1000;
final Map<int, Approval> _approvals = {};
@override
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
int? transactionId,
int? approvalStatusId,
int? requestedById,
List<String>? statusCodes,
bool includePending = false,
bool includeHistories = false,
bool includeSteps = false,
}) async {
final items = _approvals.values.toList(growable: false);
return PaginatedResult(
items: items,
page: 1,
pageSize: items.length,
total: items.length,
);
}
@override
Future<Approval> fetchDetail(
int id, {
bool includeSteps = true,
bool includeHistories = true,
}) async {
final approval = _approvals[id];
if (approval == null) {
throw StateError('결재($id)를 찾을 수 없습니다.');
}
return approval;
}
@override
Future<Approval> submit(ApprovalSubmissionInput input) async {
final id = _sequence++;
final approvalNo = 'APP-${id.toString().padLeft(6, '0')}';
final status = ApprovalStatus(id: input.statusId, name: '진행중');
final requester = ApprovalRequester(
id: input.requesterId,
employeeNo: 'EMP-${input.requesterId}',
name: '상신자',
);
final steps = input.steps
.map(
(step) => ApprovalStep(
id: step.stepOrder,
stepOrder: step.stepOrder,
approver: ApprovalApprover(
id: step.approverId,
employeeNo: 'EMP-${step.approverId}',
name: '승인자 ${step.approverId}',
),
status: ApprovalStatus(id: 1, name: '대기'),
assignedAt: DateTime.now(),
note: step.note,
),
)
.toList(growable: false);
final approval = Approval(
id: id,
approvalNo: approvalNo,
transactionNo: input.transactionId != null
? 'TRX-${input.transactionId}'
: 'TRX',
status: status,
requester: requester,
requestedAt: DateTime.now(),
note: input.note,
steps: steps,
histories: const [],
currentStep: steps.isEmpty ? null : steps.first,
);
_approvals[id] = approval;
return approval;
}
@override
Future<Approval> approve(ApprovalDecisionInput input) async {
final approval = await fetchDetail(input.approvalId);
final current = approval.steps.firstWhere(
(step) => step.decidedAt == null,
orElse: () => throw StateError('모든 결재 단계가 이미 완료되었습니다.'),
);
if (current.approver.id != input.actorId) {
throw StateError('현재 단계 승인자가 아닙니다.');
}
final steps = approval.steps
.map((step) {
if (step.decidedAt != null) {
return step;
}
if (step.approver.id != input.actorId) {
return step;
}
return step.copyWith(
decidedAt: DateTime.now(),
status: ApprovalStatus(id: 2, name: '승인됨', isTerminal: false),
);
})
.toList(growable: false);
final nextPending = steps.firstWhere(
(step) => step.decidedAt == null,
orElse: () => ApprovalStep(
id: -1,
stepOrder: -1,
approver: ApprovalApprover(
id: approval.requester.id,
employeeNo: approval.requester.employeeNo,
name: approval.requester.name,
),
status: ApprovalStatus(id: 2, name: '완료', isTerminal: true),
assignedAt: DateTime.now(),
),
);
ApprovalStatus nextStatus;
ApprovalStep? currentStep;
if (nextPending.stepOrder == -1) {
nextStatus = ApprovalStatus(id: 9, name: '승인 완료', isTerminal: true);
currentStep = null;
} else {
nextStatus = approval.status;
currentStep = steps.firstWhere((step) => step.decidedAt == null);
}
final updated = approval.copyWith(
status: nextStatus,
steps: steps,
currentStep: currentStep,
updatedAt: DateTime.now(),
);
_approvals[input.approvalId] = updated;
return updated;
}
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
final approval = await fetchDetail(id);
final nextPending = approval.steps.firstWhere(
(step) => step.decidedAt == null,
orElse: () => ApprovalStep(
id: -1,
stepOrder: -1,
approver: ApprovalApprover(
id: approval.requester.id,
employeeNo: approval.requester.employeeNo,
name: approval.requester.name,
),
status: approval.status,
assignedAt: DateTime.now(),
),
);
if (nextPending.stepOrder == -1) {
return ApprovalProceedStatus(
approvalId: id,
canProceed: false,
reason: '모든 단계가 완료되었습니다.',
);
}
return ApprovalProceedStatus(
approvalId: id,
canProceed: true,
reason: null,
);
}
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
return [
ApprovalAction(id: 1, name: '승인', code: 'approve'),
ApprovalAction(id: 2, name: '반려', code: 'reject'),
];
}
@override
Future<Approval> resubmit(ApprovalResubmissionInput input) =>
throw UnimplementedError();
@override
Future<Approval> reject(ApprovalDecisionInput input) =>
throw UnimplementedError();
@override
Future<Approval> recall(ApprovalRecallInput input) =>
throw UnimplementedError();
@override
Future<PaginatedResult<ApprovalHistory>> listHistory({
required int approvalId,
int page = 1,
int pageSize = 20,
DateTime? from,
DateTime? to,
int? actorId,
int? approvalActionId,
}) => throw UnimplementedError();
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) =>
throw UnimplementedError();
@override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) =>
throw UnimplementedError();
@override
Future<Approval> create(ApprovalCreateInput input) =>
throw UnimplementedError();
@override
Future<Approval> update(ApprovalUpdateInput input) =>
throw UnimplementedError();
@override
Future<void> delete(int id) => throw UnimplementedError();
@override
Future<Approval> restore(int id) => throw UnimplementedError();
}
class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository {
@override
Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1,
int pageSize = 20,
String? query,
bool? isActive,
}) async {
return PaginatedResult(
items: const <ApprovalTemplate>[],
page: 1,
pageSize: 0,
total: 0,
);
}
@override
Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true}) =>
throw UnimplementedError();
@override
Future<ApprovalTemplate> create(
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
}) => throw UnimplementedError();
@override
Future<ApprovalTemplate> update(
int id,
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
}) => throw UnimplementedError();
@override
Future<void> delete(int id) => throw UnimplementedError();
@override
Future<ApprovalTemplate> restore(int id) => throw UnimplementedError();
}

View File

@@ -139,6 +139,9 @@ void main() {
customers: [
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
],
approval: StockTransactionApprovalInput(
requestedById: resolvedEmployeeId,
),
);
final created = await repository.create(createInput);