From 3e83408aa770bff0f553a4a350f8106c85ec9d74 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 31 Oct 2025 16:43:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(approvals):=20=EA=B2=B0=EC=9E=AC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=B0=A8=EB=8B=A8=20=EB=8C=80=EC=9D=91?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=84=ED=91=9C=20=EC=A0=84=EC=9D=B4=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EC=A0=84=EB=8B=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리 - approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강 - 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트 - 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트 --- doc/API_CLIENT_SPEC.md | 175 ++++++----------- ...lFlow_System_Integration_and_ChangePlan.md | 3 + doc/approval_flow_alignment_review.md | 119 +++++------- doc/approval_flow_frontend_task_plan.md | 12 +- doc/frontend_api_alignment_plan.md | 38 ++-- doc/frontend_backend_alignment_report.md | 105 +++++----- doc/qa/approval_flow_uat_checklist.md | 28 +++ doc/stock_approval_system_api_v4.md | 81 +++++++- doc/user_management_plan.md | 6 +- doc/user_setting.md | 14 +- integration_test/approvals_flow_test.dart | 14 +- .../stock_transaction_state_flow_test.dart | 182 ++++++++++++++---- lib/core/config/feature_flags.dart | 6 +- .../approval_repository_remote.dart | 109 +++++++++-- .../approval_access_denied_exception.dart | 21 ++ .../approval_history_repository_remote.dart | 28 ++- .../approval_history_repository.dart | 2 +- .../approval_history_controller.dart | 66 ++++++- .../controllers/approval_controller.dart | 40 +++- .../presentation/pages/approval_page.dart | 17 ++ .../controllers/inbound_controller.dart | 15 +- .../controllers/outbound_controller.dart | 15 +- .../controllers/rental_controller.dart | 15 +- .../stock_transaction_repository_remote.dart | 55 +++--- .../stock_transaction_repository.dart | 20 +- .../approval_history_controller_test.dart | 26 ++- .../pages/approval_history_page_test.dart | 65 ++++++- .../controllers/approval_controller_test.dart | 41 ++++ .../pages/approval_page_test.dart | 25 +++ .../controllers/inbound_controller_test.dart | 7 +- .../controllers/outbound_controller_test.dart | 2 +- .../controllers/rental_controller_test.dart | 2 +- ...ck_transaction_repository_remote_test.dart | 131 +++++++++---- .../group_permission_controller_test.dart | 31 +++ test/helpers/inventory_test_stubs.dart | 10 +- 35 files changed, 1056 insertions(+), 470 deletions(-) create mode 100644 doc/qa/approval_flow_uat_checklist.md create mode 100644 lib/features/approvals/domain/errors/approval_access_denied_exception.dart diff --git a/doc/API_CLIENT_SPEC.md b/doc/API_CLIENT_SPEC.md index c2548c3..7a7accb 100644 --- a/doc/API_CLIENT_SPEC.md +++ b/doc/API_CLIENT_SPEC.md @@ -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 ` -- 토큰 저장: 보안 저장소(모바일)/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> get( - String path, { - Map? query, - Options? options, - CancelToken? cancelToken, - }); - - Future> post( - String path, { - dynamic data, - Map? query, - Options? options, - CancelToken? cancelToken, - }); - - Future> patch( - String path, { - dynamic data, - Map? query, - Options? options, - CancelToken? cancelToken, - }); - - Future> delete( - String path, { - dynamic data, - Map? query, - Options? options, - CancelToken? cancelToken, - }); + Future> get(String path, {Map? query, Options? options, CancelToken? cancelToken}); + Future> post(String path, {dynamic data, Map? query, Options? options, CancelToken? cancelToken}); + Future> patch(String path, {dynamic data, Map? query, Options? options, CancelToken? cancelToken}); + Future> delete(String path, {dynamic data, Map? query, Options? options, CancelToken? cancelToken}); + static Map buildQuery({...}); + static String buildPath(Object base, [Iterable segments = const []]); } ``` -구현 시 기본 옵션 -- BaseOptions: baseUrl, connectTimeout, receiveTimeout -- 공통 헤더: `Accept: application/json`, `Authorization: Bearer ` -- 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(res, fromJson)`, `parseItem(res, fromJson)` - -## 10) 샘플 사용 (Repository) +- 목록: `{ items, page, page_size, total }` → `PaginatedResult` (`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> 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(res.data, Vendor.fromJson); - } - - @override - Future create(VendorCreate body) async { - final res = await api.post('/vendors', data: body.toJson()); - return parseItem(res.data, Vendor.fromJson); - } -} +final response = await _api.get>( + 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`를 사용한다. diff --git a/doc/ApprovalFlow_System_Integration_and_ChangePlan.md b/doc/ApprovalFlow_System_Integration_and_ChangePlan.md index 2bc468b..29d69df 100644 --- a/doc/ApprovalFlow_System_Integration_and_ChangePlan.md +++ b/doc/ApprovalFlow_System_Integration_and_ChangePlan.md @@ -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=`, `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** diff --git a/doc/approval_flow_alignment_review.md b/doc/approval_flow_alignment_review.md index 0c52168..471ade0 100644 --- a/doc/approval_flow_alignment_review.md +++ b/doc/approval_flow_alignment_review.md @@ -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`)와 이 문서를 동시에 업데이트한다. diff --git a/doc/approval_flow_frontend_task_plan.md b/doc/approval_flow_frontend_task_plan.md index b28fcb7..0518444 100644 --- a/doc/approval_flow_frontend_task_plan.md +++ b/doc/approval_flow_frontend_task_plan.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 체크리스트에 포함했다. --- diff --git a/doc/frontend_api_alignment_plan.md b/doc/frontend_api_alignment_plan.md index 42b2f49..4e03a6c 100644 --- a/doc/frontend_api_alignment_plan.md +++ b/doc/frontend_api_alignment_plan.md @@ -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(백엔드 지원 필요)와 연동한다. diff --git a/doc/frontend_backend_alignment_report.md b/doc/frontend_backend_alignment_report.md index 12da26d..c2b145c 100644 --- a/doc/frontend_backend_alignment_report.md +++ b/doc/frontend_backend_alignment_report.md @@ -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)을 공유 캘린더에 업데이트한다. diff --git a/doc/qa/approval_flow_uat_checklist.md b/doc/qa/approval_flow_uat_checklist.md new file mode 100644 index 0000000..a8c3b5f --- /dev/null +++ b/doc/qa/approval_flow_uat_checklist.md @@ -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 항목에 일자와 상태를 업데이트한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index e692d64..cbcacd6 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -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 diff --git a/doc/user_management_plan.md b/doc/user_management_plan.md index f4ee72c..12744dd 100644 --- a/doc/user_management_plan.md +++ b/doc/user_management_plan.md @@ -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/` 모듈에 추가돼 있으니 실패 시 최근 사용자 필드 변경 여부부터 확인한다. diff --git a/doc/user_setting.md b/doc/user_setting.md index 87b140a..7ec813f 100644 --- a/doc/user_setting.md +++ b/doc/user_setting.md @@ -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를 구현한다. diff --git a/integration_test/approvals_flow_test.dart b/integration_test/approvals_flow_test.dart index bca456d..5bd2a6a 100644 --- a/integration_test/approvals_flow_test.dart +++ b/integration_test/approvals_flow_test.dart @@ -235,7 +235,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository { } @override - Future submit(int id) async { + Future 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 restore(int id) => throw UnimplementedError(); @override - Future complete(int id) => throw UnimplementedError(); + Future complete(int id, {String? note}) => + throw UnimplementedError(); @override - Future approve(int id) => throw UnimplementedError(); + Future approve(int id, {String? note}) => + throw UnimplementedError(); @override - Future reject(int id) => throw UnimplementedError(); + Future reject(int id, {String? note}) => + throw UnimplementedError(); @override - Future cancel(int id) => throw UnimplementedError(); + Future cancel(int id, {String? note}) => + throw UnimplementedError(); } class _FakeApprovalRepository implements ApprovalRepository { diff --git a/integration_test/stock_transaction_state_flow_test.dart b/integration_test/stock_transaction_state_flow_test.dart index 8a38ff4..dffba03 100644 --- a/integration_test/stock_transaction_state_flow_test.dart +++ b/integration_test/stock_transaction_state_flow_test.dart @@ -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 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 submit(int id) async { + Future submit(int id, {String? note}) async { return _updateStatus( id, StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'), @@ -249,7 +355,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository { } @override - Future complete(int id) async { + Future complete(int id, {String? note}) async { return _updateStatus( id, StockTransactionStatus(id: initialStatusId + 2, name: '완료'), @@ -257,7 +363,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository { } @override - Future approve(int id) async { + Future approve(int id, {String? note}) async { return _updateStatus( id, StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'), @@ -265,7 +371,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository { } @override - Future reject(int id) async { + Future reject(int id, {String? note}) async { return _updateStatus( id, StockTransactionStatus(id: initialStatusId + 4, name: '반려'), @@ -273,7 +379,7 @@ class _FakeStockTransactionRepository implements StockTransactionRepository { } @override - Future cancel(int id) async { + Future cancel(int id, {String? note}) async { return _updateStatus( id, StockTransactionStatus(id: initialStatusId + 5, name: '취소'), diff --git a/lib/core/config/feature_flags.dart b/lib/core/config/feature_flags.dart index 1fe12df..ecd6254 100644 --- a/lib/core/config/feature_flags.dart +++ b/lib/core/config/feature_flags.dart @@ -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, ), }); diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index 17025cf..6c6a4c5 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -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>( - _basePath, - query: query, - options: Options(responseType: ResponseType.json), - ); - return ApprovalDto.parsePaginated(response.data ?? const {}); + return _guardApprovalAccess(() async { + final response = await _api.get>( + _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>( - 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>( + 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 _guardApprovalAccess(Future 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() + .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) { + 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) { + final nested = errorNode['code'] ?? errorNode['error_code']; + if (nested is String && nested.trim().isNotEmpty) { + return nested.trim(); + } + } + } + return null; + } } diff --git a/lib/features/approvals/domain/errors/approval_access_denied_exception.dart b/lib/features/approvals/domain/errors/approval_access_denied_exception.dart new file mode 100644 index 0000000..b3553c2 --- /dev/null +++ b/lib/features/approvals/domain/errors/approval_access_denied_exception.dart @@ -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)'; +} diff --git a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart index aa5c988..9250c5f 100644 --- a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart +++ b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart @@ -15,6 +15,14 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository { final ApiClient _api; static const _basePath = '${ApiRoutes.apiV1}/approval-histories'; + static const _defaultInclude = [ + '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>( _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), ); diff --git a/lib/features/approvals/history/domain/repositories/approval_history_repository.dart b/lib/features/approvals/history/domain/repositories/approval_history_repository.dart index 7c9dda6..3bc23ba 100644 --- a/lib/features/approvals/history/domain/repositories/approval_history_repository.dart +++ b/lib/features/approvals/history/domain/repositories/approval_history_repository.dart @@ -9,7 +9,7 @@ abstract class ApprovalHistoryRepository { int page = 1, int pageSize = 20, String? query, - String? action, + int? approvalActionId, DateTime? from, DateTime? to, }); diff --git a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart index cf3697b..2510e2c 100644 --- a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -57,6 +57,8 @@ class ApprovalHistoryController extends ChangeNotifier { DateTime? _auditFrom; DateTime? _auditTo; final Map _auditActions = {}; + final Map _actionIdsByCode = {}; + bool _hasLoadedActionCatalog = false; bool _isSelectionForbidden = false; PaginatedResult? 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 _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 _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) { diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index bc2495e..a621b57 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -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 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(); diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 92e0231..e192ccd 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -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); diff --git a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart index 1c0265a..850a017 100644 --- a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart +++ b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart @@ -400,11 +400,12 @@ class InboundController extends ChangeNotifier { /// 재고 트랜잭션을 상신(submit)한다. Future 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 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 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 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 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, diff --git a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart index dbed0a4..650353f 100644 --- a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart +++ b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart @@ -371,11 +371,12 @@ class OutboundController extends ChangeNotifier { /// 출고 트랜잭션을 상신한다. Future 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 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 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 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 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, diff --git a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart index 3179897..aa893e5 100644 --- a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart +++ b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart @@ -408,12 +408,13 @@ class RentalController extends ChangeNotifier { /// 대여/반납 트랜잭션을 상신한다. Future 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 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 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 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 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, diff --git a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart index 487f5ef..bc3407a 100644 --- a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart +++ b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart @@ -82,50 +82,43 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { } @override - Future submit(int id) async { - final response = await _api.post>( - '$_basePath/$id/submit', - data: {'id': id}, - options: Options(responseType: ResponseType.json), - ); - return _parseSingle(response.data); + Future submit(int id, {String? note}) async { + return _postTransition(id: id, action: 'submit', note: note); } @override - Future complete(int id) async { - final response = await _api.post>( - '$_basePath/$id/complete', - data: {'id': id}, - options: Options(responseType: ResponseType.json), - ); - return _parseSingle(response.data); + Future complete(int id, {String? note}) async { + return _postTransition(id: id, action: 'complete', note: note); } @override - Future approve(int id) async { - final response = await _api.post>( - '$_basePath/$id/approve', - data: {'id': id}, - options: Options(responseType: ResponseType.json), - ); - return _parseSingle(response.data); + Future approve(int id, {String? note}) async { + return _postTransition(id: id, action: 'approve', note: note); } @override - Future reject(int id) async { - final response = await _api.post>( - '$_basePath/$id/reject', - data: {'id': id}, - options: Options(responseType: ResponseType.json), - ); - return _parseSingle(response.data); + Future reject(int id, {String? note}) async { + return _postTransition(id: id, action: 'reject', note: note); } @override - Future cancel(int id) async { + Future cancel(int id, {String? note}) async { + return _postTransition(id: id, action: 'cancel', note: note); + } + + Future _postTransition({ + required int id, + required String action, + String? note, + }) async { + final payload = {'id': id}; + final trimmedNote = note?.trim(); + if (trimmedNote != null && trimmedNote.isNotEmpty) { + payload['note'] = trimmedNote; + } final response = await _api.post>( - '$_basePath/$id/cancel', - data: {'id': id}, + '$_basePath/$id/$action', + data: payload, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); diff --git a/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart b/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart index 02022f3..863ecd1 100644 --- a/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart +++ b/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart @@ -29,19 +29,29 @@ abstract class StockTransactionRepository { Future restore(int id); /// 재고 트랜잭션을 상신(submit)한다. - Future submit(int id); + /// + /// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다. + Future submit(int id, {String? note}); /// 재고 트랜잭션을 완료 처리한다. - Future complete(int id); + /// + /// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다. + Future complete(int id, {String? note}); /// 재고 트랜잭션을 승인 처리한다. - Future approve(int id); + /// + /// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다. + Future approve(int id, {String? note}); /// 재고 트랜잭션을 반려 처리한다. - Future reject(int id); + /// + /// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다. + Future reject(int id, {String? note}); /// 재고 트랜잭션을 취소 처리한다. - Future cancel(int id); + /// + /// [note]는 상태 전이 사유를 서버에 전달할 때 사용된다. + Future cancel(int id, {String? note}); } /// 재고 트랜잭션 라인 저장소 인터페이스. diff --git a/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart b/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart index 0fc7282..b1ffada 100644 --- a/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart +++ b/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart @@ -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'), ), diff --git a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart index fbf6588..b0918f1 100644 --- a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart +++ b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart @@ -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(() => resubmitUseCase); sl.registerLazySingleton(() => 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 = []; + 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( + items: [record, secondRecord], + page: 1, + pageSize: 20, + total: 2, + ); + }); + + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); + await tester.pumpAndSettle(); + + final controller = tester + .widgetList(find.byType(AnimatedBuilder)) + .map((builder) => builder.animation) + .whereType() + .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'), ), diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index d2fc9c6..003308f 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -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( diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart index 570a72f..9fb78f9 100644 --- a/test/features/approvals/presentation/pages/approval_page_test.dart +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -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); + }); }); } diff --git a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart index 8c1ac8a..8dc0856 100644 --- a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart +++ b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart @@ -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 { diff --git a/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart b/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart index f3fa393..b01f96c 100644 --- a/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart +++ b/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart @@ -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( diff --git a/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart b/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart index d59d1be..d5c6710 100644 --- a/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart +++ b/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart @@ -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')), diff --git a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart index c943cd3..081b6ba 100644 --- a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart +++ b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart @@ -215,16 +215,20 @@ void main() { ), ); - await repository.submit(10); + await repository.submit(10, note: '승인 요청'); - verify( - () => apiClient.post>( - path, - data: any(named: 'data'), - options: any(named: 'options'), - cancelToken: any(named: 'cancelToken'), - ), - ).called(1); + final payload = + verify( + () => apiClient.post>( + path, + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[0] + as Map; + 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>( - path, - data: any(named: 'data'), - options: any(named: 'options'), - cancelToken: any(named: 'cancelToken'), - ), - ).called(1); + final payload = + verify( + () => apiClient.post>( + path, + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[0] + as Map; + 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>( - path, - data: any(named: 'data'), - options: any(named: 'options'), - cancelToken: any(named: 'cancelToken'), - ), - ).called(1); + final payload = + verify( + () => apiClient.post>( + path, + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[0] + as Map; + 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>( - path, - data: any(named: 'data'), - options: any(named: 'options'), - cancelToken: any(named: 'cancelToken'), - ), - ).called(1); + final payload = + verify( + () => apiClient.post>( + path, + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[0] + as Map; + 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>( + path, + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[0] + as Map; + expect(payload['id'], 13); + expect(payload['note'], '상신 취소'); + }); + + test('transition note는 공백 입력 시 제외된다', () async { + const path = '/api/v1/stock-transactions/99/submit'; + when( () => apiClient.post>( path, data: any(named: 'data'), options: any(named: 'options'), cancelToken: any(named: 'cancelToken'), ), - ).called(1); + ).thenAnswer( + (_) async => Response>( + data: detailBody(), + requestOptions: RequestOptions(path: path), + statusCode: 200, + ), + ); + + await repository.submit(99, note: ' '); + + final payload = + verify( + () => apiClient.post>( + path, + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured[0] + as Map; + expect(payload['id'], 99); + expect(payload.containsKey('note'), isFalse); }); } diff --git a/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart b/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart index ce222ef..1211845 100644 --- a/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart +++ b/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart @@ -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)); + }); }); } diff --git a/test/helpers/inventory_test_stubs.dart b/test/helpers/inventory_test_stubs.dart index 8b1c26a..a7e34bb 100644 --- a/test/helpers/inventory_test_stubs.dart +++ b/test/helpers/inventory_test_stubs.dart @@ -327,7 +327,7 @@ class _StubStockTransactionRepository implements StockTransactionRepository { } @override - Future submit(int id) async { + Future 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 complete(int id) async { + Future complete(int id, {String? note}) async { return _mutateTransaction(id, _applyCompleteStatus); } @override - Future approve(int id) async { + Future approve(int id, {String? note}) async { return _mutateTransaction(id, (transaction) => transaction); } @override - Future reject(int id) async { + Future reject(int id, {String? note}) async { return _mutateTransaction(id, (transaction) => transaction); } @override - Future cancel(int id) async { + Future cancel(int id, {String? note}) async { return _mutateTransaction(id, (transaction) => transaction); }