- lib/features/inventory/summary 계층과 warehouse select 위젯을 추가해 목록/상세, 자동 새로고침, 필터, 상세 시트를 구현 - PermissionBootstrapper, scope 파서, 라우트 가드로 inventory.view 기반 권한 부여와 메뉴 노출을 통합(lib/core, lib/main.dart 등) - Inventory Summary API/QA/Audit 문서와 PR 템플릿, CHANGELOG를 신규 스펙과 검증 커맨드로 업데이트 - DTO 직렬화 의존성을 추가하고 Golden·Widget·단위 테스트를 작성했으며 flutter analyze / flutter test --coverage를 통과
179 lines
9.1 KiB
Markdown
179 lines
9.1 KiB
Markdown
# ApiClient 설계서 (Dio 기반, Superport 스타일)
|
|
|
|
## 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`, `get_it: ^7.x`, `flutter_secure_storage`, `intl`, `pretty_dio_logger`(dev) — `pubspec.yaml`에 반영됨.
|
|
- 테스트: `mocktail`, `flutter_test` (already configured).
|
|
|
|
## 3) 환경 변수
|
|
- `API_BASE_URL`, `API_CONNECT_TIMEOUT_MS`, `API_RECEIVE_TIMEOUT_MS`, `LOG_LEVEL`
|
|
- 로드 순서: `await Environment.initialize()` → `injection_container.dart`에서 `ApiClient` 생성
|
|
- `.env.*` 파일에 샘플 값이 포함되어 있으며, `FeatureFlags.initialize()` 이전에 호출된다.
|
|
|
|
## 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) 에러 매핑 정책
|
|
- `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) 쿼리 헬퍼와 규약
|
|
- `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
|
|
class ApiClient {
|
|
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
|
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
|
Future<Response<T>> patch<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
|
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
|
|
static Map<String, dynamic> buildQuery({...});
|
|
static String buildPath(Object base, [Iterable<Object?> segments = const []]);
|
|
}
|
|
```
|
|
|
|
## 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 }` → `PaginatedResult<T>` (`lib/core/common/models/paginated_result.dart`).
|
|
- 단건: `{ data: {...} }` → `ApiClient.unwrapAsMap/unwrap` 헬퍼가 추출한다 (`lib/core/network/api_client.dart:91`).
|
|
|
|
## 10) 사용 예시
|
|
```dart
|
|
final response = await _api.get<Map<String, dynamic>>(
|
|
ApiRoutes.approvals,
|
|
query: ApiClient.buildQuery(page: page, pageSize: pageSize, include: ['requested_by']),
|
|
);
|
|
return ApprovalDto.parsePaginated(response.data ?? const {});
|
|
```
|
|
|
|
## 11) 보안/스토리지
|
|
- 웹: localStorage, 모바일: secure storage — `TokenStorage`가 추상화.
|
|
- 민감정보 로깅 금지, 개발 모드에서만 `pretty_dio_logger` 활성화.
|
|
- 쿠키 기반 인증 시 `dio.options.extra['withCredentials']=true`를 사용하도록 확장 가능.
|
|
|
|
## 12) 테스트 전략
|
|
- 단위 테스트: `test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`에서 경로·쿼리·에러·토큰 재시도를 검증.
|
|
- 기능 테스트: 각 Remote Repository 테스트가 파라미터 직렬화를 검증하고, 통합 테스트(`integration_test/approvals_flow_test.dart`)가 실제 플로우를 검증한다.
|
|
|
|
## 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`를 사용한다.
|
|
|
|
## 14) Inventory Summary API (신규)
|
|
|
|
### 14.1 목록 `GET /api/v1/inventory/summary`
|
|
- **권한**: `scope:inventory.view` + `menu_code=inventory` `can_read=true`
|
|
- **Query 파라미터**
|
|
- `page`, `page_size` (기본 1/50, 최대 200)
|
|
- `q`, `product_name`, `vendor_name`
|
|
- `warehouse_id`, `include_empty`, `updated_since`
|
|
- `sort`: `last_event_at|product_name|vendor_name|total_quantity`
|
|
- `order`: `asc|desc`
|
|
- **응답 스키마**
|
|
```json
|
|
{
|
|
"items": [
|
|
{
|
|
"product": {
|
|
"id": 101,
|
|
"product_code": "INV-DEMO-001",
|
|
"product_name": "QA 데모 장비",
|
|
"vendor": { "id": 10, "vendor_name": "Inventory Demo Vendor" }
|
|
},
|
|
"total_quantity": 145,
|
|
"warehouse_balances": [
|
|
{
|
|
"warehouse": {
|
|
"id": 1,
|
|
"warehouse_code": "INV-QA-A",
|
|
"warehouse_name": "QA 1센터"
|
|
},
|
|
"quantity": 115
|
|
}
|
|
],
|
|
"recent_event": {
|
|
"event_id": 15001,
|
|
"event_kind": "issue",
|
|
"event_label": "출고",
|
|
"delta_quantity": -20,
|
|
"counterparty": { "type": "customer", "name": "Inventory QA 고객" },
|
|
"warehouse": { "id": 1, "warehouse_code": "INV-QA-A", "warehouse_name": "QA 1센터" },
|
|
"transaction": { "id": 9100, "transaction_no": "INV-ISS-001" },
|
|
"occurred_at": "2025-10-24T02:58:00Z"
|
|
},
|
|
"updated_at": "2025-10-24T03:12:00Z"
|
|
}
|
|
],
|
|
"page": 1,
|
|
"page_size": 50,
|
|
"total": 1
|
|
}
|
|
```
|
|
- **오류**: `403 INVENTORY_SCOPE_REQUIRED`, `409 INVENTORY_SNAPSHOT_NOT_READY`
|
|
|
|
### 14.2 단건 `GET /api/v1/inventory/summary/{product_id}`
|
|
- **Query**: `event_limit`(1~100, 기본 20), `warehouse_id`
|
|
- **응답**
|
|
```json
|
|
{
|
|
"data": {
|
|
"product": { "id": 101, "product_code": "INV-DEMO-001", "product_name": "QA 데모 장비",
|
|
"vendor": { "id": 10, "vendor_name": "Inventory Demo Vendor" }
|
|
},
|
|
"total_quantity": 145,
|
|
"warehouse_balances": [
|
|
{
|
|
"warehouse": { "id": 1, "warehouse_code": "INV-QA-A", "warehouse_name": "QA 1센터" },
|
|
"quantity": 115
|
|
}
|
|
],
|
|
"recent_events": [
|
|
{
|
|
"event_id": 15001,
|
|
"event_kind": "issue",
|
|
"event_label": "출고",
|
|
"delta_quantity": -20,
|
|
"counterparty": { "type": "customer", "name": "Inventory QA 고객" },
|
|
"warehouse": { "id": 1, "warehouse_code": "INV-QA-A", "warehouse_name": "QA 1센터" },
|
|
"transaction": { "id": 9100, "transaction_no": "INV-ISS-001" },
|
|
"line": { "id": 12001, "line_no": 1, "quantity": 20 },
|
|
"occurred_at": "2025-10-24T02:58:00Z"
|
|
}
|
|
],
|
|
"updated_at": "2025-10-24T03:12:00Z",
|
|
"last_refreshed_at": "2025-10-24T03:10:00Z"
|
|
}
|
|
}
|
|
```
|
|
- **오류**: `403 INVENTORY_SCOPE_REQUIRED`, `409 INVENTORY_SNAPSHOT_NOT_READY`, `404 NOT_FOUND`
|
|
|
|
### 14.3 프런트 TODO
|
|
- DTO/JSON 직렬화: `InventorySummaryResponse`, `InventoryDetailResponse` → `build_runner` 재생성
|
|
- 상태관리: `InventorySummaryController`, `InventoryDetailController` (Pagination, 필터, `event_limit`)
|
|
- UI: 리스트(테이블) + 상세 시트, `warehouse_balances` 시각화, `recent_event` 배지
|
|
- 테스트: 위젯/Golden/통합 + `flutter analyze`, `flutter test --coverage`
|