- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
566 lines
17 KiB
Dart
566 lines
17 KiB
Dart
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, {String? note}) 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, {String? note}) =>
|
|
throw UnimplementedError();
|
|
|
|
@override
|
|
Future<StockTransaction> approve(int id, {String? note}) =>
|
|
throw UnimplementedError();
|
|
|
|
@override
|
|
Future<StockTransaction> reject(int id, {String? note}) =>
|
|
throw UnimplementedError();
|
|
|
|
@override
|
|
Future<StockTransaction> cancel(int id, {String? note}) =>
|
|
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();
|
|
}
|