feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화

- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리

- approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강

- 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트

- 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
JiWoong Sul
2025-10-31 16:43:14 +09:00
parent d76f765814
commit 3e83408aa7
35 changed files with 1056 additions and 470 deletions

View File

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

View File

@@ -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: '취소'),