feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
@@ -235,7 +235,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> submit(int id) async {
|
||||
Future<StockTransaction> submit(int id, {String? note}) async {
|
||||
final transaction = _require(id);
|
||||
final updated = transaction.copyWith(
|
||||
status: StockTransactionStatus(id: transaction.status.id, name: '제출'),
|
||||
@@ -281,16 +281,20 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
Future<StockTransaction> restore(int id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<StockTransaction> complete(int id) => throw UnimplementedError();
|
||||
Future<StockTransaction> complete(int id, {String? note}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<StockTransaction> approve(int id) => throw UnimplementedError();
|
||||
Future<StockTransaction> approve(int id, {String? note}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<StockTransaction> reject(int id) => throw UnimplementedError();
|
||||
Future<StockTransaction> reject(int id, {String? note}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<StockTransaction> cancel(int id) => throw UnimplementedError();
|
||||
Future<StockTransaction> cancel(int id, {String? note}) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
class _FakeApprovalRepository implements ApprovalRepository {
|
||||
|
||||
@@ -121,45 +121,151 @@ void main() {
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
final createInput = StockTransactionCreateInput(
|
||||
transactionTypeId: resolvedTransactionTypeId,
|
||||
transactionStatusId: resolvedTransactionStatusId,
|
||||
warehouseId: resolvedWarehouseId,
|
||||
transactionDate: now,
|
||||
createdById: resolvedEmployeeId,
|
||||
note: 'integration-test ${now.toIso8601String()}',
|
||||
lines: [
|
||||
TransactionLineCreateInput(
|
||||
lineNo: 1,
|
||||
productId: resolvedProductId,
|
||||
quantity: 1,
|
||||
unitPrice: 1,
|
||||
StockTransactionCreateInput buildCreateInput(String label) {
|
||||
final suffix = '$label-${now.millisecondsSinceEpoch}';
|
||||
return StockTransactionCreateInput(
|
||||
transactionTypeId: resolvedTransactionTypeId,
|
||||
transactionStatusId: resolvedTransactionStatusId,
|
||||
warehouseId: resolvedWarehouseId,
|
||||
transactionDate: now,
|
||||
createdById: resolvedEmployeeId,
|
||||
note: 'integration-test $suffix',
|
||||
lines: [
|
||||
TransactionLineCreateInput(
|
||||
lineNo: 1,
|
||||
productId: resolvedProductId,
|
||||
quantity: 1,
|
||||
unitPrice: 1,
|
||||
note: 'line $suffix',
|
||||
),
|
||||
],
|
||||
customers: [
|
||||
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
|
||||
],
|
||||
approval: StockTransactionApprovalInput(
|
||||
requestedById: resolvedEmployeeId,
|
||||
note: 'approval $suffix',
|
||||
),
|
||||
],
|
||||
customers: [
|
||||
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
|
||||
],
|
||||
approval: StockTransactionApprovalInput(
|
||||
requestedById: resolvedEmployeeId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool statusChanged(StockTransaction before, StockTransaction after) {
|
||||
if (before.status.id != after.status.id) {
|
||||
return true;
|
||||
}
|
||||
final beforeName = before.status.name.trim();
|
||||
final afterName = after.status.name.trim();
|
||||
return beforeName != afterName;
|
||||
}
|
||||
|
||||
Future<void> safeDelete(int id) async {
|
||||
try {
|
||||
await repository.delete(id);
|
||||
tester.printToConsole('deleted transaction: $id');
|
||||
} catch (error) {
|
||||
tester.printToConsole(
|
||||
'삭제 중 경고: transaction $id 제거 실패 (${error.runtimeType})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final primary = await repository.create(buildCreateInput('primary'));
|
||||
expect(primary.id, isNotNull);
|
||||
final primaryId = primary.id!;
|
||||
tester.printToConsole('created transaction(primary): $primaryId');
|
||||
|
||||
final submitted = await repository.submit(
|
||||
primaryId,
|
||||
note: 'integration-submit-primary',
|
||||
);
|
||||
expect(submitted.id, equals(primaryId));
|
||||
if (useFakeFlow) {
|
||||
expect(submitted.status.name, contains('승인대기'));
|
||||
} else {
|
||||
expect(statusChanged(primary, submitted), isTrue);
|
||||
}
|
||||
tester.printToConsole(
|
||||
'submitted transaction: $primaryId (status: ${submitted.status.name})',
|
||||
);
|
||||
|
||||
final created = await repository.create(createInput);
|
||||
expect(created.id, isNotNull);
|
||||
tester.printToConsole('created transaction: ${created.id}');
|
||||
final approved = await repository.approve(
|
||||
primaryId,
|
||||
note: 'integration-approve-primary',
|
||||
);
|
||||
expect(approved.id, equals(primaryId));
|
||||
if (useFakeFlow) {
|
||||
expect(approved.status.name, contains('승인완료'));
|
||||
} else {
|
||||
expect(statusChanged(submitted, approved), isTrue);
|
||||
}
|
||||
tester.printToConsole(
|
||||
'approved transaction: $primaryId (status: ${approved.status.name})',
|
||||
);
|
||||
|
||||
// 상태 전이: submit -> cancel 순으로 흐름 검증 (승인 루프는 환경에 따라 조정 필요).
|
||||
final submitted = await repository.submit(created.id!);
|
||||
expect(submitted.id, equals(created.id));
|
||||
tester.printToConsole('submitted transaction: ${submitted.id}');
|
||||
final completed = await repository.complete(
|
||||
primaryId,
|
||||
note: 'integration-complete-primary',
|
||||
);
|
||||
expect(completed.id, equals(primaryId));
|
||||
if (useFakeFlow) {
|
||||
expect(completed.status.name, contains('완료'));
|
||||
} else {
|
||||
expect(statusChanged(approved, completed), isTrue);
|
||||
}
|
||||
tester.printToConsole(
|
||||
'completed transaction: $primaryId (status: ${completed.status.name})',
|
||||
);
|
||||
await safeDelete(primaryId);
|
||||
|
||||
final cancelled = await repository.cancel(created.id!);
|
||||
expect(cancelled.id, equals(created.id));
|
||||
tester.printToConsole('cancelled transaction: ${cancelled.id}');
|
||||
final cancelTarget = await repository.create(buildCreateInput('cancel'));
|
||||
expect(cancelTarget.id, isNotNull);
|
||||
final cancelId = cancelTarget.id!;
|
||||
tester.printToConsole('created transaction(cancel): $cancelId');
|
||||
|
||||
// 테스트 데이터 정리.
|
||||
await repository.delete(created.id!);
|
||||
tester.printToConsole('deleted transaction: ${created.id}');
|
||||
final cancelSubmitted = await repository.submit(
|
||||
cancelId,
|
||||
note: 'integration-submit-cancel',
|
||||
);
|
||||
expect(cancelSubmitted.id, equals(cancelId));
|
||||
final cancelled = await repository.cancel(
|
||||
cancelId,
|
||||
note: 'integration-cancel',
|
||||
);
|
||||
expect(cancelled.id, equals(cancelId));
|
||||
if (useFakeFlow) {
|
||||
expect(cancelled.status.name, contains('취소'));
|
||||
} else {
|
||||
expect(statusChanged(cancelSubmitted, cancelled), isTrue);
|
||||
}
|
||||
tester.printToConsole(
|
||||
'cancelled transaction: $cancelId (status: ${cancelled.status.name})',
|
||||
);
|
||||
await safeDelete(cancelId);
|
||||
|
||||
final rejectTarget = await repository.create(buildCreateInput('reject'));
|
||||
expect(rejectTarget.id, isNotNull);
|
||||
final rejectId = rejectTarget.id!;
|
||||
tester.printToConsole('created transaction(reject): $rejectId');
|
||||
|
||||
final rejectSubmitted = await repository.submit(
|
||||
rejectId,
|
||||
note: 'integration-submit-reject',
|
||||
);
|
||||
expect(rejectSubmitted.id, equals(rejectId));
|
||||
final rejected = await repository.reject(
|
||||
rejectId,
|
||||
note: 'integration-reject',
|
||||
);
|
||||
expect(rejected.id, equals(rejectId));
|
||||
if (useFakeFlow) {
|
||||
expect(rejected.status.name, contains('반려'));
|
||||
} else {
|
||||
expect(statusChanged(rejectSubmitted, rejected), isTrue);
|
||||
}
|
||||
tester.printToConsole(
|
||||
'rejected transaction: $rejectId (status: ${rejected.status.name})',
|
||||
);
|
||||
await safeDelete(rejectId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,7 +347,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> submit(int id) async {
|
||||
Future<StockTransaction> submit(int id, {String? note}) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'),
|
||||
@@ -249,7 +355,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> complete(int id) async {
|
||||
Future<StockTransaction> complete(int id, {String? note}) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 2, name: '완료'),
|
||||
@@ -257,7 +363,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> approve(int id) async {
|
||||
Future<StockTransaction> approve(int id, {String? note}) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'),
|
||||
@@ -265,7 +371,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> reject(int id) async {
|
||||
Future<StockTransaction> reject(int id, {String? note}) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 4, name: '반려'),
|
||||
@@ -273,7 +379,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> cancel(int id) async {
|
||||
Future<StockTransaction> cancel(int id, {String? note}) async {
|
||||
return _updateStatus(
|
||||
id,
|
||||
StockTransactionStatus(id: initialStatusId + 5, name: '취소'),
|
||||
|
||||
Reference in New Issue
Block a user