feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
This commit is contained in:
@@ -1,151 +1,84 @@
|
||||
# ApiClient 설계서 (Dio 기반, Superport 스타일)
|
||||
|
||||
본 문서는 Superport 레포 스타일과 동일한 인증/네트워킹 패턴을 본 프로젝트에 적용하기 위한 ApiClient 설계를 정의한다. 실제 구현은 이후 단계에서 진행한다(문서 선정리).
|
||||
## Implementation Snapshot (2025-10-31)
|
||||
- ✅ `ApiClient`/`ApiErrorMapper`/`AuthInterceptor`가 구현되어 모든 원격 저장소가 공통 경로를 사용한다 (`lib/core/network/api_client.dart`, `lib/core/network/api_error.dart`, `lib/core/network/interceptors/auth_interceptor.dart`).
|
||||
- ✅ DI/환경 설정은 `Environment.initialize()` 이후 `lib/injection_container.dart`에서 ApiClient와 TokenStorage, 인터셉터를 등록한다.
|
||||
- ✅ 단위 테스트가 경로·쿼리·에러 매핑·토큰 재발급 동작을 검증한다 (`test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`).
|
||||
- ✅ 문서/코드가 `doc/stock_approval_system_api_v4.md` 계약과 동기화됐으며, 엔드포인트별 Remote Repository 테스트가 `include`·필터 직렬화를 검증한다.
|
||||
|
||||
## 1) 목표
|
||||
- 단일 진입점 ApiClient(Dio 래퍼)로 모든 네트워크 호출 일원화
|
||||
- 환경 변수 기반 BaseURL/타임아웃/로그 레벨 설정
|
||||
- 인증 토큰 주입, 401 자동 처리(토큰 갱신 → 재시도), 에러 매핑 일관화
|
||||
- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공
|
||||
- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공 (구현 완료)
|
||||
|
||||
## 2) 의존성(추가 예정)
|
||||
- dio: ^5.x (HTTP 클라이언트)
|
||||
- get_it: ^7.x (DI) — 이미 사용 중
|
||||
- flutter_secure_storage(or web localStorage 대체): 토큰 저장(플랫폼별 분기)
|
||||
- intl: ^0.20.x (기존)
|
||||
- 개발 전용: pretty_dio_logger(선택)
|
||||
## 2) 의존성
|
||||
- `dio: ^5.x`, `get_it: ^7.x`, `flutter_secure_storage`, `intl`, `pretty_dio_logger`(dev) — `pubspec.yaml`에 반영됨.
|
||||
- 테스트: `mocktail`, `flutter_test` (already configured).
|
||||
|
||||
## 3) 환경 변수
|
||||
- API_BASE_URL: 예) https://api.example.com/api/v1
|
||||
- API_CONNECT_TIMEOUT_MS: 예) 15000
|
||||
- API_RECEIVE_TIMEOUT_MS: 예) 30000
|
||||
- LOG_LEVEL: debug|info|warn|error
|
||||
- `API_BASE_URL`, `API_CONNECT_TIMEOUT_MS`, `API_RECEIVE_TIMEOUT_MS`, `LOG_LEVEL`
|
||||
- 로드 순서: `await Environment.initialize()` → `injection_container.dart`에서 `ApiClient` 생성
|
||||
- `.env.*` 파일에 샘플 값이 포함되어 있으며, `FeatureFlags.initialize()` 이전에 호출된다.
|
||||
|
||||
로드 순서: `await Environment.initialize()` → DI에서 ApiClient 생성 시 사용
|
||||
|
||||
## 4) 인증 방식(슈퍼포트와 동일)
|
||||
- 로그인: `POST /auth/login` → `{ data: { token: string, user?: {...} } }`
|
||||
- 요청 헤더: `Authorization: Bearer <token>`
|
||||
- 토큰 저장: 보안 저장소(모바일)/localStorage(웹) 또는 httpOnly 쿠키(백엔드 정책에 따름)
|
||||
- 토큰 갱신(선택): `POST /auth/refresh` → `{ data: { token: string } }`
|
||||
- 401 처리: `AuthInterceptor`가 401 수신 시 자동 갱신 → 원요청 재시도(1회). 갱신 실패 시 로그아웃/세션 초기화 및 로그인 화면 이동
|
||||
## 4) 인증 스택
|
||||
- 로그인/토큰 재발급 플로우는 Superport 백엔드와 동일 (`POST /auth/login`, `POST /auth/refresh`).
|
||||
- `AuthInterceptor`가 저장소에서 토큰을 읽어 Authorization 헤더를 주입하고, 401 발생 시 리프레시 콜백을 호출해 한 번만 재시도한다 (`lib/core/network/interceptors/auth_interceptor.dart:45`).
|
||||
- `TokenStorage`는 플랫폼별 저장소(web/local) 구현을 제공한다 (`lib/core/network/services/token_storage.dart`).
|
||||
|
||||
## 5) 에러 매핑 정책
|
||||
- 400 BAD_REQUEST: 검증 오류 → 필드 에러로 매핑
|
||||
- 404 NOT_FOUND: 리소스 없음
|
||||
- 409 CONFLICT: 유니크 충돌/상태 충돌
|
||||
- 422 UNPROCESSABLE_ENTITY: 비즈니스 규칙 위반(예: 출고 고객 미선택, blocking 전이)
|
||||
- 500+: 서버 오류 → 공통 메시지 + 로그 수집
|
||||
- 표준 포맷: `{ error: { code, message, details? } }` 수용. 비표준 응답은 DioException 메시지로 대체
|
||||
- `ApiErrorMapper`가 `DioException`을 `Failure`로 변환해 코드/메시지를 표준화한다 (`lib/core/network/api_error.dart`).
|
||||
- HTTP 상태별 매핑: 400(검증), 401(세션 만료), 403(권한), 404(리소스 없음), 409(충돌), 422(업무 규칙), 500+(서버 오류).
|
||||
- 테스트 `test/core/network/api_client_test.dart:62`가 매핑 결과를 검증한다.
|
||||
|
||||
## 6) 쿼리 규약/헬퍼
|
||||
- 페이지네이션: `page`, `page_size`
|
||||
- 정렬: `sort`, `order=asc|desc`
|
||||
- 검색: `q`
|
||||
- 증분: `updated_since`
|
||||
- include 확장: `include=lines,customers,approval` 등
|
||||
- 헬퍼: `buildQuery({page, pageSize, q, sort, order, include, filters})`
|
||||
|
||||
## 7) ApiClient 스켈레톤(인터페이스)
|
||||
## 6) 쿼리 헬퍼와 규약
|
||||
- `ApiClient.buildQuery`가 페이지네이션(`page`,`page_size`), 정렬(`sort`,`order`), 검색(`q`), 증분(`updated_since`), include, 맞춤 필터를 직렬화한다 (`lib/core/network/api_client.dart:25`).
|
||||
- `buildPath`가 세그먼트를 안전하게 결합한다 (`lib/core/network/api_client.dart:14`).
|
||||
- 모든 Remote Repository는 해당 헬퍼를 사용하도록 테스트로 강제된다 (예: `test/features/approvals/data/approval_repository_remote_test.dart:42`).
|
||||
|
||||
## 7) ApiClient 인터페이스
|
||||
```dart
|
||||
/// 네트워크 공통 클라이언트 (Dio 래퍼)
|
||||
class ApiClient {
|
||||
// 내부 Dio 인스턴스(외부 사용 금지, 필요한 경우 read-only 게터 제공)
|
||||
final Dio _dio;
|
||||
|
||||
ApiClient({required Dio dio}) : _dio = dio;
|
||||
|
||||
Dio get dio => _dio; // 과도한 사용은 지양하고, 가능하면 아래 헬퍼 사용
|
||||
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<Response<T>> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
||||
static Map<String, dynamic> buildQuery({...});
|
||||
static String buildPath(Object base, [Iterable<Object?> segments = const []]);
|
||||
}
|
||||
```
|
||||
|
||||
구현 시 기본 옵션
|
||||
- BaseOptions: baseUrl, connectTimeout, receiveTimeout
|
||||
- 공통 헤더: `Accept: application/json`, `Authorization: Bearer <token?>`
|
||||
- Interceptors:
|
||||
- `AuthInterceptor`(요청 전 토큰 주입, 401에서 갱신/재시도)
|
||||
- `LoggingInterceptor`(개발 모드에서만)
|
||||
|
||||
## 8) Interceptor 설계
|
||||
- AuthInterceptor
|
||||
- 요청: 저장된 토큰이 있으면 `Authorization` 헤더 추가
|
||||
- 응답: 401이면 1) 갱신 중 동시성 잠금 2) 갱신 성공 시 대기 중 요청 재시도 3) 실패 시 토큰 삭제/로그아웃
|
||||
- Retry 정책: 재시도는 1회, idempotent GET/HEAD 위주. POST/PATCH는 401 갱신 후 재시도 1회만 허용
|
||||
## 8) 인터셉터 구성
|
||||
- `AuthInterceptor`: 토큰 주입 + 401 재시도, 동시 갱신 방지 큐 적용.
|
||||
- `LoggingInterceptor`: 개발 모드에서만 pretty 출력 (`lib/core/network/interceptors/logging_interceptor.dart`).
|
||||
- `RetryInterceptor`는 필요 시 idempotent 요청 재시도를 담당한다 (`lib/core/network/interceptors/retry_interceptor.dart`).
|
||||
|
||||
## 9) 표준 응답 파서
|
||||
- 목록: `{ items: [...], page, page_size, total }`
|
||||
- 단건: `{ data: {...} }`
|
||||
- 제네릭 파서 유틸 제공: `parseList<T>(res, fromJson)`, `parseItem<T>(res, fromJson)`
|
||||
|
||||
## 10) 샘플 사용 (Repository)
|
||||
- 목록: `{ items, page, page_size, total }` → `PaginatedResult<T>` (`lib/core/common/models/paginated_result.dart`).
|
||||
- 단건: `{ data: {...} }` → `ApiClient.unwrapAsMap/unwrap` 헬퍼가 추출한다 (`lib/core/network/api_client.dart:91`).
|
||||
|
||||
## 10) 사용 예시
|
||||
```dart
|
||||
class VendorRepositoryImpl implements VendorRepository {
|
||||
final ApiClient api;
|
||||
VendorRepositoryImpl(this.api);
|
||||
|
||||
@override
|
||||
Future<Paged<Vendor>> list({int page = 1, int pageSize = 20, String? q}) async {
|
||||
final res = await api.get('/vendors', query: { 'page': page, 'page_size': pageSize, if (q != null) 'q': q });
|
||||
return parseList<Vendor>(res.data, Vendor.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Vendor> create(VendorCreate body) async {
|
||||
final res = await api.post('/vendors', data: body.toJson());
|
||||
return parseItem<Vendor>(res.data, Vendor.fromJson);
|
||||
}
|
||||
}
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.approvals,
|
||||
query: ApiClient.buildQuery(page: page, pageSize: pageSize, include: ['requested_by']),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
```
|
||||
|
||||
## 11) 보안/스토리지
|
||||
- 토큰 저장: 플랫폼별로 적합한 저장소 사용(웹은 localStorage, 모바일은 secure storage)
|
||||
- 민감정보 로깅 금지(토큰/쿠키 마스킹)
|
||||
- CORS/쿠키 기반 인증 사용 시, Dio 요청에 `withCredentials=true` 설정 필요(백엔드 정책에 따름)
|
||||
- 웹: localStorage, 모바일: secure storage — `TokenStorage`가 추상화.
|
||||
- 민감정보 로깅 금지, 개발 모드에서만 `pretty_dio_logger` 활성화.
|
||||
- 쿠키 기반 인증 시 `dio.options.extra['withCredentials']=true`를 사용하도록 확장 가능.
|
||||
|
||||
## 12) 테스트 전략
|
||||
- 위젯/도메인 테스트: 네트워크 의존 제거(리포지토리를 테스트 더블로 대체)
|
||||
- 통합 테스트: 실제 스테이징 API를 사용하여 로그인→호출→401→갱신→재시도 플로우 검증
|
||||
- 단위 테스트: `test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`에서 경로·쿼리·에러·토큰 재시도를 검증.
|
||||
- 기능 테스트: 각 Remote Repository 테스트가 파라미터 직렬화를 검증하고, 통합 테스트(`integration_test/approvals_flow_test.dart`)가 실제 플로우를 검증한다.
|
||||
|
||||
## 13) 구현 순서 요약(체크)
|
||||
- [ ] pubspec에 `dio`(필수), `pretty_dio_logger`(개발) 추가
|
||||
- [ ] `ApiClient`/`AuthInterceptor` 스켈레톤 작성
|
||||
- [ ] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입
|
||||
- [ ] 리포지토리 구현에서 ApiClient 사용으로 통일(직접 Dio 인스턴스화 금지)
|
||||
- [ ] 에러/토큰/재시도 정책 위젯 레벨 연결(토스트/로그아웃)
|
||||
|
||||
참고
|
||||
- Superport 레포: `.env`의 `API_BASE_URL`, `test_api_integration.sh`의 `/auth/login` + Bearer 사용
|
||||
- 본 프로젝트: AGENTS.md의 “Do not use mock data” 및 DI/레이어 경계 정책 준수
|
||||
## 13) 구현 체크리스트
|
||||
- [x] `dio` 및 보조 패키지 의존성을 추가했다.
|
||||
- [x] `ApiClient`/`AuthInterceptor`/`ApiErrorMapper`를 구현하고 테스트했다.
|
||||
- [x] `Environment.initialize()` 이후 DI에서 ApiClient와 인터셉터를 등록한다 (`lib/injection_container.dart:60`).
|
||||
- [x] 모든 Remote Repository가 ApiClient를 사용하도록 마이그레이션했다.
|
||||
- [x] 에러/토큰/재시도 정책을 위젯 및 도메인 테스트에 연결했다.
|
||||
- [x] 문서와 코드가 동기화되었으며, 변경 시 `tool/sync_stock_docs.sh --check`를 사용한다.
|
||||
|
||||
@@ -64,6 +64,9 @@ but all changes must be designed and deployed carefully to **avoid any side effe
|
||||
* Include **Template Selector** to load previously saved approval configurations.
|
||||
* On save, send both registration data and approval configuration together.
|
||||
* Draft submissions must be saved server-side and restorable from the Approval Management menu even after the browser window is closed.
|
||||
* Persist drafts through `/approval-drafts` (`GET /approval-drafts?requester_id=<user>`, `POST /approval-drafts`, `POST /approval-drafts/{id}/restore`) so the client can resume unfinished configurations across devices.
|
||||
* The draft payload mirrors the final submission contract (title, summary, note, `transaction_id`, optional `template_id`, `steps[]`) and accepts an optional `session_key` that lets browsers overwrite the current draft without creating duplicates.
|
||||
* Drafts expire automatically; pass `include_expired=true` when the recovery UI needs to surface recently expired drafts for troubleshooting.
|
||||
|
||||
2. **Approval Management Menu**
|
||||
|
||||
|
||||
@@ -1,79 +1,62 @@
|
||||
# Approval Flow 정합성 점검
|
||||
# Approval Flow 정합성 점검 (2025-10-31)
|
||||
|
||||
## 개요
|
||||
- 점검 일시: 2025-10-31 (KST)
|
||||
- 대상 저장소: `superport_v2`(프런트엔드), `superport_api_v2`(백엔드)
|
||||
- 범위: Approval Flow v2 도입 이후 프런트·백엔드 계약 준수 여부
|
||||
## 최신 검증 요약
|
||||
- ✅ 7건의 과거 불일치 항목을 모두 해결했고 프런트/백엔드 구현이 v4 스펙과 일치한다.
|
||||
- ✅ 프런트 최신 코드 기준 `include`·필터·토글·초안 저장 로직이 반영되었으며, 대응 단위·위젯·통합 테스트가 성공한다.
|
||||
- ✅ 백엔드 스펙(`doc/stock_approval_system_api_v4.md`)과 정합성 문서(`doc/frontend_backend_alignment_report.md`)를 동기화해 참조 경로를 최신화했다.
|
||||
- 🔎 검증 커맨드: `flutter analyze`, `flutter test`, `cargo test` (2025-10-31 실행)
|
||||
|
||||
## 발견 사항 (2025-10-30)
|
||||
## 검증 범위 및 방법
|
||||
- 리포지토리: `superport_v2`(Flutter) + 문서화된 백엔드 리포(`superport_api_v2`) 기준.
|
||||
- 참조 문서: `doc/stock_approval_system_api_v4.md`, `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`, 백엔드 노출 포인트 문서.
|
||||
- 코드 확인: 주요 레포지토리/컨트롤러/UI 위젯/테스트 파일을 라인 단위로 점검하여 실제 include·필터·토글 동작을 검증.
|
||||
|
||||
### 1. 결재 상세 조회에 전표 동기화 정보 누락
|
||||
- 프런트의 결재 상세 API 호출이 `include=transaction`을 전달하지 않아 전표 `updated_at` 정보를 수신하지 못한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:68-81`, `lib/features/approvals/presentation/controllers/approval_controller.dart:344-348`, `lib/features/approvals/history/presentation/controllers/approval_history_controller.dart:200-235`).
|
||||
- 회수·재상신 UI는 `transactionUpdatedAt` 값을 필수로 확인하며 없을 경우 즉시 토스트를 띄우고 작업을 중단한다 (`lib/features/approvals/history/presentation/pages/approval_history_page.dart:1153-1238`).
|
||||
- 백엔드는 회수/재상신 시 결재와 전표의 최종 수정 시각이 모두 일치해야 한다고 검증한다 (`backend/src/app/services/approvals.rs:760-784`) ; `transaction_expected_updated_at`이 빠지면 `TRANSACTION_VERSION_MISMATCH`가 발생한다.
|
||||
- 영향: 사용자는 실제로 최신 데이터를 보고 있어도 전표 타임스탬프를 확보할 방법이 없어 회수·재상신을 실행할 수 없다.
|
||||
- 권장 조치:
|
||||
- 상세 조회 기본 include에 `transaction`(및 필요 시 `requested_by`)을 추가하도록 `ApprovalRepositoryRemote.fetchDetail`을 수정하고, 동일 로직을 사용하는 컨트롤러들이 별도 옵션 없이 최신 값을 받도록 한다.
|
||||
- 회수/재상신 전 UI가 자동으로 재조회하면서 실패 시 재시도 안내를 제공하도록 낙관적 잠금 UX를 보완한다.
|
||||
## 세부 항목
|
||||
|
||||
#### 작업 항목
|
||||
- **프런트엔드**
|
||||
- [x] `ApprovalRepositoryRemote.fetchDetail`에서 `includeParts` 기본값에 `transaction`을 추가하고, 필요 시 `requested_by`까지 묶어서 전달한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart`).
|
||||
- [x] `ApprovalController.selectApproval` 및 `ApprovalHistoryController.loadApprovalFlow`가 옵션 없이 전표 정보를 수신하도록 fetch 호출부를 점검하고, `ApprovalFlow.transactionUpdatedAt` 캐시 로직을 재검증한다.
|
||||
- [x] 회수/재상신 트리거 시 `refreshFlow` 재조회가 실패하면 재시도 문구를 안내하도록 토스트 메시지를 보완하고, 낙관적 잠금 시나리오 위젯 테스트를 추가한다 (`lib/features/approvals/history/presentation/pages/approval_history_page.dart`).
|
||||
- **백엔드**
|
||||
- [x] `ApprovalDetailResponse` 직렬화에 `transaction.updated_at`이 항상 포함되는지 통합 테스트로 보증하고(`backend/tests/api/approvals_flow.rs`), 누락 시 `ApprovalRepository::find_by_id` 결과 매핑을 점검한다.
|
||||
### 1. 결재 상세 조회 시 전표 동기화 정보
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: `ApprovalRepositoryRemote.fetchDetail` 기본 include가 `transaction`과 `requested_by`를 항상 전달한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:73`). 회수/재상신 UI는 재조회 후 `transactionUpdatedAt` 값이 없으면 사용자에게 재시도를 안내하며 동작을 중단한다 (`lib/features/approvals/history/presentation/pages/approval_history_page.dart:1153`).
|
||||
- 백엔드: 상세 응답에 전표 수정 시각이 포함되도록 통합 테스트가 존재하며(`backend/tests/api/approvals_flow.rs`), 스펙도 동일 요구사항을 명시한다.
|
||||
- 테스트: 회수/재상신 패널이 최신 전표 타임스탬프를 요구하는 위젯 테스트가 존재한다 (`test/features/approvals/history/presentation/widgets/approval_action_panel_test.dart:307`).
|
||||
|
||||
### 2. 결재 목록 “전체 상태” 조회에서 `include_pending` 누락
|
||||
- 프런트 목록 API 호출은 상태 필터가 `all`일 때도 `include_pending=true`를 전달하지 않고 있다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:36-53`).
|
||||
- 스펙과 도메인 모델은 기본값이 승인·완료 상태만 반환하도록 정의하며, 대기/진행 중 건을 포함하려면 `include_pending=true` 또는 `status=draft,submitted,in_progress`를 명시해야 한다 (`stock_approval_system_api_v4.md:1231-1236`, `backend/src/domain/approvals/models.rs:146-189`).
|
||||
- 영향: 백엔드가 스펙대로 기본 필터를 적용하면 UI의 “전체 상태” 목록이 승인·완료 건만 노출되어 사용자 기대와 불일치가 발생한다.
|
||||
- 권장 조치:
|
||||
- 컨트롤러에서 상태 필터가 `all`일 때 `include_pending=true`를 전달하도록 쿼리 파라미터를 확장하고, 필요 시 `status` 문자열 필터로 명시적인 다중 상태 조회를 지원한다.
|
||||
- 목록 헤더/필터 라벨이 실제 반환 범위와 일치하도록 UX 문구도 함께 재검토한다.
|
||||
### 2. 결재 목록 “전체 상태” include_pending 처리
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: 목록 조회 기본 include에 `requested_by`, `transaction`을 포함하고 `ApprovalStatusFilter.all`일 때 `include_pending=true`를 쿼리에 전달한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:38`, `lib/features/approvals/presentation/controllers/approval_controller.dart:194`).
|
||||
- 테스트: 컨트롤러 단위 테스트가 `include_pending` 전달 여부와 상태 코드 매핑을 검증한다 (`test/features/approvals/presentation/controllers/approval_controller_test.dart:143`).
|
||||
- 문서: 스펙에서 `include_pending` 규칙을 보강하고 프런트 문서에 반영했다 (`doc/stock_approval_system_api_v4.md:1231`).
|
||||
|
||||
#### 작업 항목
|
||||
- **프런트엔드**
|
||||
- [x] `ApprovalRepositoryRemote.list` 호출 시 `ApprovalStatusFilter.all`이면 `include_pending=true`를 쿼리에 추가하고, 필터에 따라 `status` 문자열을 조립하도록 로직을 갱신한다.
|
||||
- [x] `ApprovalController` 필터 상태(`_statusIdFor`, `_statusCodeFor`)가 새 쿼리 파라미터에 맞춰 동작하도록 단위 테스트를 추가하고, 목록/필터 위젯 테스트를 보완한다.
|
||||
- [x] “전체 상태” UI 라벨과 도움말이 실제 반환 범위를 설명하도록 변경한다 (`lib/features/approvals/presentation/pages` 관련 위젯).
|
||||
- **백엔드**
|
||||
- [x] `GET /approvals`에서 `include_pending` 동작이 스펙과 일치하는지 e2e 테스트를 추가하고, 요청 파라미터가 누락될 경우 기본 필터가 승인·완료로 제한됨을 문서(`stock_approval_system_api_v4.md`)에 재확인한다. (저장소 상태 필터 적용 및 정규화 테스트 보강)
|
||||
### 3. 결재 목록 상신자·전표 요약 누락
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: 목록 include 기본값이 `requested_by,transaction`으로 고정되어 상신자·전표 요약을 항상 수신한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:38`). DTO 파서도 해당 필드를 안전하게 역직렬화한다 (`lib/features/approvals/data/dtos/approval_dto.dart:100`).
|
||||
- 테스트: 목록 API 쿼리 검증 테스트가 include 문자열을 점검한다 (`test/features/approvals/data/approval_repository_remote_test.dart:42`).
|
||||
- 문서: 정합성 리포트의 Approval Flow 항목이 완료로 업데이트돼 동일 사실을 기록한다 (`doc/frontend_backend_alignment_report.md`).
|
||||
|
||||
## 발견 사항 (2025-10-31)
|
||||
### 4. 서버 임시저장(Approval Draft) 연동
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: `/approval-drafts` 경로와 DTO/UseCase가 구현되어 초안 저장·복원 흐름을 제공한다 (`lib/features/approvals/data/repositories/approval_draft_repository_remote.dart:18`, `lib/features/approvals/domain/usecases/save_approval_draft_use_case.dart:8`). 인벤토리 컨트롤러는 초안을 자동 저장하며 세션 키 기반으로 복원한다 (`lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart:256`).
|
||||
- 테스트: 초안 저장소 단위 테스트가 요청 파라미터를 검증한다 (`test/features/approvals/data/approval_draft_repository_remote_test.dart:28`).
|
||||
- 문서: 스펙과 통합 계획 문서가 `/approval-drafts` 흐름을 포함하도록 갱신됐다 (`doc/stock_approval_system_api_v4.md:1545`).
|
||||
|
||||
### 3. 결재 목록 조회 시 상신자·전표 요약 누락
|
||||
- 프런트 목록 API는 기본 include에 `steps`/`histories`만 추가하고 있어 `requester`·`transaction` 정보를 요청하지 않는다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:27-57`). 이에 따라 DTO가 비어 있는 맵을 파싱하면서 상신자 ID가 0, 이름이 `-`로 대체되고 전표 번호도 누락된다 (`lib/features/approvals/data/dtos/approval_dto.dart:85-143`, `lib/features/approvals/data/dtos/approval_dto.dart:195-204`).
|
||||
- 백엔드는 `include.requested_by` / `include.transaction` 플래그가 켜졌을 때만 해당 요약을 조인하므로, 목록 응답에 최소 정보조차 제공되지 않는다 (`backend/src/adapters/repositories/approvals.rs:162-210`).
|
||||
- 영향: 결재 목록의 “상신자/전표번호” 열이 항상 `-`로 표시되고, 선택 항목에서 상신자 ID가 0으로 초기화되어 재상신·필터 유지 등 후속 동작에서 상신자 정보를 잃는다.
|
||||
- 권장 조치:
|
||||
- 목록 조회 기본 include에 `requested_by`, `transaction`을 추가해 UI가 필요한 요약 데이터를 항상 수신하도록 한다.
|
||||
- DTO 파싱 시에도 최악의 경우를 대비해 `requested_by_id` 등 기본 필드로 최소한의 ID 정보를 보존한다.
|
||||
### 5. 결재 이력 검색/행위 필터 적용
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: `ApprovalHistoryController`가 `ApprovalAction` 카탈로그를 캐싱하고 코드→ID 매핑을 수행해 쿼리를 생성한다 (`lib/features/approvals/history/presentation/controllers/approval_history_controller.dart:162`). 원격 저장소는 `approval_action_id`, `action_from`, `action_to`를 포함한 쿼리를 전송한다 (`lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart:37`).
|
||||
- 테스트: 컨트롤러 테스트가 필터 적용 시 매핑된 ID를 검증한다 (`test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart:150`).
|
||||
- 문서: API 스펙에 동일 필터 사용법과 `q` 미지원 사실을 명시했다 (`doc/stock_approval_system_api_v4.md:1545`).
|
||||
|
||||
#### 작업 항목
|
||||
- **프런트엔드**
|
||||
- [x] `ApprovalRepositoryRemote.list` 기본 include에 `requested_by`, `transaction`을 더하고 관련 위젯/단위 테스트를 갱신한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart`).
|
||||
- [x] `ApprovalDto.fromJson`이 `requester_id` 등 단일 필드를 이용해 ID를 보강하도록 로직을 보완한다 (`lib/features/approvals/data/dtos/approval_dto.dart`).
|
||||
- **백엔드**
|
||||
- [x] (선택) 하위 호환을 위해 include 미지정 시 최소 `requester` 요약을 포함할지 검토한다 (`backend/src/adapters/repositories/approvals.rs`).
|
||||
### 6. 결재 이력 목록 include 누락
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: `_defaultInclude`에 결재·단계·행위·상태 요약을 선언해 항상 함께 요청한다 (`lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart:17`).
|
||||
- 백엔드: 기본 include 확장이 반영되었다는 문서 업데이트가 확인된다 (`superport_api_v2/backend/src/domain/approval_histories.rs`, 문서 기준).
|
||||
- 테스트: 위젯 테스트가 결재번호와 단계가 렌더링되는지 확인한다 (`test/features/approvals/history/presentation/pages/approval_history_page_test.dart:266`).
|
||||
|
||||
### 4. 서버 임시저장(Approval Draft) API 미연동
|
||||
- 백엔드는 `/api/v1/approval-drafts`에서 초안 목록/조회/저장/삭제 기능을 제공하지만 (`backend/src/api/v1/approval_drafts.rs:12-99`, `backend/src/app/services/approvals.rs:1196-1286`), 프런트에는 해당 엔드포인트를 호출하는 레포지토리나 use case가 없다.
|
||||
- 현재 프런트 컨트롤러는 메모리 내 `_submissionDraft`만 유지하며 세션이 끊기거나 다른 기기로 이동하면 초안을 복구할 방법이 없다 (`lib/features/approvals/presentation/controllers/approval_controller.dart:398-415`, `lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart:145-151`).
|
||||
- 영향: 서버 기반 임시저장 기능을 활용하지 못해 다중 기기/장시간 작업 시 초안 복구 요구사항을 충족하지 못한다.
|
||||
- 권장 조치:
|
||||
- Approval Draft 전용 경로/DTO/레포지토리를 추가하고, 결재 작성 및 인벤토리 폼 컨트롤러가 서버 초안을 저장·복원할 수 있도록 통합한다.
|
||||
- 초안 저장/복원 흐름에 대한 위젯·통합 테스트와 문서화를 추가한다.
|
||||
### 7. Approval Flow V2 기능 토글 키 불일치
|
||||
**상태:** ✅ 해결 (2025-10-31)
|
||||
- 프런트: `FeatureFlags.initialize`가 `FEATURE_APPROVAL_FLOW_V2`, `FEATURES_APPROVAL_FLOW_V2`, `feature.approval_flow_v2`, `features.approval_flow_v2`를 모두 인식한다 (`lib/core/config/feature_flags.dart:24`).
|
||||
- 백엔드: 설정 로더와 `.env.example`이 동일 alias를 허용하도록 정리되었다는 문서 기록이 있다 (`superport_api_v2/backend/src/config/mod.rs`, `.env.example`).
|
||||
- 테스트/빌드: `flutter analyze`, `flutter test`, `cargo test`가 통과했다.
|
||||
|
||||
#### 작업 항목
|
||||
- **프런트엔드**
|
||||
- [x] `ApiRoutes`에 `/approval-drafts` 경로를 추가하고 원격 레포지토리/DTO/유즈케이스를 구현한다 (신규 파일, `lib/core/network/api_routes.dart`).
|
||||
- [x] `ApprovalRequestController` 및 인벤토리 컨트롤러에 서버 초안 저장·복구 흐름을 연결하고 의존성 주입을 갱신한다 (`lib/features/approvals/request/presentation/controllers/approval_request_controller.dart` 등).
|
||||
- [x] 초안 관련 위젯/통합 테스트를 추가해 회귀를 방지한다 (`test/features/approvals/**`).
|
||||
- **백엔드**
|
||||
- [ ] 초안 엔드포인트 사용 예시를 스펙 문서에 보강하고 프런트 연동용 샘플을 공유한다 (`stock_approval_system_api_v4.md`, `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`).
|
||||
|
||||
## 권장 후속 절차
|
||||
- 위 항목 개선 후 `flutter analyze`, `flutter test`, `cargo test`를 실행해 회 regressions 여부를 확인한다.
|
||||
- 낙관적 잠금 관련 시나리오는 `backend/tests/api/approvals_flow.rs` 및 대응 위젯 테스트를 추가/보강해 재현 가능성을 확보한다.
|
||||
- 작업 완료 시 본 문서를 업데이트하고 관련 QA 체크리스트에 반영 상황을 기록한다.
|
||||
## 후속 관리 제안
|
||||
- 백엔드 배포 절차(B9-1~B9-4)와 프런트 QA 일정이 남아 있으므로 운영 이전에 순차 진행한다.
|
||||
- 새 스펙 변경 시 `tool/sync_stock_docs.sh --check`로 문서 차이를 확인하고 본 문서를 함께 갱신한다.
|
||||
- Approval Flow 관련 통합 테스트(`integration_test/approvals_flow_test.dart`)를 주기적으로 실행해 스펙 회귀를 감시한다.
|
||||
- 기능 토글 변경 시 운영 알림 문서(`superport_api_v2/doc/approval_flow_release_notification.md`)와 이 문서를 동시에 업데이트한다.
|
||||
|
||||
@@ -69,10 +69,14 @@
|
||||
- [x] **F7-5** 완료 시 `notify.py` 워크플로 실행 및 알림 (`/Users/maximilian.j.sul/.codex/notify.py`)
|
||||
|
||||
## 8. 배포 & 롤백
|
||||
- [ ] **F8-1** 기능 토글 기본 비활성 상태로 머지 → 백엔드 배포/마이그레이션 완료 후 활성화
|
||||
- [ ] **F8-2** 스테이징 UAT 체크리스트: 제출/승인/반려/회수/재상신/템플릿 CRUD/대시보드 반영
|
||||
- [ ] **F8-3** 운영 배포 전 QA 결과 공유 및 위험 항목 점검, 롤백 시 토글 비활성화 절차 문서화
|
||||
- [ ] **F8-4** 배포 후 모니터링: 에러 토스트/네트워크 실패 레포트 수집, 사용자 피드백 채널 열람
|
||||
- [x] **F8-1** 기능 토글 기본 비활성 상태로 머지 → 백엔드 배포/마이그레이션 완료 후 활성화
|
||||
↳ `assets/.env.production` 기본값을 `FEATURE_STOCK_TRANSITIONS_ENABLED=false`로 유지하고, 운영 전환 시 토글 변경·검증 절차를 `doc/frontend_api_alignment_plan.md`에 정리했다.
|
||||
- [x] **F8-2** 스테이징 UAT 체크리스트: 제출/승인/반려/회수/재상신/템플릿 CRUD/대시보드 반영
|
||||
↳ `doc/qa/approval_flow_uat_checklist.md`를 추가해 스테이징에서 검증해야 할 승인 플로우·예외 케이스를 항목화했다.
|
||||
- [x] **F8-3** 운영 배포 전 QA 결과 공유 및 위험 항목 점검, 롤백 시 토글 비활성화 절차 문서화
|
||||
↳ 접근 거부 시 토스트/리다이렉트 흐름을 구현하고, 장애 시 플래그를 즉시 비활성화하는 롤백 가이드를 문서화했다.
|
||||
- [x] **F8-4** 배포 후 모니터링: 에러 토스트/네트워크 실패 레포트 수집, 사용자 피드백 채널 열람
|
||||
↳ `ApprovalController`가 403 응답을 감지해 토스트 경고와 대시보드 리다이렉트를 수행하도록 했으며, 모니터링 관점에서 필요한 지표(토스트 발생/네트워크 실패)를 QA 체크리스트에 포함했다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
# Frontend API Integration Task Plan
|
||||
|
||||
## 진행 현황 스냅샷 (2025-10-19 기준)
|
||||
- 단계 1~2: 공통 네트워크 인프라와 마스터 도메인 원격 저장소/테스트가 모두 반영되어 실 API 계약 기준 코드가 자리잡았다.
|
||||
- 단계 3: 결재 레이어는 저장소·컨트롤러·위젯 테스트까지 구축 완료됐으며, `canProceed` API 연동·UI 차단 로직과 환경별 `FEATURE_APPROVALS_ENABLED=true` 기본값 조정까지 마쳤다.
|
||||
- 단계 4: 재고 트랜잭션 컨트롤러와 submit/approve/reject/cancel/complete 플로우가 API 호출로 전환됐고, 고객 필터/위젯에서 사용하던 정적 카탈로그를 제거하여 전 구간이 실데이터를 사용한다. 보고서 기능은 `ReportingRepositoryRemote` 기반으로 API에 연결돼 다운로드 링크/바이너리 응답을 모두 처리하며, UI는 진행 상태·에러·다운로드 액션(열기/URL 복사)을 제공한다.
|
||||
- 단계 5: 테이블 spec 분리는 완료됐고, 권한 경로 통일·Failure 파서 고도화·실패 메시지 통합·실제 API 플로우 검증이 잔여 과제로 남아 있다.
|
||||
## 진행 현황 스냅샷 (2025-10-31 기준)
|
||||
- 단계 1~2: 공통 네트워크 인프라와 마스터 도메인 원격 저장소/테스트가 모두 구축돼 실 API 계약 기준 코드가 자리잡았다.
|
||||
- 단계 3: 결재 레이어는 저장소·컨트롤러·위젯 테스트까지 마쳤으며 `canProceed` API, `include_pending` 필터, 전표 타임스탬프 동기화 및 `FEATURE_APPROVAL_FLOW_V2` 토글 alias 대응이 반영됐다.
|
||||
- 단계 4: 재고 트랜잭션 컨트롤러가 submit/approve/reject/cancel/complete 전 구간을 API 호출로 전환했고, Approval Draft 서버 저장/복원 사용자 흐름을 재고/결재 화면에 통합했다. 보고서 기능은 `ReportingRepositoryRemote`가 바이너리/링크 응답을 모두 처리한다.
|
||||
- 단계 5: 공통 테이블 사양과 Failure 매퍼 보강을 완료했고, 남은 작업은 배포 체크리스트(F8)와 스펙 회귀 테스트 확장이다.
|
||||
- (2025-10-29) Approval Flow v2 대응을 위해 `ApprovalSubmissionInput` 등 도메인 입력 모델과 `/approval/submit|approve|reject|recall|resubmit|history` 호출을 Data 레이어에 도입했다. 기존 `create/update` 경로는 레거시 화면이 교체될 때까지 병행 유지한다.
|
||||
- (2025-11-01) `ApprovalHistoryController`가 감사 로그·카탈로그 기반 코드→ID 캐싱(`_auditActions`→`_actionIdsByCode`)과 `ApiClient.buildQuery` `filters` 매개변수를 적용해 `approval_action_id`/`action_from`/`action_to`를 전송하며, 위젯 테스트(`approval_history_page_test.dart`)로 회귀를 감시한다.
|
||||
|
||||
## 문서 동기화 규칙
|
||||
1. `superport_api_v2` 리포지터리의 `stock_approval_system_*.md` 문서를 단일 소스로 간주하고, 수정은 반드시 백엔드 리포지터리에서 먼저 수행한다.
|
||||
2. 백엔드 문서 변경 후 프론트 리포지터리 루트에서 `tool/sync_stock_docs.sh`를 실행해 `doc/` 경로를 갱신한다. CI 또는 로컬 검증 시에는 `tool/sync_stock_docs.sh --check`로 차이를 확인한다.
|
||||
3. 문서 차이가 감지되면 동기화 커밋을 생성하고 PR 본문에 백엔드 커밋 링크를 포함해 리뷰어가 출처를 추적할 수 있도록 한다.
|
||||
|
||||
## Approval Flow v2 연동 계획 (신규)
|
||||
1. 백엔드 세부 작업 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)과 프런트 작업 계획(`doc/approval_flow_frontend_task_plan.md`)을 기준으로 동시 진행한다.
|
||||
2. 입고/출고/대여 등록 화면은 결재 단계 구성 섹션을 추가하고 제출 요청에 Approval payload를 병합한다.
|
||||
3. 결재 템플릿/이력 메뉴는 `ShadTable` 기반으로 재구성하고 recall/resubmit, 감사 로그 UI를 확장한다.
|
||||
4. Approval 관련 DTO/레포지터리/유즈케이스를 전면 재정비하여 신규 엔드포인트(`/approval/submit|approve|reject|recall|resubmit`, `/approval/templates`)와 계약을 맞춘다.
|
||||
5. 테스트 체계는 위젯/통합 테스트에서 결재 단계 추가/삭제/회수/재상신 플로우를 검증하고, `integration_test`에 시나리오를 추가한다.
|
||||
## Approval Flow v2 연동 계획 (현황)
|
||||
- [x] 백엔드 세부 작업 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)과 프런트 작업 계획(`doc/approval_flow_frontend_task_plan.md`)을 동기화했다.
|
||||
- [x] 입고/출고/대여 등록 화면에 결재 단계 구성 섹션을 추가하고 제출 요청에 Approval payload를 병합했다 (`lib/features/inventory/*/presentation/pages/*_page.dart`).
|
||||
- [x] 결재 템플릿/이력 메뉴를 `ShadTable` 기반으로 재구성하고 recall/resubmit, 감사 로그 UI를 확장했다 (`lib/features/approvals/request/presentation/widgets/`, `lib/features/approvals/history/presentation/pages/approval_history_page.dart`).
|
||||
- [x] Approval 관련 DTO/레포지토리/유즈케이스를 전면 재정비하여 신규 엔드포인트(`/approval/submit|approve|reject|recall|resubmit`, `/approval/templates`)와 계약을 맞췄다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart`, `lib/features/approvals/domain/usecases/*`).
|
||||
- [x] 테스트 체계에 결재 단계 추가/삭제/회수/재상신 위젯·통합 시나리오를 추가했고 `integration_test/approvals_flow_test.dart`로 회귀를 검증한다.
|
||||
|
||||
## 0. 사전 준비 및 브랜치 전략
|
||||
1. 현재 백엔드 서버는 아직 기동되지 않았지만, 모든 기능은 실제 API 계약(`stock_approval_system_api_v4.md`)을 기준으로 구현한다.
|
||||
1. 스테이징/운영 환경 여부와 무관하게 모든 기능은 실제 API 계약(`stock_approval_system_api_v4.md`)을 단일 소스로 삼고, 로컬 개발에서도 동일 계약을 기준으로 구현한다.
|
||||
2. 프론트엔드 작업용 브랜치를 `feature/api-integration` 형태로 생성하고, 단계별 작업이 끝난 뒤 스쿼시 머지한다.
|
||||
3. `.env.development`/`.env.production`에 `API_BASE_URL`을 최신 서버 URL(가용 시)을 기입하고, 베이스 URL에는 버전 prefix(`/api/v1`)가 포함되지 않는다고 주석으로 명시한다.
|
||||
|
||||
@@ -128,11 +129,18 @@
|
||||
- 최종 머지 전 `notify.py` 호출 및 릴리스 노트/환경 파일 확정 프로세스는 배포 승인 시점에 수행하도록 안내를 남긴다.
|
||||
|
||||
- 백엔드 v4 스펙 반영 체크리스트
|
||||
- [ ] 재고 상태 전이 API 회귀 테스트를 `doc/stock_approval_system_api_v4.md` 4.7절 기준으로 재작성하고 submit/approve/reject/cancel/complete 호출 성공 여부를 통합 테스트에 반영한다.
|
||||
- [ ] 그룹-메뉴 권한 복구 API(`POST /group-menu-permissions/{id}/restore`) 시나리오를 복구해 삭제/복구 UI가 `include_deleted=true` 응답을 사용하는지 검증한다.
|
||||
- [ ] 백엔드 배포 확인 후 `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그 해제 시나리오와 운영 전환 체크리스트를 정리한다.
|
||||
- [x] 재고 상태 전이 API 회귀 테스트를 `doc/stock_approval_system_api_v4.md` 4.7절 기준으로 재작성하고 submit/approve/reject/cancel/complete 호출 성공 여부를 통합 테스트에 반영한다.
|
||||
- (2025-11-05) `integration_test/stock_transaction_state_flow_test.dart`에서 가상/실제 환경 모두 `submit → approve → complete`, `submit → cancel`, `submit → reject` 흐름을 검증하도록 리팩터링했다.
|
||||
- 상태 전이 요청 본문에 `note` 필드를 전달하도록 `StockTransactionRepository` 인터페이스와 원격 구현·단위 테스트를 갱신했다.
|
||||
- [x] 그룹-메뉴 권한 복구 API(`POST /group-menu-permissions/{id}/restore`) 시나리오를 복구해 삭제/복구 UI가 `include_deleted=true` 응답을 사용하는지 검증한다.
|
||||
- 삭제 포함 토글이 활성화된 상태에서 복구 후 재조회 시 `include_deleted=true`가 유지되는지를 컨트롤러 단위 테스트로 심사하고, 복구 직후 목록이 최신 상태로 동기화되도록 확인했다.
|
||||
- [x] 백엔드 배포 확인 후 `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그 해제 시나리오와 운영 전환 체크리스트를 정리한다.
|
||||
- 운영 배포 전 점검: (1) 스테이징에서 통합 테스트 승인을 완료하고, (2) 백엔드 릴리스 노트의 마이그레이션 완료 여부를 확인한다.
|
||||
- 배포 직후 절차: `assets/.env.production`의 `FEATURE_STOCK_TRANSITIONS_ENABLED` 값을 `true`로 전환하고 운영 배포 파이프라인에서 해당 파일을 사용해 웹 번들을 재생성한다. 배포 이후 재고 화면의 상신/승인 버튼 노출과 토스트 메시지를 QA 체크리스트에 따라 검증한다.
|
||||
- 롤백 가이드: 장애 발생 시 동일 순서로 토글을 `false`로 되돌리고, 통합 테스트의 `STAGING_RUN_TRANSACTION_FLOW`를 `false`로 설정해 회귀 시나리오를 비활성화한다.
|
||||
|
||||
## 8. 재고 생성 결재 정보 수집 계획 (2024-08-XX 업데이트)
|
||||
> 현황: `StockTransactionApprovalInput`과 인벤토리 컨트롤러 초안 저장 로직이 반영되어 UI/테스트 레벨에서 요구사항을 충족한다 (`lib/features/inventory/*/presentation/controllers/*_controller.dart`, `test/features/inventory/*/presentation/controllers/*_controller_test.dart`).
|
||||
1. **신규 입력 필드 구성**
|
||||
- 입고/출고/대여 등록 모달에 “결재 정보” 섹션을 추가하고 `거래번호`, `결재번호`, `결재 메모`, `결재 요청자` 필드를 배치한다.
|
||||
- 거래번호는 수동 입력 + “번호 자동 생성” 버튼을 제공하고, 후자는 시퀀스 API(백엔드 지원 필요)와 연동한다.
|
||||
|
||||
@@ -1,82 +1,65 @@
|
||||
# 프런트엔드/백엔드 정합성 점검 리포트 (2025-10-21)
|
||||
# 프런트엔드/백엔드 정합성 점검 리포트 (2025-10-23)
|
||||
|
||||
## 개요
|
||||
- 기준 문서: `doc/backup/backend_change_requests.md`와 최신 계약 문서(`doc/stock_approval_system_api_v4.md`)를 토대로 Flutter 프런트(`superport_v2`)와 Rust 백엔드(`superport_api_v2`) 구현을 재검증했다.
|
||||
- 백엔드 팀이 전달한 최신 패치(로그인/트랜잭션, 결재 단계, 대시보드·보고서, 권한)와 `cargo test` 통과 결과를 반영해 실제 로그인 → 대시보드 → 재고/결재 → 보고서/권한 흐름을 다시 점검했다.
|
||||
- Approval Flow 전면 개편 합의를 위해 백엔드 작업 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)과 프런트 작업 계획(`doc/approval_flow_frontend_task_plan.md`)을 신규 작성했다.
|
||||
- 기준 문서: 갱신된 `backend_change_requests.md`(B8-2 완료)와 `stock_approval_system_api_v4.md`(Approval Flow v2 전면 개편 반영)를 토대로 Flutter 프런트(`superport_v2`)와 Rust 백엔드(`superport_api_v2`)의 계약을 재검증했다.
|
||||
- 로그인 → 대시보드 → 재고/결재 → 보고서/권한까지 전 흐름을 재검증하고, 기본 목록 비노출 정책(B5-5) 적용 여부를 코드·테스트로 확인했다.
|
||||
|
||||
## 주요 정합성 결과
|
||||
| 구분 | 내용 | 결과 | 후속 조치 |
|
||||
| 구분 | 내용 | 상태 | 후속 조치 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 대여/출고 `expected_return_date` 저장·조회 | ✅ 해결 (`backend/src/domain/stock_transactions.rs:274`, `backend/src/adapters/repositories/stock_transactions.rs:808`) | 프런트 DTO·폼이 필드를 유지하는지 위젯 테스트로 확인 |
|
||||
| 2 | 결재 단계 `q`·`status_id` 필터 및 `status` 응답 | ✅ 해결 (`backend/src/domain/approval_steps.rs:31`, `backend/src/adapters/repositories/approval_steps.rs:162`) | 검색/필터 UI와 리스트 표시가 새 계약(`status`)을 반영하는지 시나리오 테스트 필요 |
|
||||
| 3 | 결재 단계 요청/응답 내 상태 필드 정규화 | ✅ 해결 (요청 `status_id`, 응답 `status`) | 프런트 DTO(`lib/features/approvals/step/domain/entities/approval_step_input.dart:30`)에서 레거시 `step_status_id` 제거 여부 확인 |
|
||||
| 4 | 보고서 PDF 스트리밍·메타데이터 생성 | ✅ 해결 (`backend/src/api/v1/reports.rs:94`) | `ReportingRepositoryRemote`가 스트림·파일명 메타 처리하는지 합동 점검 |
|
||||
| 5 | 그룹-메뉴 권한 `path`·`is_deleted`·`include_deleted` | ✅ 해결 (`backend/src/domain/group_menu_permissions.rs:149`, `backend/src/adapters/repositories/group_menu_permissions.rs:227`) | DTO/필터·권한 편집 UI가 추가 필드로 회귀 없는지 테스트 |
|
||||
| 6 | 대시보드 KPI `delta` 전일 대비 비율 계산 | ✅ 해결 (`backend/src/adapters/repositories/dashboard.rs:61`) | KPI 카드/차트가 백분율·부호 표시를 지원하는지 확인 |
|
||||
| 7 | 사용자 요약(`created_by`, `requester`) 기본 노출 및 회귀 테스트 | ✅ 해결 (`backend/src/domain/approval_templates.rs:34`, `backend/src/adapters/repositories/approval_templates.rs:100`, `backend/src/adapters/repositories/approvals.rs:878`, `backend/src/adapters/repositories/stock_transactions.rs:1173`, `backend/src/adapters/repositories/reports.rs:256`) | 프런트 DTO가 사번(`employee_id`)·이름을 모두 반영하는지, 리스트/리포트 표시가 정상인지 검증 |
|
||||
| 8 | Approval Flow v2: 트랜잭션 결재 구성 필수화 + `/approval/*` 엔드포인트 확장 | 🚧 진행중 (`backend/src/domain/stock_transactions.rs:365`, `backend/src/domain/approvals/models.rs:583`, `backend/src/api/v1/approval_flow.rs:13`) | 프런트 DTO/리포지토리 확장(`lib/features/inventory/transactions/data/dtos/`, `lib/features/approvals/data/`) 및 기능 토글 기반 UI 연동 필요 |
|
||||
| 1 | Approval Flow v2 API 문서 (`expected_updated_at`, drafts, 이력·지표) | ✅ 해결 | `tool/sync_stock_docs.sh`로 프런트 문서/DTO 재생성 |
|
||||
| 2 | 결재/재고 응답 스키마(상태·메타데이터·중첩 객체) | ✅ 해결 | 프런트 DTO/위젯에 새 필드 반영 및 테스트 추가 |
|
||||
| 3 | 전표 기본 목록 비노출 정책(B5-5) | ✅ 해결 | 기본 목록=승인·완료, 대기 영역은 `status=draft,submitted` 또는 `include_pending`으로 조회 |
|
||||
| 4 | 보고서 Export(PDF/XLSX) 스트리밍·메타데이터 | ✅ 해결 | 감사 로그 확인 및 다운로드 UI 메타 필드 적용 |
|
||||
| 5 | 그룹-메뉴 권한 `route_path`·`is_deleted`·`include_deleted` | ✅ 해결 | 편집 화면에 삭제 항목/경로 노출 및 회귀 테스트 |
|
||||
| 6 | Prometheus 지표(`approval_flow_action_*`) 및 감사 로그 | ✅ 해결 | Ops 대시보드/알림 구성안 수립 |
|
||||
|
||||
아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다.
|
||||
|
||||
## 로그인 & 세션
|
||||
- 변경 없음: 로그인/세션 API는 기존 계약과 동일하며(`backend/src/api/v1/login.rs`), 프런트 `AuthSessionDto` 매핑도 변동이 없다(`lib/features/auth/data/dtos/auth_session_dto.dart:17`).
|
||||
- 체크포인트: 세션 만료 401 처리 시 백엔드 토큰 갱신 로직은 유지되므로, 프런트 재시도/로그아웃 UX를 QA 체크리스트에 유지한다.
|
||||
- 추가 확인: `POST /api/v1/auth/refresh` 오류 메시지가 문서 규격(`token expired`, `token revoked`, `invalid token`)으로 일치하는지 스테이징 로그로 검증한다. 메시지 표준화가 미완료인 경우 `Failure` 매퍼에서 임시 매핑을 추가해야 한다.
|
||||
- `backend/src/api/v1/auth.rs`가 `data.access_token`, `data.refresh_token`, `data.expires_at`, `data.user`, `data.permissions`를 반환하며 오류 메시지는 문서 규격과 일치한다.
|
||||
- 프런트 `AuthSessionDto` 매핑은 유지되지만, 알림 메시지가 영어 키(`invalid credentials`, `token expired` 등)에 맞춰 노출되는지 QA에서 다시 확인한다.
|
||||
- 세션 만료 재로그인 UX는 기존대로 유지하되, 만료/재사용 토큰 구분 안내를 사용자에게 명확히 보여주는지 체크한다.
|
||||
|
||||
## 대시보드
|
||||
- KPI `delta`가 전일 대비 증감률(예: `0.125` → 12.5%)로 채워지며(`backend/src/adapters/repositories/dashboard.rs:61`), 프런트는 % 포맷과 부호를 고려해 렌더링해야 한다(`lib/features/dashboard/presentation/widgets/dashboard_kpi_card.dart`).
|
||||
- `step_summary` 포맷이 `"2단계 / 승인자"`에서 `"2단계 · 승인자"`로 정규화됐다. 문자열을 그대로 노출하는 UI라면 디자인팀과 표시 규칙을 다시 합의한다.
|
||||
- 추가 활동: 대시보드 테스트에서 `delta != null` 기준으로 동작하는 메트릭 뱃지/차트 회귀 여부를 확인한다.
|
||||
- `GET /api/v1/dashboard/summary`가 `kpis[]`, `recent_transactions[]`, `pending_approvals[]`를 제공하고 `delta`·`trend_label`이 문서와 코드에 맞춰 채워진다(`backend/src/api/v1/dashboard.rs`).
|
||||
- 프런트 KPI 카드에서 `delta`가 소수(0.125) → 백분율(12.5%)로 변환되는 로직과 `step_summary` 포맷(`"2단계 · 승인자"`)이 정상 노출되는지 UI 스냅샷 테스트를 업데이트한다.
|
||||
|
||||
## 재고·대여 트랜잭션
|
||||
- `expected_return_date`가 생성/수정/조회 전 흐름에 포함된다(`backend/src/domain/stock_transactions.rs:274`, `backend/src/adapters/repositories/stock_transactions.rs:808`). 프런트 `StockTransactionInput`과 `RentalPage`는 이미 필드를 전송하므로, 저장 후 상세/목록에서 값이 노출되는지 UI 테스트를 추가하면 된다(`lib/features/inventory/rental/presentation/pages/rental_page.dart:1651`).
|
||||
- 마이그레이션 `migration/006_add_expected_return_date_to_stock_transactions.sql`을 반드시 적용해야 하며, 로컬/스테이징 DB에 컬럼이 없으면 500 에러가 발생한다. DevOps와 일정 합의 후 `diesel migration run`을 실행하고 `.env` DB URL을 재확인한다.
|
||||
- 추가 확인: 고객 정보(`customers[].customer`), 거래 라인 메모, 템플릿명 등 선택 필드가 null일 때 키가 빠지지 않는지 샘플 데이터를 확보해 양쪽 DTO 직렬화/역직렬화 테스트를 보강한다.
|
||||
- 최종 승인 완료 전에는 기본 입고/출고/대여 목록에서 전표가 숨겨져야 하므로, 프런트 목록/완료 카드에 `status=draft|submitted` 필터를 추가하고 대기 전용 섹션을 제공한다.
|
||||
- 응답 본문이 거래/창고/라인/고객/결재 요약을 중첩 객체로 반환하고 `quantity`·`unit_price`의 null도 유지한다(`backend/src/api/v1/stock_transactions.rs`).
|
||||
- 결재 전이 API가 `expected_updated_at`, `transaction_expected_updated_at`을 요구하며 최신 `data.transaction`/`data.approval`을 반환한다. 프런트는 낙관적 잠금 실패 시 메시지를 문서에 맞춰 노출해야 한다.
|
||||
- 기본 목록은 승인·완료 상태만 반환하고, 초안·상신 전표는 `status=draft,submitted` 또는 `include_pending=true`로 별도 조회한다. (`backend/src/domain/stock_transactions.rs:74`, `backend/src/adapters/repositories/stock_transactions.rs:45`)
|
||||
|
||||
## 결재 단계
|
||||
- 목록 API가 `q`·`status_id` 필터를 처리하고 응답에 `transaction_no`를 포함한다(`backend/src/adapters/repositories/approval_steps.rs:176`). 프런트 검색 바(`lib/features/approvals/step/presentation/controllers/approval_step_controller.dart`)가 두 파라미터를 전달하는지, 리스트에서 거래번호를 표시하는지 확인한다.
|
||||
- 도메인이 `status` 구조체(`{ id, name, code }`)를 반환한다(`backend/src/domain/approval_steps.rs:84`). 프런트 DTO는 `status_id` 입력과 `status` 응답을 모두 지원해야 하므로, 레거시 필드 제거와 단위 테스트(`test/features/approvals/step/domain/`) 성공 여부를 점검한다.
|
||||
- 컨트롤러/위젯 테스트: 필터링, 상태 변경, 거래번호 표시 흐름을 추가해 회귀를 방지한다.
|
||||
- 추가 확인: `histories[].action`에 레거시 데이터가 들어오는 경우(`id`, `name` 누락) 프런트가 안전하게 폴백 문자열을 표시하는지, 백엔드는 해당 케이스를 데이터 정제 로직으로 보완할지 정한다.
|
||||
- 열람 권한은 상신자와 이미 결재한 승인자에게만 부여된다. 단계 미도달 승인자는 목록/상세 접근 시 403 처리되므로, 프런트 `ApprovalDetailPage`·`MyApprovals`에서 숨김/권한 안내 토스트를 구현하고 최종 승인 대기 상태에서도 상신자·중간 승인자만 접근 가능하도록 필터링한다.
|
||||
## 결재 단계 & 행위
|
||||
- `GET /api/v1/approval-steps`가 `approver_id`, `approval_id`, `status_id`, `q` 필터와 `include=approval,approver,status` 확장을 지원한다. 프런트 컨트롤러가 새 파라미터를 모두 전달하는지 점검한다.
|
||||
- `/approval/**` 행위가 `expected_updated_at`을 요구하고 `data.approval`을 반환하며, Prometheus 지표(`approval_flow_action_total`, `approval_flow_action_duration_seconds`)가 발행된다.
|
||||
- 열람 권한 정책이 상신자·기결재자에게만 상세 접근을 허용하므로, 프런트 `MyApprovals`·`ApprovalDetailPage`에서 403 시나리오 UX를 재확인한다.
|
||||
|
||||
## Approval Flow v2
|
||||
- 입·출·대여 생성 요청에 `approval` 블록이 필수(`backend/src/domain/stock_transactions.rs:365`)이며, `approval.config`는 템플릿(`template_id`) 또는 직접 지정 단계(`steps[]`) 중 하나가 존재해야 한다(`backend/src/domain/stock_transactions.rs:384`). 프런트 입력모델(`lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart:1`)은 `approval`을 선택이 아닌 필수 값으로 승격하고, 최소 1단계 + 최종 승인자 검증을 위젯 레벨에서 선반영해야 한다.
|
||||
- 트랜잭션 목록 기본 필터는 승인 완료 건만 노출하고 `include_pending` 파라미터를 명시해야 대기/초안이 반환된다(`backend/src/domain/stock_transactions.rs:29`, `backend/src/domain/stock_transactions.rs:178`). 프런트 리스트 필터(`lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart:158`)와 대시보드 카드가 새 파라미터를 전달하도록 조정하고, 결재 대기 전표 전용 섹션을 기능 토글(`feature.approval_flow_v2`)로 가드한다.
|
||||
- 결재 제출·재상신 엔드포인트는 `ApprovalSubmitRequest`/`ApprovalResubmitRequest`를 사용하며 전 단계 배열을 전송해야 한다(`backend/src/domain/approvals/models.rs:583`, `backend/src/api/v1/approval_flow.rs:13`). 프런트는 `ApprovalRequestDto`·`ApprovalStepDto`·`ApprovalAuditDto` 신설 후 `ApprovalRepositoryRemote`를 통해 `/approval/submit|resubmit` 호출 시 단계 순번·승인자 ID를 직렬화한다.
|
||||
- 승인/반려/회수 액션은 `actor_id`가 세션 사용자와 일치해야 하고 옵티미스틱 잠금(`expected_updated_at`, `transaction_expected_updated_at`)을 요구한다(`backend/src/domain/approvals/models.rs:624`, `backend/src/domain/approvals/models.rs:634`). 프런트 컨트롤러(`lib/features/approvals/presentation/controllers/approval_history_controller.dart`)는 서버 응답의 `approval.updated_at`을 저장해 재전송 시 파라미터로 포함해야 충돌 409를 피할 수 있다.
|
||||
- 결재 상세 응답은 `current_step`, `steps[].status.is_blocking_next`, `histories[].action_code`를 포함하며 비허용 사용자에게는 `APPROVAL_ACCESS_DENIED`가 반환된다(`doc/stock_approval_system_api_v4.md:1011`, `backend/src/api/v1/approval_flow.rs:42`). DTO 파서(`lib/features/approvals/data/dtos/approval_dto.dart`)에서 새 서브 객체를 맵핑하고, 403 수신 시 접근 제한 안내를 표준 토스트로 노출한다.
|
||||
- 프런트 데이터 계층에 `ApprovalSubmissionInput`/`ApprovalDecisionInput`/`ApprovalRecallInput`/`ApprovalResubmissionInput`을 추가하고, `ApprovalRepositoryRemote.submit|approve|reject|recall|resubmit|listHistory` 메서드를 신규 엔드포인트(`/approval/submit`, `/approval/approve`, `/approval/reject`, `/approval/recall`, `/approval/resubmit`, `/approval/history`)에 맞춰 구현했다. (`lib/features/approvals/domain/entities/approval.dart`, `lib/features/approvals/data/repositories/approval_repository_remote.dart`, 2025-10-29) — 기존 `create/update/assignSteps` 경로는 레거시 호환을 위해 유지하되, F2 단계에서 컨트롤러/유즈케이스를 새 흐름으로 전환할 예정이다.
|
||||
## 결재 플로우 문서 & 모니터링
|
||||
- `stock_approval_system_api_v4.md`가 `/approval`, `/approval-drafts`, 회수/재상신, 이력, 권한 정책, 예상 업데이트 시각(`expected_updated_at`)을 모두 포함한다.
|
||||
- Prometheus 지표/Slack 알림 정책은 `doc/approval_flow_alert_policy.md`에 정리돼 있으니 Ops와 함께 대시보드 구성을 착수한다.
|
||||
- 프런트 문서 동기화(`tool/sync_stock_docs.sh`)와 DTO 리프레시 후 회귀 테스트(`flutter analyze`, `flutter test`) 일정을 맞춘다.
|
||||
|
||||
## 보고서 (PDF)
|
||||
- 백엔드가 PDF를 스트리밍으로 내려주고 파일명·Content-Length·ETag를 헤더에 포함한다(`backend/src/api/v1/reports.rs:94`). 프런트 `ReportingRepositoryRemote`는 `StreamedResponse` 처리를 유지하되, 새 메타데이터(`report_name`, `generated_at`)로 다운로드 UI를 업데이트한다(`lib/features/reporting/presentation/controllers/reporting_controller.dart`).
|
||||
- 단위 테스트(`backend/tests/api_reports_pdf.rs`)가 계약을 고정하고 있으므로, 프런트에서도 PDF 다운로드 및 실패 경로(404/500 등)를 위젯 테스트에 반영한다.
|
||||
- 추가 확인: PDF 다운로드 요청이 감사 로그에 기록되는지 스테이징에서 확인하고, 정책상 필요 시 프런트 다운로드 성공/실패 토스트에 감사 로그 연동 여부를 표시한다.
|
||||
## 보고서 (PDF/XLSX)
|
||||
- `GET /api/v1/reports/transactions|approvals/export`가 스트리밍과 메타데이터 모드를 모두 지원하고, `download_url`, `filename`, `mime_type`, `expires_at`을 반환한다.
|
||||
- 프런트 다운로드 UI는 새 메타 필드를 표시하고, 감사 로그(Download 요청 시 이벤트 남는지) 결과를 스테이징에서 확인한 뒤 UX 안내문구를 보강한다.
|
||||
- 대용량 PDF/XLSX에 대한 합동 테스트를 QA 시나리오에 추가한다.
|
||||
|
||||
## 권한/문서
|
||||
- 그룹-메뉴 권한 API가 `include_deleted=true` 시 삭제 항목을 함께 반환하고 각 항목에 `path`, `is_deleted`가 포함된다(`backend/src/domain/group_menu_permissions.rs:149`). 프런트 DTO(`lib/features/masters/group_permission/data/dtos/group_permission_dto.dart:49`)와 편집 UI가 새 필드를 사용하는지 확인한다.
|
||||
- `doc/stock_approval_system_api_v4.md`가 갱신됐으므로, 프런트 문서는 `tool/sync_stock_docs.sh`로 재동기화한다.
|
||||
- 추가 확인: 그룹-메뉴 배치 업데이트 API가 변경 이력을 남기는지 백엔드 로그로 점검하고, 프런트 편집 시 이력 누락에 대비한 사용자 안내를 준비한다.
|
||||
- `GET /api/v1/group-menu-permissions`가 `include_deleted`를 허용하고 `route_path`, `path`, `is_deleted`를 응답에 포함한다. 프런트 DTO와 편집 화면이 삭제 항목을 구분 표시하는지 확인한다.
|
||||
- 백엔드/프런트 문서가 모두 최신 스펙을 참조하도록, `backend_change_requests.md`와 프런트 대응 문서를 동시에 업데이트한 후 공유한다.
|
||||
|
||||
## 공동 액션 아이템
|
||||
| 구분 | 작업 내용 | 담당 | 상태 | 비고 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| DB | `006_add_expected_return_date_to_stock_transactions.sql` 적용 확인 | 백엔드 | 진행 예정 | 스테이징 DB 스키마 점검 후 공유 |
|
||||
| 결재 | 단계 검색(`q`, `status_id`)·거래번호 노출 통합 테스트 | 프런트/백엔드 | 준비 | 계약 데이터 샘플 확보 필요 |
|
||||
| 결재 | `histories.action` 레거시 데이터 폴백 처리 협의 | 프런트/백엔드 | 준비 | 데이터 정제 vs UI 폴백 선택 |
|
||||
| 결재 | 열람 권한/대기 전표 노출 제한 구현 (`draft`,`submitted` 접근 제어) | 프런트/백엔드 | 준비 | API 403/필터 명세 동기화 |
|
||||
| 보고서 | Approvals/Transactions PDF 스트리밍 합동 점검 | 프런트/백엔드 | 준비 | 대용량 파일·감사 로그 확인 |
|
||||
| 보고서 | 감사 로그 정책 준수 여부 재확인 | 백엔드 | 준비 | 정책 준수 결과 문서화 |
|
||||
| 결재 | Approval Flow 작업 계획 상호 공유(`doc/approval_flow_frontend_task_plan.md`) | 프런트/백엔드 | 완료 | 백엔드 문서(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)와 동기화 |
|
||||
| QA | `flutter analyze`, `flutter test --coverage` 회귀 실행 후 공유 | 프런트 | 준비 | DTO/테스트 수정 후 `notify.py` 발송 |
|
||||
| QA | `cargo test` + 통합 시나리오 스크립트 재실행 | 백엔드 | 준비 | 보고서/결재 단계 회귀 포함 |
|
||||
## 일정 & 의존성
|
||||
| 구분 | 작업 내용 | 담당 | 상태 | 목표일(제안) | 비고 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 결재 | 전표 기본 목록 비노출 정책 구현(B5-5) | 백엔드 | ✅ 완료 | 2025-10-24 | 기본 필터/테스트 반영 완료, 대기 목록은 상태 필터로 조회 |
|
||||
| 결재 | 목록 비노출 UI/필터 적용 및 QA 시나리오 | 프런트 | 🗓️ 예정 | 2025-10-25 | 백엔드 배포 직후 테스트 연동 |
|
||||
| 문서 | `tool/sync_stock_docs.sh` 실행 및 DTO 재생성 | 프런트 | 🗓️ 예정 | 2025-10-23 | 문서/코드 차이 검출 후 PR 공유 |
|
||||
| QA | Approval Flow 통합 테스트 재실행 (`cargo test`) | 백엔드 | 🗓️ 예정 | 2025-10-24 | 보고서/결재 전이 회귀 포함 |
|
||||
| QA | `flutter analyze`, `flutter test --coverage` | 프런트 | 🗓️ 예정 | 2025-10-26 | 새 DTO·UI 반영 후 보고 |
|
||||
| Ops | Prometheus 지표 대시보드 구성 | 백엔드/Ops | 🗓️ 예정 | 2025-10-28 | `approval_flow_action_*` 메트릭 시각화 |
|
||||
|
||||
## 테스트 & 다음 단계
|
||||
- Approval Flow 개선 과제는 `doc/approval_flow_frontend_task_plan.md`를 기준으로 우선순위를 재정정하고 백엔드 진행 상황과 주별 체크인을 맞춘다.
|
||||
- 백엔드 `cargo test` 통과 보고가 공유됐지만, 프런트 QA 관점에서는 다음을 진행한다.
|
||||
- 새 마이그레이션(`006_add_expected_return_date_to_stock_transactions.sql`) 적용 → 스테이징 DB 반영 상태 확인.
|
||||
- 결재 단계 검색(`q`, `status_id`), 거래번호 노출, 결재 열람 권한 제한, 보고서 PDF 다운로드를 프런트/백엔드 합동 점검.
|
||||
- `flutter analyze`, `flutter test --coverage`로 DTO·테스트 변경 이후 회귀 여부 확인.
|
||||
- 모든 작업을 마치면 `notify.py` 워크플로를 통해 완료 알림을 발송한다.
|
||||
- 백엔드는 기본 필터 동작 검증 후 `cargo fmt`, `cargo check`, `cargo test` 결과를 공유한다.
|
||||
- 프런트는 문서/DTO 동기화 이후 Approval Flow UI·보고서 다운로드·권한 편집 시나리오를 회귀 테스트로 보강한다.
|
||||
- 양측 모두 QA 완료 시 `notify.py` 워크플로로 완료 알림을 발송하고, 남은 일정(B8-4, B8-5, B9-x)을 공유 캘린더에 업데이트한다.
|
||||
|
||||
28
doc/qa/approval_flow_uat_checklist.md
Normal file
28
doc/qa/approval_flow_uat_checklist.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Approval Flow UAT 체크리스트
|
||||
|
||||
## 1. 환경 준비
|
||||
- [ ] 스테이징 API `FEATURE_APPROVALS_ENABLED=true`, `FEATURE_STOCK_TRANSITIONS_ENABLED=true` 상태를 확인한다.
|
||||
- [ ] 통합 테스트 토큰/식별자(`STAGING_*`)를 최신 값으로 교체하고 `flutter test integration_test/stock_transaction_state_flow_test.dart`를 드라이런한다.
|
||||
- [ ] 결재 템플릿/승인자 마스터 데이터가 스테이징과 동기화되어 있는지 확인한다.
|
||||
|
||||
## 2. 핵심 플로우 검증
|
||||
1. **입고 상신/승인**
|
||||
- [ ] 입고 전표를 작성해 상신 → 승인 → 완료까지 진행하고 상태 변경/결재 이력을 확인한다.
|
||||
2. **반려/재상신**
|
||||
- [ ] 동일 전표를 반려 처리 후 요청자가 수정·재상신하여 승인까지 재진행한다.
|
||||
3. **회수(Recall)**
|
||||
- [ ] 승인 대기 상태에서 작성자가 회수한 뒤 수정 후 재상신 시 정상 동작하는지 확인한다.
|
||||
4. **취소(Cancel)**
|
||||
- [ ] 상신 직후 취소 시 상태가 초안(또는 취소)으로 복귀하고 결재 단계가 비워지는지 확인한다.
|
||||
5. **템플릿 CRUD**
|
||||
- [ ] 결재 템플릿 생성/수정/삭제/복구 후 전표에 적용되며 단계 구성·승인자 배정이 유지되는지 확인한다.
|
||||
6. **대시보드/승인 목록 반영**
|
||||
- [ ] 상신/승인/반려 이벤트 후 대시보드 및 결재 목록에 실시간으로 반영되는지 확인한다.
|
||||
|
||||
## 3. 예외/권한 시나리오
|
||||
- [ ] 승인 대상이 아닌 사용자로 결재 목록/상세 조회 시 `APPROVAL_ACCESS_DENIED` 토스트와 대시보드 리다이렉트가 동작한다.
|
||||
- [ ] 삭제된 결재 템플릿/전표를 복구했을 때 `include_deleted=true` 목록에서 재노출되는지 확인한다.
|
||||
|
||||
## 4. 보고 및 마무리
|
||||
- [ ] 각 시나리오별 기대 결과/실측 결과를 QA 스프레드시트에 기록한다.
|
||||
- [ ] 확인 완료 후 `doc/approval_flow_frontend_task_plan.md`의 F8-2 항목에 일자와 상태를 업데이트한다.
|
||||
@@ -1542,7 +1542,7 @@
|
||||
- `POST /approvals/5001/restore`
|
||||
|
||||
### 5.9 결재 이력 조회
|
||||
`GET /approval-histories?approval_id=5001&include=approval,step,approver`
|
||||
`GET /approval-histories?approval_id=5001`
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
@@ -1607,6 +1607,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
기본 응답에는 `approval`, `step`, `approval_action`, `approver`, `from_status`, `to_status` 서브 오브젝트가 포함되며, 추가 정보가 필요하지 않은 경우 `include` 파라미터를 생략해도 동일한 페이로드를 수신한다. `approval_action_id` 필터는 정수 ID 기준으로 동작하므로, 클라이언트는 사전에 제공된 행위 메타데이터로 코드 → ID 매핑을 수행한 뒤 요청해야 한다.
|
||||
|
||||
### 5.10 단계 개별 CRUD
|
||||
- `GET /approval-steps?approval_id=5001&include=approval,approver,status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 `approval`, `approver`, `status` 서브 오브젝트를 선택적으로 포함한다.
|
||||
- `GET /approval-steps/7001?include=approval,approver,status` → `{ data: { ... } }`.
|
||||
@@ -1618,10 +1620,11 @@
|
||||
주요 필터 및 확장 파라미터:
|
||||
|
||||
- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`(정수 ID), `status_id`
|
||||
- `q`(결재번호·승인자 검색), `action_from`, `action_to` (ISO8601 UTC)
|
||||
- `action_from`, `action_to` (ISO8601 UTC). 문자열 검색 파라미터 `q`는 2025-11-01 기준 제공되지 않으며, 도입 시 본 문서를 갱신한다.
|
||||
- `sort=action_at|created_at|updated_at`, `order=asc|desc`
|
||||
- `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step`, `status` 토큰으로 확장
|
||||
- `include` 기본값은 `approval,step,approval_action,approver,from_status,to_status`이며, `status` 토큰으로 응답을 확장할 수 있다.
|
||||
- 응답은 `action` 오브젝트에 `name`/`code`를, 루트 레벨에 `action_code`를 포함하여 감사 행위 식별자를 일관되게 노출한다.
|
||||
- 프런트엔드는 `approval_action_id` 정수 필터를 사용해야 하며, `approval_action.code`만으로는 필터링이 되지 않는다.
|
||||
|
||||
`GET /approval-histories/91001?include=approval,step`
|
||||
```json
|
||||
@@ -1680,6 +1683,78 @@
|
||||
}
|
||||
```
|
||||
|
||||
### 5.11 결재 초안 API (`/approval-drafts`)
|
||||
- 상신자가 작성 중이던 결재 구성을 서버에 저장하고, 다른 세션에서 복구할 수 있도록 지원한다.
|
||||
- 초안은 `requester_id`(상신자) 기준으로 구분되며 기본 목록은 유효(`status=active`) 초안만 반환한다. 만료된 초안을 함께 조회하려면 `include_expired=true`를 전달한다.
|
||||
|
||||
`GET /approval-drafts?requester_id=7`
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 88001,
|
||||
"request_id": null,
|
||||
"transaction_id": 91005,
|
||||
"requester_id": 7,
|
||||
"template_id": 1201,
|
||||
"title": "입고 결재 초안",
|
||||
"summary": "서류 미완료",
|
||||
"status": "active",
|
||||
"saved_at": "2025-01-04T05:10:00Z",
|
||||
"expires_at": "2025-01-06T05:10:00Z",
|
||||
"session_key": "draft-session-123",
|
||||
"step_count": 2
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"page_size": 50,
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
`POST /approval-drafts`
|
||||
```json
|
||||
{
|
||||
"requester_id": 7,
|
||||
"transaction_id": 91005,
|
||||
"template_id": 1201,
|
||||
"title": "입고 결재 초안",
|
||||
"summary": "서류 미완료",
|
||||
"note": "재고 파악 필요",
|
||||
"session_key": "draft-session-123",
|
||||
"steps": [
|
||||
{ "step_order": 1, "approver_id": 21, "is_optional": false },
|
||||
{ "step_order": 2, "approver_id": 34, "is_optional": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`POST /approval-drafts/88001/restore`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 88001,
|
||||
"requester_id": 7,
|
||||
"transaction_id": 91005,
|
||||
"template_id": 1201,
|
||||
"payload": {
|
||||
"title": "입고 결재 초안",
|
||||
"summary": "서류 미완료",
|
||||
"note": "재고 파악 필요",
|
||||
"status": "draft",
|
||||
"steps": [
|
||||
{ "step_order": 1, "approver_id": 21, "is_optional": false },
|
||||
{ "step_order": 2, "approver_id": 34, "is_optional": false, "note": "재무 확인" }
|
||||
]
|
||||
},
|
||||
"saved_at": "2025-01-04T05:10:00Z",
|
||||
"expires_at": "2025-01-06T05:10:00Z",
|
||||
"session_key": "draft-session-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
- 초안 삭제는 `DELETE /approval-drafts/{id}?requester_id=<상신자 ID>`를 호출하며 `204 No Content`가 응답된다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 결재 템플릿 API
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- OpenAPI 재생성(`backend/docs/openapi.generated.json`) 및 문서 싱크 확인.
|
||||
|
||||
## 단계별 작업 순서
|
||||
1. **마이그레이션 작성 (`migration/010_migrate_employees_to_users.sql`)**
|
||||
1. **마이그레이션 작성 (`migration/080_schema_migrate_employees_to_users.sql`)**
|
||||
- 컬럼/테이블 rename, 새 컬럼 추가, 인덱스/트리거 재정의, 데이터 정규화.
|
||||
2. **도메인/리포지토리/엔티티 리팩터링**
|
||||
- `Employee*` 구조체 및 레포지토리를 `User*`로 일괄 교체.
|
||||
@@ -45,7 +45,7 @@
|
||||
- 통합 테스트 및 `cargo check`, `cargo fmt`, `cargo clippy`, `cargo test` 수행.
|
||||
|
||||
## 진행 현황 (2025-01-07)
|
||||
- [x] `migration/010_migrate_employees_to_users.sql` 작성 및 컬럼/인덱스/트리거 갱신.
|
||||
- [x] `migration/080_schema_migrate_employees_to_users.sql` 작성 및 컬럼/인덱스/트리거 갱신.
|
||||
- [x] 도메인/레포지토리/인증 계층을 `users` 기준으로 리팩터링하고 비밀번호/사번 검증 로직 반영.
|
||||
- [x] `/api/v1/users` + `/users/me` + `/users/{id}/reset-password` 등 사용자 API 구현 및 기존 `/employees` 제거.
|
||||
- [x] 인증 토큰 강제 갱신 로직과 세션 무효화 훅 연동.
|
||||
@@ -53,7 +53,7 @@
|
||||
- [x] 문서(`stock_approval_system_api_v4.md`, `stock_approval_system_spec_v4.md`, alignment 보고서) 최종 검수.
|
||||
|
||||
## 중단 대비 메모
|
||||
- `migration/010_migrate_employees_to_users.sql`이 적용된 상태이므로 롤백 시 `employees`→`users` rename 전후 스키마 차이를 반드시 확인할 것.
|
||||
- `migration/080_schema_migrate_employees_to_users.sql`이 적용된 상태이므로 롤백 시 `employees`→`users` rename 전후 스키마 차이를 반드시 확인할 것.
|
||||
- `/api/v1/users` 엔드포인트가 활성화되어 있으며, JWT `pwd_updated_at` 클레임 기반 세션 무효화가 도입되어 이전 토큰은 비밀번호 변경 직후 사용 불가하다.
|
||||
- 승인/거래/리포트 모듈에서 사용자요약을 읽어가는 경로를 전수 점검 중이므로, 후속 담당자는 변경된 도메인 구조(`ApprovalUserSummary`, `StockTransactionUserSummary` 등)를 참고해 릴레이션 누락이 없는지 점검할 것.
|
||||
- 리포트/승인/재고 레이어의 사용자 요약 회귀 테스트가 `backend/src/adapters/repositories/` 모듈에 추가돼 있으니 실패 시 최근 사용자 필드 변경 여부부터 확인한다.
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
입고 등록/사용자 관리 기능에서 작성자(로그인 사용자) 정보를 정확히 추적하고, 관리자가 신규 사용자를 생성·관리할 수 있도록 백엔드/프런트엔드 동시 진행 항목을 정리했습니다. 기존 인증/세션 흐름과 충돌할 수 있으므로, 아래 항목을 참고해 단계별 검증을 병행하세요.
|
||||
|
||||
## 결재 플로우 용어 및 권한 매핑 (Approval Flow v2)
|
||||
- **결재 요청자(Submitter)**: 트랜잭션 작성 시 자동으로 지정되는 사용자. 본인 단계에 대한 회수/재상신만 수행할 수 있으며, 결재 진행 현황을 전체 조회할 수 있다. (참조 문서: `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`)
|
||||
- **중간 승인자(Intermediate Approver)**: 순차 결재 단계에 배치되는 승인자. 지정된 단계에 도달했을 때만 승인/반려를 수행할 수 있으며, 중복 배치가 허용되지 않는다. (필요 권한: `approval.approve`)
|
||||
- **최종 승인자(Final Approver)**: 결재 완료 여부를 확정하는 마지막 승인자. 결재 플로우 생성 시 반드시 포함되어야 하며, 승인 후 감사 로그가 기록된다. (필요 권한: `approval.approve`)
|
||||
- **결재 관리자(Approval Manager)**: 결재 템플릿 생성/수정, 결재 요청 강제 종료 등 관리 기능을 실행하는 역할. 기능 토글(`feature.approval_flow_v2`) 활성화 시 백오피스에서 구성하며, 권한 키는 `approval.manage`로 통일한다.
|
||||
- **감사 뷰어(Audit Viewer)**: 모든 결재 요청/이력에 대한 열람 권한을 가진 역할. 운영/QA용 계정에 부여하며, 권한 키는 `approval.view_all`을 사용한다.
|
||||
- **슈퍼 관리자(Super Admin, terabits)**: 시스템 전역을 읽기 전용으로 조회할 수 있는 최고 권한. 결재 이력 수정은 금지되며, 실운영에서 데이터 정제 시 지원 역할로만 사용한다.
|
||||
|
||||
## 요구사항 요약
|
||||
- 작성자는 현재 로그인한 사용자 계정을 사용한다. (기존 더미 계정 `terabits`는 최고 관리자 계정으로 유지하며 삭제하지 않는다)
|
||||
- 신규 사용자는 관리자만 등록할 수 있으며, 필수 입력 필드는 `employee_id`, `name`, `phone`, `email`, `password`.
|
||||
@@ -74,8 +82,8 @@
|
||||
- 메일 발송 실패 시 재시도 큐를 3회까지 두고, 실패 알림을 Slack/Notify로 전송한다.
|
||||
|
||||
### 7. 테스트 & 배포 체크리스트
|
||||
- [x] 사용자 생성, 자기 정보 수정, 관리자 초기화 API 통합 테스트 작성. (테스트: `test/features/masters/user/data/user_repository_remote_test.dart`)
|
||||
- [x] 비밀번호 정책 유효성 테스트 (허용/거부 케이스) 구현. (테스트: `test/core/validation/password_rules_test.dart`)
|
||||
- [ ] 사용자 생성, 자기 정보 수정, 관리자 초기화 API 통합 테스트 작성.
|
||||
- [ ] 비밀번호 정책 유효성 테스트 (허용/거부 케이스) 구현.
|
||||
- [ ] 마이그레이션 스크립트와 롤백 스크립트 준비.
|
||||
- [ ] 배포 전 staging에서 실제 메일 발송 여부 검증.
|
||||
- [ ] 기존 로그인 세션/토큰 구조와 충돌 여부 점검.
|
||||
@@ -91,7 +99,6 @@
|
||||
- 생성 성공 후 토스트 및 리스트 리프레시, 임시 비밀번호가 이메일로 발송됨을 명시한다.
|
||||
- UI 구현 시 `lib/features/user_management/presentation/widgets/shad_user_table.dart`와 `ShadTable` 컴포넌트를 기반으로 열 구성을 추가한다.
|
||||
- 상태 관리는 `lib/features/user_management/presentation/controllers/user_controller.dart`에 `createUser` 액션을 확장하고, 에러 핸들링을 중앙화한다.
|
||||
- (2025-10-24) 신규 등록 모달에 임시 비밀번호/이메일/연락처 필수 검증을 적용하고 목록 액션에 비밀번호 재설정 버튼과 확인 다이얼로그를 추가했다. (`lib/features/masters/user/presentation/pages/user_page.dart`, 테스트: `test/features/masters/user/presentation/pages/user_page_test.dart`)
|
||||
|
||||
### 2. 관리자 > 사용자 상세 보기
|
||||
- 비밀번호 재설정 버튼 추가: 클릭 시 확인 다이얼로그 → API 호출 → 성공 토스트.
|
||||
@@ -106,7 +113,6 @@
|
||||
- 비밀번호 변경 진입 버튼을 분리하고, 모달 또는 전용 페이지에서 3개 입력 필드를 제공한다.
|
||||
- `lib/features/profile/presentation/pages/profile_page.dart`에서 폼 상태와 검증 로직을 `lib/features/profile/presentation/controllers/profile_controller.dart`로 분리한다.
|
||||
- `lib/core/validation/password_rules.dart`에 비밀번호 정책 검증 유틸을 추가하고, 모든 비밀번호 입력 필드에서 재사용한다.
|
||||
- (2025-10-24) 상단 내 정보 모달에 이메일/연락처 저장과 비밀번호 변경(강제 로그아웃) 흐름을 구현하고 테스트를 추가했다. (`lib/widgets/app_shell.dart`, 테스트: `test/widgets/app_shell_test.dart`)
|
||||
|
||||
### 4. 비밀번호 변경 플로우
|
||||
- `현재 비밀번호`, `새 비밀번호`, `새 비밀번호 확인` 필드와 실시간 정책 검증(대/소문자, 숫자, 특수문자) UI를 구현한다.
|
||||
|
||||
@@ -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: '취소'),
|
||||
|
||||
@@ -34,7 +34,11 @@ class FeatureFlags {
|
||||
),
|
||||
'approval_flow_v2': _readFlag(
|
||||
'FEATURE_APPROVAL_FLOW_V2',
|
||||
aliases: const ['feature.approval_flow_v2'],
|
||||
aliases: const [
|
||||
'feature.approval_flow_v2',
|
||||
'FEATURES_APPROVAL_FLOW_V2',
|
||||
'features.approval_flow_v2',
|
||||
],
|
||||
defaultValue: false,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_error.dart';
|
||||
import 'package:superport_v2/core/network/api_routes.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/errors/approval_access_denied_exception.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../dtos/approval_audit_dto.dart';
|
||||
import '../dtos/approval_dto.dart';
|
||||
@@ -55,12 +57,14 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
if (includePending) 'include_pending': includePending,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
return _guardApprovalAccess(() async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
});
|
||||
}
|
||||
|
||||
/// 결재 상세를 조회한다. 단계/이력 포함 여부를 쿼리 파라미터로 제어한다.
|
||||
@@ -78,12 +82,14 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final query = ApiClient.buildQuery(include: includeParts);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiClient.buildPath(_basePath, [id]),
|
||||
query: query.isEmpty ? null : query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
return _guardApprovalAccess(() async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiClient.buildPath(_basePath, [id]),
|
||||
query: query.isEmpty ? null : query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -544,4 +550,83 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
});
|
||||
return histories;
|
||||
}
|
||||
|
||||
Future<T> _guardApprovalAccess<T>(Future<T> Function() action) async {
|
||||
try {
|
||||
return await action();
|
||||
} on ApiException catch (error) {
|
||||
if (_isApprovalAccessDenied(error)) {
|
||||
throw ApprovalAccessDeniedException(
|
||||
message: _accessDeniedMessage(error),
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isApprovalAccessDenied(ApiException error) {
|
||||
if (error.code != ApiErrorCode.forbidden) {
|
||||
return false;
|
||||
}
|
||||
final serverCode = _extractServerErrorCode(error);
|
||||
if (serverCode != null &&
|
||||
serverCode.toUpperCase() == 'APPROVAL_ACCESS_DENIED') {
|
||||
return true;
|
||||
}
|
||||
final message = error.message.trim().toLowerCase();
|
||||
if (message.contains('approval access denied')) {
|
||||
return true;
|
||||
}
|
||||
final reasons = error.details?.values
|
||||
.whereType<String>()
|
||||
.map((value) => value.toLowerCase())
|
||||
.toList(growable: false);
|
||||
if (reasons != null &&
|
||||
reasons.any((value) => value.contains('approval access denied'))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _accessDeniedMessage(ApiException error) {
|
||||
final message = error.message.trim();
|
||||
if (message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
final code = _extractServerErrorCode(error);
|
||||
if (code != null && code.isNotEmpty) {
|
||||
return '결재를 조회할 권한이 없습니다. (code: $code)';
|
||||
}
|
||||
return '결재를 조회할 권한이 없습니다. 관리자에게 권한을 요청하세요.';
|
||||
}
|
||||
|
||||
String? _extractServerErrorCode(ApiException error) {
|
||||
final details = error.details;
|
||||
if (details != null) {
|
||||
final detailCode = details['code'] ?? details['error_code'];
|
||||
if (detailCode is String && detailCode.trim().isNotEmpty) {
|
||||
return detailCode.trim();
|
||||
}
|
||||
}
|
||||
final data = error.cause?.response?.data;
|
||||
return _readErrorCodeFromPayload(data);
|
||||
}
|
||||
|
||||
String? _readErrorCodeFromPayload(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final direct = data['error_code'] ?? data['code'];
|
||||
if (direct is String && direct.trim().isNotEmpty) {
|
||||
return direct.trim();
|
||||
}
|
||||
final errorNode = data['error'];
|
||||
if (errorNode is Map<String, dynamic>) {
|
||||
final nested = errorNode['code'] ?? errorNode['error_code'];
|
||||
if (nested is String && nested.trim().isNotEmpty) {
|
||||
return nested.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../../../../core/network/api_error.dart';
|
||||
|
||||
/// 결재 열람 권한이 없을 때 던지는 예외.
|
||||
///
|
||||
/// - 목록/상세 API가 `403`(`APPROVAL_ACCESS_DENIED`)을 반환하면 이 예외로 변환한다.
|
||||
class ApprovalAccessDeniedException implements Exception {
|
||||
const ApprovalAccessDeniedException({
|
||||
this.message = '결재를 조회할 권한이 없습니다.',
|
||||
this.cause,
|
||||
});
|
||||
|
||||
/// 사용자에게 노출할 안내 메시지.
|
||||
final String message;
|
||||
|
||||
/// 원본 API 예외.
|
||||
final ApiException? cause;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApprovalAccessDeniedException(message: $message, cause: $cause)';
|
||||
}
|
||||
@@ -15,6 +15,14 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/approval-histories';
|
||||
static const _defaultInclude = <String>[
|
||||
'approval',
|
||||
'step',
|
||||
'approval_action',
|
||||
'approver',
|
||||
'from_status',
|
||||
'to_status',
|
||||
];
|
||||
|
||||
/// 결재 이력 목록을 조회한다.
|
||||
@override
|
||||
@@ -22,18 +30,24 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
String? action,
|
||||
int? approvalActionId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
}) async {
|
||||
final resolvedQuery = ApiClient.buildQuery(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
q: query,
|
||||
include: _defaultInclude,
|
||||
filters: {
|
||||
if (from != null) 'action_from': from,
|
||||
if (to != null) 'action_to': to,
|
||||
if (approvalActionId != null) 'approval_action_id': approvalActionId,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (from != null) 'action_from': from.toIso8601String(),
|
||||
if (to != null) 'action_to': to.toIso8601String(),
|
||||
},
|
||||
query: resolvedQuery.isEmpty ? null : resolvedQuery,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ abstract class ApprovalHistoryRepository {
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
String? action,
|
||||
int? approvalActionId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
DateTime? _auditFrom;
|
||||
DateTime? _auditTo;
|
||||
final Map<String, ApprovalAction> _auditActions = <String, ApprovalAction>{};
|
||||
final Map<String, int> _actionIdsByCode = <String, int>{};
|
||||
bool _hasLoadedActionCatalog = false;
|
||||
bool _isSelectionForbidden = false;
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
|
||||
@@ -101,18 +103,13 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
try {
|
||||
final resolvedPage = _resolvePage(page, _result);
|
||||
final action = switch (_actionFilter) {
|
||||
ApprovalHistoryActionFilter.all => null,
|
||||
ApprovalHistoryActionFilter.approve => 'approve',
|
||||
ApprovalHistoryActionFilter.reject => 'reject',
|
||||
ApprovalHistoryActionFilter.comment => 'comment',
|
||||
};
|
||||
final approvalActionId = await _resolveActionIdForFilter(_actionFilter);
|
||||
|
||||
final response = await _repository.list(
|
||||
page: resolvedPage,
|
||||
pageSize: _pageSize,
|
||||
query: _query.trim().isEmpty ? null : _query.trim(),
|
||||
action: action,
|
||||
approvalActionId: approvalActionId,
|
||||
from: _from,
|
||||
to: _to,
|
||||
);
|
||||
@@ -158,6 +155,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
actionMap.putIfAbsent(code, () => log.action);
|
||||
_actionIdsByCode[code] = log.action.id;
|
||||
}
|
||||
if (actionMap.isNotEmpty) {
|
||||
_auditActions
|
||||
@@ -443,6 +441,60 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
_auditFrom != null ||
|
||||
_auditTo != null;
|
||||
|
||||
Future<int?> _resolveActionIdForFilter(
|
||||
ApprovalHistoryActionFilter filter,
|
||||
) async {
|
||||
final code = _codeForFilter(filter);
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
final cached = _actionIdsByCode[code];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final auditAction = _auditActions[code];
|
||||
if (auditAction != null) {
|
||||
final id = auditAction.id;
|
||||
_actionIdsByCode[code] = id;
|
||||
return id;
|
||||
}
|
||||
await _ensureActionCatalogLoaded();
|
||||
return _actionIdsByCode[code];
|
||||
}
|
||||
|
||||
String? _codeForFilter(ApprovalHistoryActionFilter filter) {
|
||||
return switch (filter) {
|
||||
ApprovalHistoryActionFilter.all => null,
|
||||
ApprovalHistoryActionFilter.approve => 'approve',
|
||||
ApprovalHistoryActionFilter.reject => 'reject',
|
||||
ApprovalHistoryActionFilter.comment => 'comment',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _ensureActionCatalogLoaded() async {
|
||||
if (_hasLoadedActionCatalog) {
|
||||
return;
|
||||
}
|
||||
final approvalRepository = _approvalRepository;
|
||||
if (approvalRepository == null) {
|
||||
_hasLoadedActionCatalog = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final actions = await approvalRepository.listActions();
|
||||
for (final action in actions) {
|
||||
final code = action.code?.trim();
|
||||
if (code == null || code.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
_actionIdsByCode.putIfAbsent(code, () => action.id);
|
||||
}
|
||||
_hasLoadedActionCatalog = true;
|
||||
} catch (_) {
|
||||
// 재시도를 위해 로드 여부 플래그를 유지한다.
|
||||
}
|
||||
}
|
||||
|
||||
int? _resolveAuditActionId() {
|
||||
final code = _auditActionCode?.trim();
|
||||
if (code == null || code.isEmpty) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_draft.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
import '../../domain/errors/approval_access_denied_exception.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../domain/usecases/get_approval_draft_use_case.dart';
|
||||
@@ -90,6 +91,8 @@ class ApprovalController extends ChangeNotifier {
|
||||
ApprovalProceedStatus? _proceedStatus;
|
||||
ApprovalSubmissionInput? _submissionDraft;
|
||||
String? _errorMessage;
|
||||
bool _accessDenied = false;
|
||||
String? _accessDeniedMessage;
|
||||
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
|
||||
int? _transactionIdFilter;
|
||||
int? _requestedById;
|
||||
@@ -114,6 +117,8 @@ class ApprovalController extends ChangeNotifier {
|
||||
bool get isPerformingAction => _isPerformingAction;
|
||||
int? get processingStepId => _processingStepId;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isAccessDenied => _accessDenied;
|
||||
String? get accessDeniedMessage => _accessDeniedMessage;
|
||||
ApprovalStatusFilter get statusFilter => _statusFilter;
|
||||
int? get transactionIdFilter => _transactionIdFilter;
|
||||
int? get requestedById => _requestedById;
|
||||
@@ -169,6 +174,12 @@ class ApprovalController extends ChangeNotifier {
|
||||
|
||||
Map<String, LookupItem> get statusLookup => _statusLookup;
|
||||
|
||||
/// 결재 열람 제한 플래그를 초기화한다.
|
||||
void acknowledgeAccessDenied() {
|
||||
_accessDenied = false;
|
||||
_accessDeniedMessage = null;
|
||||
}
|
||||
|
||||
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
|
||||
///
|
||||
/// [page]가 1보다 작으면 1페이지로 보정한다. 조회 실패 시 [_errorMessage]에
|
||||
@@ -211,8 +222,16 @@ class ApprovalController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
if (error is ApprovalAccessDeniedException) {
|
||||
_accessDenied = true;
|
||||
_accessDeniedMessage = error.message;
|
||||
_result = null;
|
||||
_selected = null;
|
||||
_proceedStatus = null;
|
||||
} else {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
}
|
||||
} finally {
|
||||
_isLoadingList = false;
|
||||
notifyListeners();
|
||||
@@ -391,11 +410,18 @@ class ApprovalController extends ChangeNotifier {
|
||||
await _loadProceedStatus(detail.id!);
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint(
|
||||
'[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}',
|
||||
); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다.
|
||||
_errorMessage = failure.describe();
|
||||
if (error is ApprovalAccessDeniedException) {
|
||||
_accessDenied = true;
|
||||
_accessDeniedMessage = error.message;
|
||||
_selected = null;
|
||||
_proceedStatus = null;
|
||||
} else {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint(
|
||||
'[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}',
|
||||
); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다.
|
||||
_errorMessage = failure.describe();
|
||||
}
|
||||
} finally {
|
||||
_isLoadingDetail = false;
|
||||
notifyListeners();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -88,6 +89,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
InventoryEmployeeSuggestion? _selectedRequester;
|
||||
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
String? _lastAccessDeniedMessage;
|
||||
int? _selectedTemplateId;
|
||||
String? _pendingRouteSelection;
|
||||
|
||||
@@ -138,6 +140,21 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
|
||||
void _handleControllerUpdate() {
|
||||
final error = _controller.errorMessage;
|
||||
if (_controller.isAccessDenied) {
|
||||
final message = _controller.accessDeniedMessage ?? '결재를 조회할 권한이 없습니다.';
|
||||
if (mounted) {
|
||||
if (_lastAccessDeniedMessage != message) {
|
||||
SuperportToast.warning(context, message);
|
||||
_lastAccessDeniedMessage = message;
|
||||
}
|
||||
final router = GoRouter.maybeOf(context);
|
||||
router?.go(dashboardRoutePath);
|
||||
}
|
||||
_controller.acknowledgeAccessDenied();
|
||||
return;
|
||||
} else {
|
||||
_lastAccessDeniedMessage = null;
|
||||
}
|
||||
if (error != null && error != _lastError && mounted) {
|
||||
_lastError = error;
|
||||
SuperportToast.error(context, error);
|
||||
|
||||
@@ -400,11 +400,12 @@ class InboundController extends ChangeNotifier {
|
||||
/// 재고 트랜잭션을 상신(submit)한다.
|
||||
Future<InboundRecord> submitTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.submit(id),
|
||||
() => _transactionRepository.submit(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -414,11 +415,12 @@ class InboundController extends ChangeNotifier {
|
||||
/// 재고 트랜잭션을 완료 처리한다.
|
||||
Future<InboundRecord> completeTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.complete(id),
|
||||
() => _transactionRepository.complete(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -428,11 +430,12 @@ class InboundController extends ChangeNotifier {
|
||||
/// 재고 트랜잭션을 승인 처리한다.
|
||||
Future<InboundRecord> approveTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.approve(id),
|
||||
() => _transactionRepository.approve(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -442,11 +445,12 @@ class InboundController extends ChangeNotifier {
|
||||
/// 재고 트랜잭션을 반려 처리한다.
|
||||
Future<InboundRecord> rejectTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.reject(id),
|
||||
() => _transactionRepository.reject(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -456,11 +460,12 @@ class InboundController extends ChangeNotifier {
|
||||
/// 재고 트랜잭션을 취소 처리한다.
|
||||
Future<InboundRecord> cancelTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.cancel(id),
|
||||
() => _transactionRepository.cancel(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
|
||||
@@ -371,11 +371,12 @@ class OutboundController extends ChangeNotifier {
|
||||
/// 출고 트랜잭션을 상신한다.
|
||||
Future<OutboundRecord> submitTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.submit(id),
|
||||
() => _transactionRepository.submit(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -385,11 +386,12 @@ class OutboundController extends ChangeNotifier {
|
||||
/// 출고 트랜잭션을 완료 처리한다.
|
||||
Future<OutboundRecord> completeTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.complete(id),
|
||||
() => _transactionRepository.complete(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -399,11 +401,12 @@ class OutboundController extends ChangeNotifier {
|
||||
/// 출고 트랜잭션을 승인 처리한다.
|
||||
Future<OutboundRecord> approveTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.approve(id),
|
||||
() => _transactionRepository.approve(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -413,11 +416,12 @@ class OutboundController extends ChangeNotifier {
|
||||
/// 출고 트랜잭션을 반려 처리한다.
|
||||
Future<OutboundRecord> rejectTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.reject(id),
|
||||
() => _transactionRepository.reject(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -427,11 +431,12 @@ class OutboundController extends ChangeNotifier {
|
||||
/// 출고 트랜잭션을 취소 처리한다.
|
||||
Future<OutboundRecord> cancelTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.cancel(id),
|
||||
() => _transactionRepository.cancel(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
|
||||
@@ -408,12 +408,13 @@ class RentalController extends ChangeNotifier {
|
||||
/// 대여/반납 트랜잭션을 상신한다.
|
||||
Future<RentalRecord> submitTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
bool? refreshFilterByRentalTypes,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.submit(id),
|
||||
() => _transactionRepository.submit(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -424,12 +425,13 @@ class RentalController extends ChangeNotifier {
|
||||
/// 대여/반납 트랜잭션을 완료 처리한다.
|
||||
Future<RentalRecord> completeTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
bool? refreshFilterByRentalTypes,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.complete(id),
|
||||
() => _transactionRepository.complete(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -440,12 +442,13 @@ class RentalController extends ChangeNotifier {
|
||||
/// 대여/반납 트랜잭션을 승인 처리한다.
|
||||
Future<RentalRecord> approveTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
bool? refreshFilterByRentalTypes,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.approve(id),
|
||||
() => _transactionRepository.approve(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -456,12 +459,13 @@ class RentalController extends ChangeNotifier {
|
||||
/// 대여/반납 트랜잭션을 반려 처리한다.
|
||||
Future<RentalRecord> rejectTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
bool? refreshFilterByRentalTypes,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.reject(id),
|
||||
() => _transactionRepository.reject(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
@@ -472,12 +476,13 @@ class RentalController extends ChangeNotifier {
|
||||
/// 대여/반납 트랜잭션을 취소 처리한다.
|
||||
Future<RentalRecord> cancelTransaction(
|
||||
int id, {
|
||||
String? note,
|
||||
bool refreshAfter = true,
|
||||
StockTransactionListFilter? refreshFilter,
|
||||
bool? refreshFilterByRentalTypes,
|
||||
}) {
|
||||
return _executeMutation(
|
||||
() => _transactionRepository.cancel(id),
|
||||
() => _transactionRepository.cancel(id, note: note),
|
||||
trackedId: id,
|
||||
refreshAfter: refreshAfter,
|
||||
refreshFilter: refreshFilter,
|
||||
|
||||
@@ -82,50 +82,43 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> submit(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/submit',
|
||||
data: {'id': id},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _parseSingle(response.data);
|
||||
Future<StockTransaction> submit(int id, {String? note}) async {
|
||||
return _postTransition(id: id, action: 'submit', note: note);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> complete(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/complete',
|
||||
data: {'id': id},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _parseSingle(response.data);
|
||||
Future<StockTransaction> complete(int id, {String? note}) async {
|
||||
return _postTransition(id: id, action: 'complete', note: note);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> approve(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/approve',
|
||||
data: {'id': id},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _parseSingle(response.data);
|
||||
Future<StockTransaction> approve(int id, {String? note}) async {
|
||||
return _postTransition(id: id, action: 'approve', note: note);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> reject(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/reject',
|
||||
data: {'id': id},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _parseSingle(response.data);
|
||||
Future<StockTransaction> reject(int id, {String? note}) async {
|
||||
return _postTransition(id: id, action: 'reject', note: note);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> cancel(int id) async {
|
||||
Future<StockTransaction> cancel(int id, {String? note}) async {
|
||||
return _postTransition(id: id, action: 'cancel', note: note);
|
||||
}
|
||||
|
||||
Future<StockTransaction> _postTransition({
|
||||
required int id,
|
||||
required String action,
|
||||
String? note,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{'id': id};
|
||||
final trimmedNote = note?.trim();
|
||||
if (trimmedNote != null && trimmedNote.isNotEmpty) {
|
||||
payload['note'] = trimmedNote;
|
||||
}
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/cancel',
|
||||
data: {'id': id},
|
||||
'$_basePath/$id/$action',
|
||||
data: payload,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _parseSingle(response.data);
|
||||
|
||||
@@ -29,19 +29,29 @@ abstract class StockTransactionRepository {
|
||||
Future<StockTransaction> restore(int id);
|
||||
|
||||
/// 재고 트랜잭션을 상신(submit)한다.
|
||||
Future<StockTransaction> submit(int id);
|
||||
///
|
||||
/// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다.
|
||||
Future<StockTransaction> submit(int id, {String? note});
|
||||
|
||||
/// 재고 트랜잭션을 완료 처리한다.
|
||||
Future<StockTransaction> complete(int id);
|
||||
///
|
||||
/// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다.
|
||||
Future<StockTransaction> complete(int id, {String? note});
|
||||
|
||||
/// 재고 트랜잭션을 승인 처리한다.
|
||||
Future<StockTransaction> approve(int id);
|
||||
///
|
||||
/// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다.
|
||||
Future<StockTransaction> approve(int id, {String? note});
|
||||
|
||||
/// 재고 트랜잭션을 반려 처리한다.
|
||||
Future<StockTransaction> reject(int id);
|
||||
///
|
||||
/// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다.
|
||||
Future<StockTransaction> reject(int id, {String? note});
|
||||
|
||||
/// 재고 트랜잭션을 취소 처리한다.
|
||||
Future<StockTransaction> cancel(int id);
|
||||
///
|
||||
/// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다.
|
||||
Future<StockTransaction> cancel(int id, {String? note});
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 라인 저장소 인터페이스.
|
||||
|
||||
@@ -101,6 +101,16 @@ void main() {
|
||||
approvalRepository = _MockApprovalRepository();
|
||||
recallUseCase = _MockRecallApprovalUseCase();
|
||||
resubmitUseCase = _MockResubmitApprovalUseCase();
|
||||
when(
|
||||
() =>
|
||||
approvalRepository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer(
|
||||
(_) async => [
|
||||
ApprovalAction(id: 11, name: 'approve', code: 'approve'),
|
||||
ApprovalAction(id: 12, name: 'reject', code: 'reject'),
|
||||
ApprovalAction(id: 13, name: 'comment', code: 'comment'),
|
||||
],
|
||||
);
|
||||
controller = ApprovalHistoryController(
|
||||
repository: repository,
|
||||
approvalRepository: approvalRepository,
|
||||
@@ -115,7 +125,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
@@ -130,7 +140,7 @@ void main() {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: null,
|
||||
action: null,
|
||||
approvalActionId: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
@@ -143,7 +153,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
@@ -160,7 +170,7 @@ void main() {
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
query: 'APP',
|
||||
action: 'approve',
|
||||
approvalActionId: 11,
|
||||
from: DateTime(2024, 4, 1),
|
||||
to: DateTime(2024, 4, 30),
|
||||
),
|
||||
@@ -173,7 +183,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
@@ -254,7 +264,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
@@ -282,7 +292,7 @@ void main() {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: null,
|
||||
action: null,
|
||||
approvalActionId: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
@@ -319,7 +329,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
|
||||
@@ -8,12 +8,13 @@ import 'package:shadcn_ui/shadcn_ui.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_flow.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart';
|
||||
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
|
||||
@@ -214,6 +215,17 @@ void main() {
|
||||
sl.registerLazySingleton<ResubmitApprovalUseCase>(() => resubmitUseCase);
|
||||
sl.registerLazySingleton<AuthService>(() => authService);
|
||||
|
||||
when(
|
||||
() =>
|
||||
approvalRepository.listActions(activeOnly: any(named: 'activeOnly')),
|
||||
).thenAnswer(
|
||||
(_) async => [
|
||||
ApprovalAction(id: 11, name: 'approve', code: 'approve'),
|
||||
ApprovalAction(id: 12, name: 'reject', code: 'reject'),
|
||||
ApprovalAction(id: 13, name: 'comment', code: 'comment'),
|
||||
],
|
||||
);
|
||||
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
@@ -259,7 +271,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
@@ -288,13 +300,54 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: 20,
|
||||
query: 'APP-2024',
|
||||
action: null,
|
||||
approvalActionId: null,
|
||||
from: null,
|
||||
to: null,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
testWidgets('행위 필터 선택 시 approval_action_id로 재조회한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
|
||||
final capturedActionIds = <int?>[];
|
||||
when(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
capturedActionIds.add(
|
||||
invocation.namedArguments[#approvalActionId] as int?,
|
||||
);
|
||||
return PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: [record, secondRecord],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 2,
|
||||
);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final controller = tester
|
||||
.widgetList<AnimatedBuilder>(find.byType(AnimatedBuilder))
|
||||
.map((builder) => builder.animation)
|
||||
.whereType<ApprovalHistoryController>()
|
||||
.first;
|
||||
|
||||
controller.updateActionFilter(ApprovalHistoryActionFilter.approve);
|
||||
await controller.fetch(page: 1);
|
||||
await tester.pump();
|
||||
|
||||
expect(capturedActionIds, contains(11));
|
||||
});
|
||||
|
||||
testWidgets('회수 시 상세 재조회 실패 안내를 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
|
||||
@@ -303,7 +356,7 @@ void main() {
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
action: any(named: 'action'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/errors/approval_access_denied_exception.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/get_approval_draft_use_case.dart';
|
||||
@@ -163,6 +164,29 @@ void main() {
|
||||
expect(statusCodes, isNull);
|
||||
});
|
||||
|
||||
test('403 접근 거부 시 accessDenied 플래그를 노출한다', () async {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenThrow(const ApprovalAccessDeniedException(message: '테스트 접근 제한'));
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.isAccessDenied, isTrue);
|
||||
expect(controller.accessDeniedMessage, '테스트 접근 제한');
|
||||
expect(controller.result, isNull);
|
||||
expect(controller.errorMessage, isNull);
|
||||
});
|
||||
|
||||
// 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다.
|
||||
test('필터 전달을 검증한다', () async {
|
||||
controller.updateTransactionFilter(55);
|
||||
@@ -316,6 +340,23 @@ void main() {
|
||||
expect(controller.canProceedSelected, isTrue);
|
||||
});
|
||||
|
||||
test('상세 조회에서 접근 거부 시 accessDenied를 기록한다', () async {
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenThrow(const ApprovalAccessDeniedException(message: '상세 접근 제한'));
|
||||
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
|
||||
expect(controller.isAccessDenied, isTrue);
|
||||
expect(controller.accessDeniedMessage, '상세 접근 제한');
|
||||
expect(controller.selected, isNull);
|
||||
verifyNever(() => repository.canProceed(any()));
|
||||
});
|
||||
|
||||
test('에러 발생 시 errorMessage 설정', () async {
|
||||
when(
|
||||
() => repository.fetchDetail(
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/domain/entities/approval_template.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/errors/approval_access_denied_exception.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/presentation/controllers/approval_controller.dart';
|
||||
@@ -111,6 +112,8 @@ void main() {
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
@@ -137,5 +140,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('승인대기'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('접근 거부 시 경고 토스트를 노출한다', (tester) async {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
transactionId: any(named: 'transactionId'),
|
||||
approvalStatusId: any(named: 'approvalStatusId'),
|
||||
requestedById: any(named: 'requestedById'),
|
||||
statusCodes: any(named: 'statusCodes'),
|
||||
includePending: any(named: 'includePending'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
),
|
||||
).thenThrow(const ApprovalAccessDeniedException(message: '열람 권한이 없습니다.'));
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalPage()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.text('열람 권한이 없습니다.'), findsWidgets);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ void main() {
|
||||
|
||||
final updated = _buildTransaction(statusName: '승인대기');
|
||||
when(
|
||||
() => transactionRepository.submit(any()),
|
||||
() => transactionRepository.submit(any(), note: any(named: 'note')),
|
||||
).thenAnswer((_) async => updated);
|
||||
when(
|
||||
() => transactionRepository.list(filter: any(named: 'filter')),
|
||||
@@ -353,7 +353,10 @@ void main() {
|
||||
|
||||
expect(result.status, equals('승인대기'));
|
||||
expect(controller.records.first.status, equals('승인대기'));
|
||||
verify(() => transactionRepository.submit(initial.id!)).called(1);
|
||||
verify(
|
||||
() =>
|
||||
transactionRepository.submit(initial.id!, note: any(named: 'note')),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('fetchTransactions 실패 시 Failure 메시지를 노출한다', () async {
|
||||
|
||||
@@ -106,7 +106,7 @@ void main() {
|
||||
() => transactionRepository.create(any()),
|
||||
).thenAnswer((_) async => original);
|
||||
when(
|
||||
() => transactionRepository.complete(any()),
|
||||
() => transactionRepository.complete(any(), note: any(named: 'note')),
|
||||
).thenAnswer((_) async => completed);
|
||||
|
||||
await controller.createTransaction(
|
||||
|
||||
@@ -164,7 +164,7 @@ void main() {
|
||||
status: StockTransactionStatus(id: 2, name: '완료'),
|
||||
);
|
||||
when(
|
||||
() => transactionRepository.complete(any()),
|
||||
() => transactionRepository.complete(any(), note: any(named: 'note')),
|
||||
).thenAnswer((_) async => updated);
|
||||
when(
|
||||
() => transactionRepository.list(filter: any(named: 'filter')),
|
||||
|
||||
@@ -215,16 +215,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await repository.submit(10);
|
||||
await repository.submit(10, note: '승인 요청');
|
||||
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: any(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
final payload =
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: captureAny(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured[0]
|
||||
as Map<String, dynamic>;
|
||||
expect(payload['id'], 10);
|
||||
expect(payload['note'], '승인 요청');
|
||||
});
|
||||
|
||||
test('complete는 /complete 엔드포인트를 호출한다', () async {
|
||||
@@ -244,16 +248,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await repository.complete(10);
|
||||
await repository.complete(10, note: '처리 완료');
|
||||
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: any(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
final payload =
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: captureAny(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured[0]
|
||||
as Map<String, dynamic>;
|
||||
expect(payload['id'], 10);
|
||||
expect(payload['note'], '처리 완료');
|
||||
});
|
||||
|
||||
test('approve는 /approve 엔드포인트를 호출한다', () async {
|
||||
@@ -273,16 +281,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await repository.approve(11);
|
||||
await repository.approve(11, note: '승인 확정');
|
||||
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: any(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
final payload =
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: captureAny(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured[0]
|
||||
as Map<String, dynamic>;
|
||||
expect(payload['id'], 11);
|
||||
expect(payload['note'], '승인 확정');
|
||||
});
|
||||
|
||||
test('reject는 /reject 엔드포인트를 호출한다', () async {
|
||||
@@ -302,16 +314,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await repository.reject(12);
|
||||
await repository.reject(12, note: '재작업 필요');
|
||||
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: any(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
final payload =
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: captureAny(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured[0]
|
||||
as Map<String, dynamic>;
|
||||
expect(payload['id'], 12);
|
||||
expect(payload['note'], '재작업 필요');
|
||||
});
|
||||
|
||||
test('cancel은 /cancel 엔드포인트를 호출한다', () async {
|
||||
@@ -331,15 +347,52 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await repository.cancel(13);
|
||||
await repository.cancel(13, note: '상신 취소');
|
||||
|
||||
verify(
|
||||
final payload =
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: captureAny(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured[0]
|
||||
as Map<String, dynamic>;
|
||||
expect(payload['id'], 13);
|
||||
expect(payload['note'], '상신 취소');
|
||||
});
|
||||
|
||||
test('transition note는 공백 입력 시 제외된다', () async {
|
||||
const path = '/api/v1/stock-transactions/99/submit';
|
||||
when(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: any(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).called(1);
|
||||
).thenAnswer(
|
||||
(_) async => Response<Map<String, dynamic>>(
|
||||
data: detailBody(),
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 200,
|
||||
),
|
||||
);
|
||||
|
||||
await repository.submit(99, note: ' ');
|
||||
|
||||
final payload =
|
||||
verify(
|
||||
() => apiClient.post<Map<String, dynamic>>(
|
||||
path,
|
||||
data: captureAny(named: 'data'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured[0]
|
||||
as Map<String, dynamic>;
|
||||
expect(payload['id'], 99);
|
||||
expect(payload.containsKey('note'), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,5 +257,36 @@ void main() {
|
||||
expect(restored, isNotNull);
|
||||
verify(() => permissionRepository.restore(1)).called(1);
|
||||
});
|
||||
|
||||
test('restore 이후 includeDeleted 상태를 유지하며 재조회한다', () async {
|
||||
controller.updateIncludeDeleted(true);
|
||||
when(
|
||||
() => permissionRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
groupId: any(named: 'groupId'),
|
||||
menuId: any(named: 'menuId'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeDeleted: any(named: 'includeDeleted'),
|
||||
),
|
||||
).thenAnswer((_) async => createResult([samplePermission]));
|
||||
when(
|
||||
() => permissionRepository.restore(any()),
|
||||
).thenAnswer((_) async => samplePermission);
|
||||
|
||||
await controller.fetch();
|
||||
await controller.restore(1);
|
||||
|
||||
verify(
|
||||
() => permissionRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
groupId: any(named: 'groupId'),
|
||||
menuId: any(named: 'menuId'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeDeleted: true,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ class _StubStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> submit(int id) async {
|
||||
Future<StockTransaction> submit(int id, {String? note}) async {
|
||||
final failure = _stubConfig.submitFailure;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
@@ -336,22 +336,22 @@ class _StubStockTransactionRepository implements StockTransactionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> complete(int id) async {
|
||||
Future<StockTransaction> complete(int id, {String? note}) async {
|
||||
return _mutateTransaction(id, _applyCompleteStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> approve(int id) async {
|
||||
Future<StockTransaction> approve(int id, {String? note}) async {
|
||||
return _mutateTransaction(id, (transaction) => transaction);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> reject(int id) async {
|
||||
Future<StockTransaction> reject(int id, {String? note}) async {
|
||||
return _mutateTransaction(id, (transaction) => transaction);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StockTransaction> cancel(int id) async {
|
||||
Future<StockTransaction> cancel(int id, {String? note}) async {
|
||||
return _mutateTransaction(id, (transaction) => transaction);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user