feat(inventory): 재고 현황 요약/상세 플로우를 릴리스
- 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를 통과
This commit is contained in:
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# 개요
|
||||
- 변경 요약:
|
||||
- 사용자 영향: 재고 현황 화면은 읽기 전용 모드(`inventory.view`)로 노출됩니다.
|
||||
|
||||
# 체크리스트
|
||||
- [ ] UI 변경 스크린샷/영상 첨부
|
||||
- [ ] 사용자 영향과 롤백 전략 설명
|
||||
- [ ] 테스트 커맨드 실행 및 결과 공유
|
||||
- [ ] `cargo test -- tests::inventory_summary`
|
||||
- [ ] `flutter analyze`
|
||||
- [ ] `flutter test --coverage`
|
||||
|
||||
# 참고
|
||||
- 관련 이슈/문서:
|
||||
- 기타 비고:
|
||||
@@ -1,5 +1,12 @@
|
||||
# 변경 기록
|
||||
|
||||
## 2025-11-08
|
||||
- 재고 현황 Summary/Detail UI를 정식 릴리스했습니다. 읽기 전용 권한(`inventory.view`)을 가진 사용자는 자동 새로고침 토글, 창고 필터, 상세 시트(그래프/타임라인)를 통해 최신 잔량을 확인할 수 있습니다.
|
||||
- 테스트 커맨드
|
||||
- `cargo test -- tests::inventory_summary`
|
||||
- `flutter analyze`
|
||||
- `flutter test --coverage`
|
||||
|
||||
## 2025-10-20
|
||||
- 재고 입·출·대여 컨트롤러가 `Failure.describe()` 기반으로 오류를 노출해 승인/취소 흐름에서 서버 메시지가 그대로 전달됩니다.
|
||||
- 우편번호 검색 다이얼로그와 창고 선택 위젯이 API 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다.
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
- `FEATURE_APPROVALS_ENABLED` — 기본값은 개발·운영 모두 `true`, 단 결재 백엔드가 준비되지 않았으면 `.env.*`에서 `false`로 내려 임시 비활성화한다.
|
||||
- `FEATURE_STOCK_TRANSITIONS_ENABLED` — 재고 상태 전이(상신/승인/취소) 버튼 노출 제어. 운영 환경은 백엔드 배포 전까지 `false`로 유지하고, 개발 환경에서만 필요 시 `true`로 전환한다.
|
||||
|
||||
QA 토큰/스코프 발급 및 검증 절차는 `doc/qa/staging_transaction_flow.md`를 참고한다.
|
||||
|
||||
2) 의존성 설치
|
||||
|
||||
```
|
||||
|
||||
@@ -82,3 +82,97 @@ return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
- [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`
|
||||
|
||||
@@ -92,6 +92,16 @@
|
||||
- [x] 재고 입출고/대여 팝업: 재고 상세 공통 다이얼로그를 `SuperportDetailDialog`로 교체하고 입고/출고/대여 레코드의 상태·창고·금액/수량·반납 예정일을 metadata로 옮겼다. 탭은 라인 품목(고객/관계 포함)만 남겨 info1과 행위 영역이 분리되었다.
|
||||
- [ ] 나머지 결재/마스터 팝업: 동일한 패턴으로 순차 대응 예정.
|
||||
|
||||
#### 4.3.1 재고 현황 상세 시트 적용 스냅샷
|
||||
- Inventory Summary 화면(`lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart`)은 통합 가이드를 따라 summary/metadata/본문을 재구성했다.
|
||||
- summary 카드: 제품명·코드, 총 수량, 뷰 리프레시 시각만 남겨 정보1에서 전체 상태를 빠르게 파악한다.
|
||||
- metadata 영역: 창고 선택, 이벤트 개수(`event_limit`), 0 수량 포함 토글, 오류 배너가 모두 상단에 배치돼 중복 필드를 없앴다.
|
||||
- 본문: 창고 잔량 그래프 + Tag, 최근 이벤트 타임라인만 유지하며 모든 필드는 한 번만 노출된다.
|
||||
- Golden 산출물
|
||||
- 목록 기본 상태: `test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png`
|
||||
- 상세 시트 오픈 상태: `test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png`
|
||||
- 검증 명령: `flutter test --update-goldens test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart`
|
||||
|
||||
### 4.4 검증 & 테스트
|
||||
- [ ] 기존 위젯 테스트 업데이트: overview 탭 삭제, metadata 렌더링 검증 등.
|
||||
- [ ] 신규 테스트 케이스 추가: summary+metadata에 필드가 모두 나타나는지, 탭이 최소 한 개만 남았을 때 동작하는지 확인.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
| 4 | 보고서 Export(PDF/XLSX) 스트리밍·메타데이터 | ✅ 해결 | 감사 로그 확인 및 다운로드 UI 메타 필드 적용 |
|
||||
| 5 | 그룹-메뉴 권한 `route_path`·`is_deleted`·`include_deleted` | ✅ 해결 | 편집 화면에 삭제 항목/경로 노출 및 회귀 테스트 |
|
||||
| 6 | Prometheus 지표(`approval_flow_action_*`) 및 감사 로그 | ✅ 해결 | Ops 대시보드/알림 구성안 수립 |
|
||||
| 7 | 재고 현황 API (`/inventory/summary`) 계약 및 RBAC | 🟡 진행중 | 백엔드 구현 완료 → 프런트 DTO/화면/권한 플로우 동기화 (`doc/inventory_management_feature_plan.md`) |
|
||||
|
||||
아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다.
|
||||
|
||||
@@ -30,6 +31,14 @@
|
||||
- 결재 전이 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
|
||||
- 백엔드가 `inventory_balance_events_view`/`inventory_balance_snapshots` 마테뷰와 `/api/v1/inventory/summary` 목록/단건 API를 구현했다. 응답 스키마는 `stock_approval_system_api_v4.md` §4.8~4.9 및 `doc/API_CLIENT_SPEC.md`(백엔드, 프런트 모두)에 정리되어 있다.
|
||||
- 응답 필드: 목록/단건 모두 `product_id`, `product_code`, `product_name`, `vendor_name`, `total_quantity`, `warehouse_balances[] { warehouse_id, warehouse_code, warehouse_name, quantity }`, `recent_event_*`(kind, delta, counterparty, warehouse_id/name, transaction_id/no, event_at), `updated_at`, `refreshed_at`을 반환한다. UI는 리스트에서 총계·주요 창고·최근 변동 요약을, 상세에서 전체 `warehouse_balances`와 최근 이벤트 타임라인을 노출해야 한다. (출처: `stock_approval_system_spec_v4.md` §§3.24~3.25)
|
||||
- 데이터 해석: `inventory_balance_events_view`는 5분 주기로 리프레시되는 마테뷰이며 `delta_quantity`는 입고/반납=양수·출고/대여=음수 규칙을 따른다. `event_kind`(receipt, issue, rental_out, rental_return 등)는 뱃지/아이콘으로 구분하고, `counterparty_name`/`recent_event_warehouse_name`은 현지화된 라벨 텍스트와 함께 표기해야 한다.
|
||||
- 권한: 메뉴 권한(`menu_code=inventory`) + 스코프 `inventory.view`. 프런트 라우트 가드/사이드 메뉴 노출은 `permissions` 배열의 `scope:inventory.view` 보유 여부를 기준으로 삼는다.
|
||||
- 캐시/리프레시: 서버 2초 TTL 캐시 + 마테뷰 리프레시 스크립트(`script/refresh_inventory_mv.sh`). UI에서 `last_refreshed_at`을 노출해 사용자에게 데이터 신선도를 알려야 한다.
|
||||
- 감사 로그: `inventory.summary.viewed` 이벤트가 `{ actor_id, filters, result_count, request_id }`를 포함한다. 프런트는 필터/정렬 상태를 명시적으로 노출해 감사 이유를 이해할 수 있도록 UX를 준비한다.
|
||||
|
||||
## 결재 단계 & 행위
|
||||
- `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`)가 발행된다.
|
||||
|
||||
103
doc/inventory_management_feature_plan.md
Normal file
103
doc/inventory_management_feature_plan.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 재고관리(Inventory) 기능 단계별 개발 계획
|
||||
|
||||
## 개요
|
||||
- 재고 변동 이력을 기준으로 최신 재고를 노출하는 API/화면을 Clean Architecture 구조에 맞춰 단계적으로 구축한다.
|
||||
- 공식 명세는 `stock_approval_system_spec_v4.md` §§3.24~3.25(재고 이벤트/집계 뷰)와 `stock_approval_system_api_v4.md` §4.8~4.9(Inventory Summary API)에 정의되어 있으며, 모든 구현·테스트는 해당 계약을 1차 근거로 삼는다.
|
||||
- 읽기 전용 권한 스코프 `inventory.view`가 추가되었고, `/api/v1/inventory/**` 경로는 해당 스코프와 메뉴 권한(`menu_code=inventory`, `route_path=/inventory/summary`)을 모두 충족해야 접근할 수 있다.
|
||||
|
||||
## 최근 진행 현황 (2025-11-08)
|
||||
- Inventory Summary UI를 정식 릴리스하며 자동 새로고침, 창고 필터, 상세 시트(그래프/타임라인)를 모두 연결 완료했다.
|
||||
- Golden 테스트(`inventory_summary_page_golden_test.dart`)와 위젯 테스트를 확장해 정렬/필터/빈 상태/권한 오류까지 회귀 시나리오를 커버한다.
|
||||
- 릴리스 노트(`CHANGELOG.md`)와 PR 템플릿(`.github/PULL_REQUEST_TEMPLATE.md`)에 사용자 영향 및 필수 검증 커맨드를 명시했다.
|
||||
- 상세 다이얼로그 통합 계획서에 재고 현황 사례와 골든 스냅샷 경로를 기록해 문서 일관성을 확보했다.
|
||||
|
||||
## 전체 타임라인 개요
|
||||
1. **요구 정합성 확보 (Backend & Product)** — 데이터 출처, 뷰 리프레시 주기, RBAC 확정.
|
||||
2. **백엔드 개발** — 마테뷰·API·권한 시드·테스트·문서 동기화.
|
||||
3. **프론트엔드 개발** — Flutter 사이드 메뉴/리스트/상세/상태 관리 구현 및 테스트.
|
||||
4. **통합 검증** — 계약 검증, 샘플 데이터, E2E 시나리오, 배포 산출 정리.
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 단계별 Tasks (선행) — ✅ 완료
|
||||
> 기준: `../superport_api_v2`
|
||||
|
||||
1. ### 요구/계약 정리
|
||||
- [x] `doc/frontend_backend_alignment_report.md` 업데이트로 이벤트 뷰→스냅샷 흐름 문서화 (2025-10-24)
|
||||
- [x] 정렬/페이징/오류 코드 스펙 동기화 (`stock_approval_system_api_v4.md` §§4.8~4.9)
|
||||
- [x] 감사 로그/Slack 알림 범위 정의 (`doc/inventory_summary_audit_plan.md`)
|
||||
|
||||
2. ### 데이터 모델링 & 마이그레이션
|
||||
- [x] `migration/110_inventory_balance_events_view.sql`
|
||||
- [x] `migration/115_inventory_balance_snapshots_mv.sql` + 인덱스/리프레시 정책
|
||||
- [x] `script/refresh_inventory_mv.sh`, `doc/qa/inventory_data_replay.md`
|
||||
|
||||
3. ### API/서비스 설계
|
||||
- [x] `backend/src/domain/inventory.rs` (쿼리/DTO 규칙)
|
||||
- [x] `backend/src/api/v1/inventory.rs` (`/inventory/summary`, `/inventory/summary/{product_id}`)
|
||||
- [x] `stock_approval_system_api_v4.md`, `doc/API_CLIENT_SPEC.md`에 직렬화/빈 결과 규칙 명시
|
||||
|
||||
4. ### 권한·인증·감사
|
||||
- [x] `migration/105_add_inventory_scope.sql`로 `inventory.view` 스코프 시드
|
||||
- [x] `backend/src/api/security.rs`에 `InventoryAuthContext` 및 스코프 검사 추가
|
||||
- [x] 감사 이벤트 `inventory.summary.viewed` (`backend/src/app/services/inventory.rs`)
|
||||
|
||||
5. ### 구현
|
||||
- [x] `backend/src/adapters/repositories/inventory.rs` (뷰 조회)
|
||||
- [x] `backend/src/app/services/inventory.rs` 2초 TTL 캐시
|
||||
- [x] 오류 코드 통일 (`INVENTORY_SNAPSHOT_NOT_READY`, `INVENTORY_SCOPE_REQUIRED`)
|
||||
|
||||
6. ### 샘플/테스트 데이터
|
||||
- [x] `migration/120_seed_inventory_summary.sql`
|
||||
- [x] `doc/qa/inventory_data_replay.md`
|
||||
|
||||
7. ### 검증 & 문서
|
||||
- [x] `backend/tests/inventory_summary.rs`
|
||||
- [x] `script/run_backend_checks.sh` (`fmt`/`check`/`clippy`/`tests::inventory_summary`)
|
||||
- [x] Postman/Thunder & `doc/API_CLIENT_SPEC.md` 업데이트 (백엔드 기준)
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 단계별 Tasks (백엔드 완료 후 착수)
|
||||
1. ### 계약 동기화 & 환경 준비
|
||||
- [x] `superport_v2`에서 API DTO/JSON 직렬화를 `build_runner`로 재생성(`InventorySummaryResponse`, `InventoryDetailResponse`).
|
||||
- [x] `ApiClient`에 `/api/v1/inventory/summary` 경로를 추가하고, 서비스 등록은 기존 의존성 주입 컨테이너(`injection_container.dart`)에 `InventoryRepository`/`InventoryService`로 분리.
|
||||
- [x] QA 계정은 로그인 응답(`permissions`, `permission_codes`)에 `scope:inventory.view`가 포함되도록 백엔드와 권한 시드를 맞추고, README에서 해당 흐름을 안내.
|
||||
|
||||
2. ### 내비게이션 & 라우팅
|
||||
- [x] Flutter 사이드 메뉴에 `재고현황`을 대시보드와 입출고 사이에 배치하고, 앱 라우터(GoRouter/AutoRoute)에서 `/inventory/summary` 라우트를 추가.
|
||||
- [x] 라우트 가드: 세션의 `permissions` 배열에 `scope:inventory.view`가 없으면 메뉴 자체를 숨기고, 직접 URL 접근 시 권한 부족 안내/감사 로그 전송.
|
||||
|
||||
3. ### 상태 관리 & 데이터 요청
|
||||
- [x] 기존 상태관리 패턴(예: Riverpod `AsyncNotifier` / Bloc)을 따른 `InventorySummaryController`를 작성하고 페이징, 정렬, 필터 상태를 보존.
|
||||
- [x] 상세 패널은 `InventoryDetailController`를 분리해 제품 ID별 캐시, 동일 요청 중복 방지, `event_limit` 조절(기본 20) 로직을 포함.
|
||||
- [x] HTTP 오류(403, 404, 409)와 빈 데이터 응답 시 UI 상태를 명시적으로 노출하고, 최근 이벤트가 비어있을 때 대체 메시지를 제공.
|
||||
|
||||
4. ### UI 컴포넌트
|
||||
- [x] Flutter `PaginatedDataTable` 또는 기존 공용 테이블 위젯으로 리스트를 구성: 컬럼 = 순번, 제품명/코드, 벤더, 총계, 창고별 요약, 최근 변동(타입/수량/시간/거래처).
|
||||
- [x] 상세 뷰(모달 또는 `DraggableScrollableSheet`)에 `warehouse_balances` 그래프/Tag, `recent_events` 타임라인(입·출고/대여/반납 구분 아이콘) 표현.
|
||||
- [x] View-only 배지, Skeleton/empty/error state 컴포넌트를 기존 디자인 시스템(`superport_v2/lib/widgets/state/`)과 재사용.
|
||||
|
||||
5. ### UX 보완
|
||||
- [x] 최신 변동 기준 정렬 라벨(`최근 이벤트: 2025-10-24 12:12`)과 자동 새로고침 토글(뷰 리프레시 시각 기준)을 안내.
|
||||
- [x] 창고별 잔량은 Tag/Pill 또는 미니 차트로 시각화하고, 총 보유 수량을 강조(Warning 색상은 음수 재고만).
|
||||
- [x] 키보드 포커스 이동, 스크린리더 라벨(`recent_event.event_label`) 등 접근성 체크.
|
||||
|
||||
6. ### 테스트 & 품질
|
||||
- [x] Widget 테스트: 리스트 렌더링, 최근 이벤트 표시, 권한 없는 경우 Alert 노출.
|
||||
- [x] 통합/Golden 테스트: 정렬/필터 조합, 빈 데이터 상태, 상세 패널 타임라인.
|
||||
- [x] `flutter analyze`, `flutter test --coverage`, 필요 시 `flutter test --coverage --machine` 결과를 CI에 업로드.
|
||||
|
||||
7. ### 문서 & 배포 준비
|
||||
- [x] `doc/detail_dialog_unification_plan.md` 또는 신규 문서에 UI 플로우와 스냅샷을 추가.
|
||||
- [x] 릴리스 노트/PR 템플릿에 사용자 영향(읽기 전용 화면 추가)과 검증 커맨드(`cargo test -- tests::inventory_summary`, `flutter test --coverage`)를 명시.
|
||||
- [x] QA와 함께 재고/입출고/대여 연계 시나리오를 포함한 E2E 테스트 목록을 `doc/qa/inventory/` 하위에 정리.
|
||||
|
||||
---
|
||||
|
||||
## 통합 체크리스트
|
||||
- [x] `inventory.view` 스코프 및 메뉴 권한 시드 적용 (백엔드 105번 마이그레이션)
|
||||
- [x] `inventory_balance_events_view` / `inventory_balance_snapshots` 리프레시 + 모니터링 스크립트
|
||||
- [x] `/api/v1/inventory/summary`/`{product_id}` 스펙 검증 & `cargo test -- tests::inventory_summary` 통과
|
||||
- [x] 감사 로그(`inventory.summary.viewed`) 경보 플로우 스테이징 검증
|
||||
- [x] 문서/QA/배포 안내 최신화 (`doc/API_CLIENT_SPEC.md`, `doc/qa/inventory_data_replay.md`, `script/DEPLOY_REMOTE.md`)
|
||||
37
doc/inventory_summary_audit_plan.md
Normal file
37
doc/inventory_summary_audit_plan.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 재고 요약 감사 로그 · Slack 알림 계획
|
||||
|
||||
> 원본 정의: `../superport_api_v2/doc/inventory_summary_audit_plan.md` – 프런트에서도 동일 정책을 참조하도록 요약본을 유지한다.
|
||||
|
||||
## 이벤트 개요
|
||||
- 엔드포인트: `GET /api/v1/inventory/summary`, `GET /api/v1/inventory/summary/{product_id}`
|
||||
- 이벤트 코드: `inventory.summary.viewed` (version `1.0`)
|
||||
- 발행 채널: Kafka(선택), WebSocket(`AuditEventStream`)
|
||||
|
||||
### Payload
|
||||
| 필드 | 설명 |
|
||||
| --- | --- |
|
||||
| `actor_id` | 요청자 ID |
|
||||
| `filters` | `page`, `page_size`, `warehouse_id`, `include_empty`, `sort`, `order`, `updated_since`, `event_limit`, `product_id` |
|
||||
| `result_count` | 목록: `items.len()`, 단건: `recent_events.len()` |
|
||||
| `request_id` | 서버가 채번한 마이크로초 기반 상관관계 키 |
|
||||
| `emitted_at` | UTC 기준 발행 시각 |
|
||||
|
||||
프런트는 필터 UI 상태를 서버와 동일하게 유지해 감사 로그와 UX 간 불일치가 없도록 한다.
|
||||
|
||||
## Slack / PagerDuty 라우팅
|
||||
| 시나리오 | 채널 | 레벨 |
|
||||
| --- | --- | --- |
|
||||
| 정상 조회 | `#inventory-monitoring` (옵션) | INFO |
|
||||
| 권한 부족 (`INVENTORY_SCOPE_REQUIRED`) | `#inventory-alerts` | WARN / 5분 내 5회 → PagerDuty Low |
|
||||
| 스냅샷 지연 (`INVENTORY_SNAPSHOT_NOT_READY`) | `#inventory-alerts` | INFO / 10분 지속 → PagerDuty Medium |
|
||||
| 정합성 실패·데이터 불일치 | `#inventory-critical` | ERROR / 즉시 PagerDuty High |
|
||||
|
||||
프런트 액션:
|
||||
- 권한 부족 시 AlertDialg + Slack 알림에 포함될 수 있는 컨텍스트(`filters`,`actor_id`)를 명시.
|
||||
- 스냅샷 지연 오류에서는 사용자에게 뷰 리프레시 절차 안내 메시지를 출력.
|
||||
|
||||
## 운영 체크리스트
|
||||
1. 배포 직후 `script/refresh_inventory_mv.sh --database-url "$DATABASE_URL"` 실행 기록을 Ops에 공유.
|
||||
2. Slack 로그 샘플을 QA와 함께 캡처해 PR에 첨부.
|
||||
3. 감사 이벤트 미수신 시 `AuditEventStream` 구독자 상태(프론트 Admin 콘솔) 확인.
|
||||
4. 월 1회 Ops와 정책 재검토, 필요 시 PagerDuty 라우팅/임계값 갱신.
|
||||
40
doc/qa/inventory/inventory_summary_e2e_checklist.md
Normal file
40
doc/qa/inventory/inventory_summary_e2e_checklist.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 재고 현황 E2E 체크리스트
|
||||
|
||||
## 개요
|
||||
- `/inventory/summary` 플로우가 백엔드 계약(`stock_approval_system_api_v4.md` §4.8~4.9)과 UI 명세(`doc/inventory_management_feature_plan.md`)을 모두 만족하는지 QA 단계에서 검증한다.
|
||||
- Chrome CanvasKit 렌더러 기준으로 테스트하며, 로그인 응답의 `permissions` 또는 `permission_codes`에 `scope:inventory.view`가 포함된 계정을 사용한다.
|
||||
|
||||
## 사전 조건
|
||||
1. `.env.development` 또는 `.env.production`에서 `Environment.initialize()`가 성공적으로 수행되어야 한다.
|
||||
2. `inventory_balance_snapshots` 마테뷰 리프레시 스크립트가 직전 5분 내 실행되어 `last_refreshed_at`이 현재 시각과 5분 이상 차이나지 않아야 한다.
|
||||
3. QA 계정은 `scope:inventory.view` 및 `menu_code=inventory` `can_read=true` 권한을 모두 보유해야 한다.
|
||||
|
||||
## 시나리오
|
||||
1. **기본 목록 로딩**
|
||||
- 조건: 초기 페이지 진입
|
||||
- 기대 결과: View Only 배지, 총 제품 수, 최근 이벤트 기준 카드 노출, 테이블 기본 정렬은 `last_event_at DESC`.
|
||||
2. **자동 새로고침 토글**
|
||||
- 조건: 자동 새로고침 스위치를 활성화한 상태에서 30초 이상 대기
|
||||
- 기대 결과: 추가 조작 없이 목록이 다시 로딩되고 `마지막 리프레시` 라벨이 최신 값으로 갱신된다.
|
||||
3. **자동 새로고침 비활성화**
|
||||
- 조건: 스위치를 끄고 45초 이상 대기
|
||||
- 기대 결과: 추가 로딩이 발생하지 않으며, 다시 스위치를 켜면 즉시 주기 타이머가 재시작된다.
|
||||
4. **필터 적용/리셋**
|
||||
- 조건: 검색어 + 벤더 + 창고 선택 후 적용 → 리셋
|
||||
- 기대 결과: 적용 시 해당 파라미터가 API 호출에 포함되고, 리셋 시 모든 입력/토글이 초기 상태로 돌아간다.
|
||||
5. **상세 시트 그래프 & 접근성**
|
||||
- 조건: 행 클릭 → 상세 시트 오픈
|
||||
- 기대 결과: 창고 잔량 미니 차트가 창고명/수량과 함께 노출되고, 스크린리더에서 `창고명 잔량 N개`로 읽힌다.
|
||||
6. **최근 이벤트 타임라인**
|
||||
- 조건: 상세 시트에서 최근 이벤트가 존재하는 제품 선택
|
||||
- 기대 결과: 이벤트 라벨, 수량 증감 색상, 거래처 정보 표기가 존재하며, 스크린리더는 `최근 이벤트 <라벨>`과 변화량/발생 시각을 낭독한다.
|
||||
7. **빈 상태 / 오류 배너**
|
||||
- 조건: 존재하지 않는 제품명으로 필터 → 빈 상태 확인, 이후 프록시로 500 오류를 강제
|
||||
- 기대 결과: 빈 상태 문구와 오류 배너가 각각 노출되고 닫기 버튼 작동.
|
||||
8. **권한 미보유 접근**
|
||||
- 조건: `scope:inventory.view`가 없는 계정으로 직접 URL 접근
|
||||
- 기대 결과: 라우터가 가드를 통해 접근을 차단하고, 감사 로그에는 권한 부족 사유가 남는다.
|
||||
|
||||
## 추가 메모
|
||||
- E2E 수행 후 `doc/qa/inventory_data_replay.md`에 사용한 시드 데이터와 `last_refreshed_at` 값을 기록한다.
|
||||
- 자동 새로고침으로 발생하는 API 호출 횟수는 30초 간격 기준 초당 0.033회이므로, 부하 테스트 시 50 동시 사용자까지 문제 없는지 모니터링한다.
|
||||
45
doc/qa/inventory_data_replay.md
Normal file
45
doc/qa/inventory_data_replay.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 재고 요약 데이터 재현 가이드 (프런트 참조)
|
||||
|
||||
> 백엔드 원본: `../superport_api_v2/doc/qa/inventory_data_replay.md` – QA/프런트 협업용 요약.
|
||||
|
||||
## 목적
|
||||
- 스테이징/로컬에서 `/api/v1/inventory/summary` 계약을 검증할 때 동일한 데이터 세트를 확보한다.
|
||||
|
||||
## 순서 요약
|
||||
1. **마이그레이션 실행**
|
||||
```bash
|
||||
for file in migration/0*_*.sql migration/1*_*.sql; do
|
||||
psql "$DATABASE_URL" --set ON_ERROR_STOP=1 -f "$file"
|
||||
done
|
||||
```
|
||||
2. **QA 시드 재적재**
|
||||
```bash
|
||||
psql "$DATABASE_URL" --set ON_ERROR_STOP=1 -f migration/120_seed_inventory_summary.sql
|
||||
```
|
||||
3. **마테뷰 리프레시**
|
||||
```bash
|
||||
../superport_api_v2/script/refresh_inventory_mv.sh --database-url "$DATABASE_URL"
|
||||
```
|
||||
4. **정합성 SQL**
|
||||
```sql
|
||||
SELECT product_id, total_quantity,
|
||||
SUM((wb->>'quantity')::numeric) AS warehouse_sum
|
||||
FROM inventory_balance_snapshots
|
||||
CROSS JOIN LATERAL jsonb_array_elements(warehouse_balances) AS wb
|
||||
GROUP BY product_id, total_quantity
|
||||
HAVING SUM((wb->>'quantity')::numeric) <> total_quantity;
|
||||
```
|
||||
5. **API 스팟 체크**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"$API_BASE/api/v1/inventory/summary?page=1&page_size=50"
|
||||
```
|
||||
|
||||
## 롤백
|
||||
1. `DROP MATERIALIZED VIEW IF EXISTS inventory_balance_snapshots;`
|
||||
2. `DROP MATERIALIZED VIEW IF EXISTS inventory_balance_events_view;`
|
||||
3. 110/115 마이그레이션 재실행 → 리프레시 → 120 시드 재적재.
|
||||
|
||||
## 프런트 활용 팁
|
||||
- QA가 위 절차로 데이터를 복원했다는 확인을 받은 뒤 UI/Golden 테스트를 실행한다.
|
||||
- `last_refreshed_at` 값(응답 필드)을 QA 케이스에 기록해 자동 새로고침 UX 기준으로 활용한다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,7 +132,7 @@ zipcodes ||--o{ customers : addressed
|
||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||
|
||||
> API 기본 응답(`GET /approval/templates`, `GET /approval/templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
|
||||
> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
|
||||
|
||||
---
|
||||
|
||||
@@ -562,6 +562,7 @@ zipcodes ||--o{ customers : addressed
|
||||
| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id |
|
||||
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
|
||||
| approval_action_id | 결재행위ID | bigint | - | - | Y | | | approval_actions.id |
|
||||
| action_code | 행위코드 | varchar | 30 | - | Y | | | - |
|
||||
| from_status_id | 변경전상태ID | bigint | - | - | N | | | approval_statuses.id |
|
||||
| to_status_id | 변경후상태ID | bigint | - | - | Y | | | approval_statuses.id |
|
||||
| action_at | 작업일시 | timestamp | - | now() | Y | | | - |
|
||||
@@ -573,6 +574,8 @@ zipcodes ||--o{ customers : addressed
|
||||
|
||||
---
|
||||
|
||||
- `action_code`는 `submit`, `approve`, `reject`, `comment`, `recall`, `resubmit` 등 표준 문자열을 저장해 참조 행위 레코드가 없어도 이력 복원이 가능하도록 한다.
|
||||
|
||||
### 3.22 `approval_templates` (결재_템플릿)
|
||||
| 영문테이블명 | 한글테이블명 |
|
||||
|---|---|
|
||||
@@ -614,6 +617,61 @@ zipcodes ||--o{ customers : addressed
|
||||
|
||||
---
|
||||
|
||||
### 3.24 `inventory_balance_events_view` (재고_이벤트_뷰)
|
||||
| 영문테이블명 | 한글테이블명 |
|
||||
|---|---|
|
||||
| inventory_balance_events_view | 재고_이벤트_뷰 |
|
||||
|
||||
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| event_id | 이벤트ID | bigint | - | - | Y | Y | Y | transaction_lines.id |
|
||||
| transaction_id | 트랜잭션ID | bigint | - | - | Y | | | stock_transactions.id |
|
||||
| transaction_line_id | 트랜잭션라인ID | bigint | - | - | Y | | | transaction_lines.id |
|
||||
| transaction_no | 전표번호 | varchar | 40 | - | Y | | | - |
|
||||
| product_id | 제품ID | bigint | - | - | Y | | | products.id |
|
||||
| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id |
|
||||
| transaction_type_id | 트랜잭션타입ID | bigint | - | - | Y | | | transaction_types.id |
|
||||
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id |
|
||||
| delta_quantity | 증감수량 | numeric | 20,6 | 0 | Y | | | - |
|
||||
| event_kind | 이벤트종류 | varchar | 30 | - | Y | | | - |
|
||||
| counterparty_name | 거래처요약 | varchar | 150 | - | N | | | - |
|
||||
| event_occurred_at | 이벤트일시 | timestamp | - | - | Y | | | - |
|
||||
| captured_at | 집계일시 | timestamp | - | now() | Y | | | - |
|
||||
|
||||
> 입고/출고/대여 라인을 `transaction_lines`에서 펼친 뷰다. `delta_quantity`는 입고/반납=양수, 출고/대여=음수 규칙을 따른다. `event_kind`는 `receipt`, `issue`, `rental_out`, `rental_return` 등 표준 문자열로 저장한다. 최신 변동 정렬을 위해 `event_occurred_at DESC`, `event_id DESC` 복합 인덱스를 생성하며, 뷰는 마테리얼라이즈드 형태로 5분마다 리프레시된다.
|
||||
|
||||
---
|
||||
|
||||
### 3.25 `inventory_balance_snapshots` (재고_집계_뷰)
|
||||
| 영문테이블명 | 한글테이블명 |
|
||||
|---|---|
|
||||
| inventory_balance_snapshots | 재고_집계_뷰 |
|
||||
|
||||
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| product_id | 제품ID | bigint | - | - | Y | Y | Y | products.id |
|
||||
| product_code | 제품코드 | varchar | 30 | - | Y | | | - |
|
||||
| product_name | 제품명 | varchar | 100 | - | Y | | | - |
|
||||
| vendor_id | 벤더ID | bigint | - | - | Y | | | vendors.id |
|
||||
| vendor_name | 벤더명 | varchar | 100 | - | Y | | | - |
|
||||
| total_quantity | 총재고수량 | numeric | 20,6 | 0 | Y | | | - |
|
||||
| warehouse_balances | 창고별요약 | jsonb | - | '[]'::jsonb | Y | | | - |
|
||||
| recent_event_id | 최근이벤트ID | bigint | - | - | N | | | inventory_balance_events_view.event_id |
|
||||
| recent_event_kind | 최근이벤트종류 | varchar | 30 | - | N | | | - |
|
||||
| recent_event_delta | 최근이벤트증감 | numeric | 20,6 | 0 | N | | | - |
|
||||
| recent_event_counterparty | 최근거래처 | varchar | 150 | - | N | | | - |
|
||||
| recent_event_warehouse_id | 최근창고ID | bigint | - | - | N | | | warehouses.id |
|
||||
| recent_event_warehouse_name | 최근창고명 | varchar | 100 | - | N | | | - |
|
||||
| recent_event_transaction_id | 최근전표ID | bigint | - | - | N | | | stock_transactions.id |
|
||||
| recent_event_transaction_no | 최근전표번호 | varchar | 40 | - | N | | | - |
|
||||
| recent_event_at | 최근이벤트일시 | timestamp | - | - | N | | | - |
|
||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | - |
|
||||
| refreshed_at | 리프레시일시 | timestamp | - | now() | Y | | | - |
|
||||
|
||||
> `inventory_balance_events_view`를 제품 단위로 집계한 마테뷰. `warehouse_balances`는 `[ { "warehouse_id": 1, "warehouse_code": "WH-001", "warehouse_name": "1센터", "quantity": 80 } ]` 형태의 배열 JSON을 저장한다. `recent_*` 필드는 최신 이벤트 스냅샷을 캐싱해 `/api/v1/inventory/summary` 응답과 동일 구조를 제공하며, `refreshed_at`은 뷰가 새로 고쳐진 시각(UTC)을 그대로 보존한다. `updated_at` 컬럼으로 증분 조회가 가능하도록 `updated_at DESC` 인덱스를 생성한다.
|
||||
|
||||
---
|
||||
|
||||
## 4) FK 관계 (source → target)
|
||||
- `menus.parent_menu_id` → `menus.id`
|
||||
- `users.group_id` → `groups.id`
|
||||
@@ -657,6 +715,7 @@ zipcodes ||--o{ customers : addressed
|
||||
- 수량/단가 음수 금지(CHECK).
|
||||
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
|
||||
- 사용자의 소속 그룹(`users.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
|
||||
- 재고 현황 조회 API는 읽기 전용 권한 스코프 `inventory.view`를 요구하며, 스코프가 없는 사용자는 `/api/v1/inventory/**` 경로에서 403(`INVENTORY_SCOPE_REQUIRED`)을 받는다.
|
||||
- `users.employee_id`는 앞뒤 공백을 제거한 뒤 대소문자 구분 없이 중복 검증하며, 저장 시 대문자로 정규화한다.
|
||||
- 자기 정보 수정(`PATCH /users/me`)에서는 `phone`, `email`, `password`만 변경할 수 있고, `password` 변경 시 기존 비밀번호 검증이 필수다.
|
||||
- 비밀번호 재설정(관리자)은 8자 영문 대소문자+숫자 조합을 생성하고 이메일 발송 큐에 푸시한 뒤 `force_password_change=true`, `password_updated_at=now()`로 기록한다.
|
||||
@@ -672,6 +731,7 @@ zipcodes ||--o{ customers : addressed
|
||||
- `transaction_lines(transaction_id, line_no, is_deleted)`
|
||||
- `transaction_customers(transaction_id, customer_id, is_deleted)`
|
||||
- FK 및 조회 인덱스: 모든 `*_id`, `updated_at`, `is_deleted`, `is_active`.
|
||||
- 재고 집계 마테뷰 인덱스: `inventory_balance_events_view(event_occurred_at DESC, event_id DESC)`, `inventory_balance_snapshots(updated_at DESC)` 및 필요 시 `inventory_balance_snapshots`의 `warehouse_balances` JSONB GIN 인덱스.
|
||||
|
||||
---
|
||||
|
||||
@@ -714,4 +774,5 @@ zipcodes ||--o{ customers : addressed
|
||||
- `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장.
|
||||
- 낙관적 잠금(선택): `version`(int) + ETag.
|
||||
- 병렬 결재 확장(선택): `approval_steps`에 `group_no`, `approval_mode(all|any)` 도입.
|
||||
- 감사 로그(`approval_audits`) 적재 시 `approval.audit.recorded` 이벤트를 Kafka(토픽 예: `approval_audit_events`)와 WebSocket 브로드캐스트로 발행한다. 이벤트 구성은 `{ event, version, emitted_at, request_id, audit_id, summary }` JSON으로 정의하며, 운영 환경별 엔드포인트는 `event_bus.kafka.*`, `event_bus.websocket.*` 설정으로 분리한다.
|
||||
- `/health` 응답의 `build_version`은 `config/default.toml`의 `[app].build_version`을 사용하며, `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
import '../permissions/permission_resources.dart';
|
||||
|
||||
/// 사이드바/내비게이션용 페이지 정보.
|
||||
class AppPageDescriptor {
|
||||
const AppPageDescriptor({
|
||||
@@ -8,12 +10,14 @@ class AppPageDescriptor {
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.summary,
|
||||
this.extraRequiredResources = const [],
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String summary;
|
||||
final List<String> extraRequiredResources;
|
||||
}
|
||||
|
||||
/// 메뉴 섹션을 나타내는 데이터 클래스.
|
||||
@@ -30,6 +34,9 @@ const loginRoutePath = '/login';
|
||||
/// 대시보드 라우트 경로.
|
||||
const dashboardRoutePath = '/dashboard';
|
||||
|
||||
/// 재고 현황 라우트 경로.
|
||||
const inventorySummaryRoutePath = '/inventory/summary';
|
||||
|
||||
/// 네비게이션 구성을 정의한 섹션 목록.
|
||||
const appSections = <AppSectionDescriptor>[
|
||||
AppSectionDescriptor(
|
||||
@@ -43,6 +50,18 @@ const appSections = <AppSectionDescriptor>[
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSectionDescriptor(
|
||||
label: '재고',
|
||||
pages: [
|
||||
AppPageDescriptor(
|
||||
path: inventorySummaryRoutePath,
|
||||
label: '재고 현황',
|
||||
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
|
||||
summary: '제품별 총 재고, 창고 잔량, 최근 이벤트를 한 화면에서 확인합니다.',
|
||||
extraRequiredResources: [PermissionResources.inventoryScope],
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSectionDescriptor(
|
||||
label: '입·출고',
|
||||
pages: [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'api_client.dart';
|
||||
|
||||
/// API 경로 상수 모음
|
||||
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
|
||||
class ApiRoutes {
|
||||
@@ -24,4 +26,12 @@ class ApiRoutes {
|
||||
.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$approvalRoot/$sanitized';
|
||||
}
|
||||
|
||||
/// 재고 현황 요약 목록 경로.
|
||||
static const inventorySummary = '$apiV1/inventory/summary';
|
||||
|
||||
/// 재고 현황 단건 경로를 조합한다.
|
||||
static String inventorySummaryDetail(Object productId) {
|
||||
return ApiClient.buildPath(inventorySummary, [productId]);
|
||||
}
|
||||
}
|
||||
|
||||
118
lib/core/permissions/permission_bootstrapper.dart
Normal file
118
lib/core/permissions/permission_bootstrapper.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
|
||||
import '../../features/auth/domain/entities/auth_session.dart';
|
||||
import '../../features/masters/group/domain/entities/group.dart';
|
||||
import '../../features/masters/group/domain/repositories/group_repository.dart';
|
||||
import '../../features/masters/group_permission/application/permission_synchronizer.dart';
|
||||
import '../../features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
|
||||
/// 세션 정보와 그룹 권한을 기반으로 [PermissionManager]를 초기화하는 부트스트랩 도우미.
|
||||
class PermissionBootstrapper {
|
||||
PermissionBootstrapper({
|
||||
required PermissionManager manager,
|
||||
required GroupRepository groupRepository,
|
||||
required GroupPermissionRepository groupPermissionRepository,
|
||||
}) : _manager = manager,
|
||||
_groupRepository = groupRepository,
|
||||
_groupPermissionRepository = groupPermissionRepository;
|
||||
|
||||
final PermissionManager _manager;
|
||||
final GroupRepository _groupRepository;
|
||||
final GroupPermissionRepository _groupPermissionRepository;
|
||||
|
||||
/// 세션의 권한 목록과 그룹 권한을 적용한다.
|
||||
Future<void> apply(AuthSession session) async {
|
||||
_manager.clearServerPermissions();
|
||||
|
||||
final aggregated = <String, Set<PermissionAction>>{};
|
||||
var hasMenuPermission = false;
|
||||
|
||||
void merge(Map<String, Set<PermissionAction>> map) {
|
||||
if (map.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final entry in map.entries) {
|
||||
final target = aggregated.putIfAbsent(
|
||||
entry.key,
|
||||
() => <PermissionAction>{},
|
||||
);
|
||||
target.addAll(entry.value);
|
||||
if (!entry.key.startsWith('scope:')) {
|
||||
hasMenuPermission = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final permission in session.permissions) {
|
||||
merge(permission.toPermissionMap());
|
||||
}
|
||||
|
||||
if (!hasMenuPermission) {
|
||||
final map = await _loadGroupPermissions(
|
||||
groupId: session.user.primaryGroupId,
|
||||
);
|
||||
merge(map);
|
||||
}
|
||||
|
||||
if (aggregated.isNotEmpty) {
|
||||
_manager.applyServerPermissions(aggregated);
|
||||
return;
|
||||
}
|
||||
|
||||
await _synchronizePermissions(groupId: session.user.primaryGroupId);
|
||||
}
|
||||
|
||||
Future<void> _synchronizePermissions({int? groupId}) async {
|
||||
final targetGroupId = await _resolveGroupId(groupId);
|
||||
if (targetGroupId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: _groupPermissionRepository,
|
||||
manager: _manager,
|
||||
);
|
||||
await synchronizer.syncForGroup(targetGroupId);
|
||||
}
|
||||
|
||||
Future<Map<String, Set<PermissionAction>>> _loadGroupPermissions({
|
||||
int? groupId,
|
||||
}) async {
|
||||
final targetGroupId = await _resolveGroupId(groupId);
|
||||
if (targetGroupId == null) {
|
||||
return const {};
|
||||
}
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: _groupPermissionRepository,
|
||||
manager: _manager,
|
||||
);
|
||||
return synchronizer.fetchPermissionMap(targetGroupId);
|
||||
}
|
||||
|
||||
Future<int?> _resolveGroupId(int? groupId) async {
|
||||
if (groupId != null) {
|
||||
return groupId;
|
||||
}
|
||||
final defaultGroups = await _groupRepository.list(
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
isDefault: true,
|
||||
);
|
||||
var targetGroup = _firstGroupWithId(defaultGroups.items);
|
||||
|
||||
if (targetGroup == null) {
|
||||
final fallbackGroups = await _groupRepository.list(page: 1, pageSize: 1);
|
||||
targetGroup = _firstGroupWithId(fallbackGroups.items);
|
||||
}
|
||||
return targetGroup?.id;
|
||||
}
|
||||
|
||||
Group? _firstGroupWithId(List<Group> groups) {
|
||||
for (final group in groups) {
|
||||
if (group.id != null) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ class PermissionManager extends ChangeNotifier {
|
||||
return server.contains(action);
|
||||
}
|
||||
|
||||
if (key.startsWith('scope:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Environment.hasPermission(key, action.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ class PermissionResources {
|
||||
static const String approvalSteps = '/approval-steps';
|
||||
static const String approvalHistories = '/approval-histories';
|
||||
static const String approvalTemplates = '/approval/templates';
|
||||
static const String inventorySummary = '/inventory/summary';
|
||||
static const String inventoryScope = 'scope:inventory.view';
|
||||
static const String groupMenuPermissions = '/group-menu-permissions';
|
||||
static const String vendors = '/vendors';
|
||||
static const String products = '/products';
|
||||
@@ -41,6 +43,7 @@ class PermissionResources {
|
||||
'/approvals/templates': approvalTemplates,
|
||||
'/approval/templates': approvalTemplates,
|
||||
'/approval-templates': approvalTemplates,
|
||||
'/inventory/summary': inventorySummary,
|
||||
'/masters/group-permissions': groupMenuPermissions,
|
||||
'/group-menu-permissions': groupMenuPermissions,
|
||||
'/masters/vendors': vendors,
|
||||
@@ -83,35 +86,39 @@ class PermissionResources {
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
var lowered = trimmed.toLowerCase();
|
||||
final lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith('scope:')) {
|
||||
return lowered;
|
||||
}
|
||||
var normalized = lowered;
|
||||
|
||||
// 절대 URL이 들어오면 path 부분만 추출한다.
|
||||
final uri = Uri.tryParse(lowered);
|
||||
final uri = Uri.tryParse(normalized);
|
||||
if (uri != null && uri.hasScheme) {
|
||||
lowered = uri.path;
|
||||
normalized = uri.path;
|
||||
}
|
||||
|
||||
// 쿼리스트링이나 프래그먼트를 제거해 순수 경로만 남긴다.
|
||||
final queryIndex = lowered.indexOf('?');
|
||||
final queryIndex = normalized.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
lowered = lowered.substring(0, queryIndex);
|
||||
normalized = normalized.substring(0, queryIndex);
|
||||
}
|
||||
final hashIndex = lowered.indexOf('#');
|
||||
final hashIndex = normalized.indexOf('#');
|
||||
if (hashIndex != -1) {
|
||||
lowered = lowered.substring(0, hashIndex);
|
||||
normalized = normalized.substring(0, hashIndex);
|
||||
}
|
||||
|
||||
if (!lowered.startsWith('/')) {
|
||||
lowered = '/$lowered';
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = '/$normalized';
|
||||
}
|
||||
|
||||
while (lowered.contains('//')) {
|
||||
lowered = lowered.replaceAll('//', '/');
|
||||
while (normalized.contains('//')) {
|
||||
normalized = normalized.replaceAll('//', '/');
|
||||
}
|
||||
|
||||
if (lowered.length > 1 && lowered.endsWith('/')) {
|
||||
lowered = lowered.substring(0, lowered.length - 1);
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.substring(0, normalized.length - 1);
|
||||
}
|
||||
return lowered;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../features/dashboard/presentation/pages/dashboard_page.dart';
|
||||
import '../../features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||
import '../../features/inventory/outbound/presentation/pages/outbound_page.dart';
|
||||
import '../../features/inventory/rental/presentation/pages/rental_page.dart';
|
||||
import '../../features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import '../../features/login/presentation/pages/login_page.dart';
|
||||
import '../../features/masters/customer/presentation/pages/customer_page.dart';
|
||||
import '../../features/masters/group/presentation/pages/group_page.dart';
|
||||
@@ -25,6 +26,7 @@ import '../../features/util/postal_search/presentation/pages/postal_search_page.
|
||||
import '../../widgets/app_shell.dart';
|
||||
import '../constants/app_sections.dart';
|
||||
import '../permissions/permission_manager.dart';
|
||||
import '../permissions/permission_resources.dart';
|
||||
import 'auth_guard.dart';
|
||||
|
||||
/// 전역 네비게이터 키(로그인/셸 라우터 공용).
|
||||
@@ -66,6 +68,21 @@ final appRouter = GoRouter(
|
||||
name: 'dashboard',
|
||||
builder: (context, state) => const DashboardPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: inventorySummaryRoutePath,
|
||||
name: 'inventory-summary',
|
||||
redirect: (context, state) {
|
||||
if (!AuthGuard.can(inventorySummaryRoutePath)) {
|
||||
return dashboardRoutePath;
|
||||
}
|
||||
if (!AuthGuard.can(PermissionResources.inventoryScope)) {
|
||||
return dashboardRoutePath;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
builder: (context, state) =>
|
||||
InventorySummaryPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/inbound',
|
||||
name: 'inventory-inbound',
|
||||
|
||||
@@ -880,10 +880,7 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (!canApplyTemplate) {
|
||||
applyButton = Tooltip(
|
||||
message: '템플릿을 적용할 권한이 없습니다.',
|
||||
child: applyButton,
|
||||
);
|
||||
applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton);
|
||||
}
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -29,11 +29,7 @@ class ApprovalFormInitializer {
|
||||
controller.setRequester(defaultRequester);
|
||||
}
|
||||
if (draft != null) {
|
||||
await _applyDraft(
|
||||
controller,
|
||||
draft,
|
||||
repository ?? _resolveRepository(),
|
||||
);
|
||||
await _applyDraft(controller, draft, repository ?? _resolveRepository());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -508,7 +508,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
||||
}
|
||||
idController.dispose();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _InfoBadge extends StatelessWidget {
|
||||
|
||||
@@ -27,12 +27,11 @@ class ApprovalApproverCandidateDto {
|
||||
: null;
|
||||
return ApprovalApproverCandidateDto(
|
||||
id: json['id'] as int? ?? JsonUtils.readInt(json, 'user_id', fallback: 0),
|
||||
employeeNo: json['employee_id'] as String? ??
|
||||
employeeNo:
|
||||
json['employee_id'] as String? ??
|
||||
json['employee_no'] as String? ??
|
||||
'-',
|
||||
name: json['name'] as String? ??
|
||||
json['employee_name'] as String? ??
|
||||
'-',
|
||||
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
team: group?['group_name'] as String? ?? json['team'] as String?,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String? ?? json['mobile_no'] as String?,
|
||||
|
||||
@@ -25,14 +25,19 @@ class AuthSessionDto {
|
||||
final expires = _parseDate(_readString(json, 'expires_at'));
|
||||
final userMap = _readMap(json, 'user');
|
||||
final permissionList = _readList(json, 'permissions');
|
||||
final permissionDtos = permissionList
|
||||
.map(AuthPermissionDto.fromJson)
|
||||
.toList(growable: true);
|
||||
final scopeCodes = _readScopeCodes(json);
|
||||
for (final scope in scopeCodes) {
|
||||
permissionDtos.add(AuthPermissionDto.fromScope(scope));
|
||||
}
|
||||
return AuthSessionDto(
|
||||
accessToken: token ?? '',
|
||||
refreshToken: refresh ?? '',
|
||||
expiresAt: expires,
|
||||
user: _parseUser(userMap),
|
||||
permissions: permissionList
|
||||
.map(AuthPermissionDto.fromJson)
|
||||
.toList(growable: false),
|
||||
permissions: List<AuthPermissionDto>.unmodifiable(permissionDtos),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +92,14 @@ class AuthPermissionDto {
|
||||
);
|
||||
}
|
||||
|
||||
factory AuthPermissionDto.fromScope(String scope) {
|
||||
final normalized = scope.trim();
|
||||
if (normalized.isEmpty) {
|
||||
throw const FormatException('권한 스코프 코드가 비어 있습니다.');
|
||||
}
|
||||
return AuthPermissionDto(resource: normalized, actions: const ['view']);
|
||||
}
|
||||
|
||||
AuthPermission toEntity() =>
|
||||
AuthPermission(resource: resource, actions: actions);
|
||||
}
|
||||
@@ -131,6 +144,65 @@ List<Map<String, dynamic>> _readList(Map<String, dynamic> source, String key) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
Set<String> _readScopeCodes(Map<String, dynamic> source) {
|
||||
final codes = <String>{};
|
||||
|
||||
void addCode(String? raw) {
|
||||
final normalized = raw == null ? null : _normalizeScopeCode(raw);
|
||||
if (normalized != null) {
|
||||
codes.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
void parse(dynamic value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value is String) {
|
||||
addCode(value);
|
||||
return;
|
||||
}
|
||||
if (value is Iterable) {
|
||||
for (final item in value) {
|
||||
parse(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value is Map<String, dynamic>) {
|
||||
for (final key in const [
|
||||
'scope_code',
|
||||
'scope',
|
||||
'code',
|
||||
'permission_code',
|
||||
'permission',
|
||||
'name',
|
||||
'scopeCode',
|
||||
'permissionCode',
|
||||
'scopeName',
|
||||
]) {
|
||||
final candidate = value[key];
|
||||
if (candidate is String && candidate.trim().isNotEmpty) {
|
||||
addCode(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (final entry in value.entries) {
|
||||
final candidate = entry.value;
|
||||
if (candidate is String && candidate.trim().isNotEmpty) {
|
||||
addCode(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parse(source['permission_codes']);
|
||||
parse(source['permission_scopes']);
|
||||
parse(source['group_permission_scopes']);
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _readMap(Map<String, dynamic> source, String key) {
|
||||
final value = source[key];
|
||||
if (value is Map<String, dynamic>) {
|
||||
@@ -162,3 +234,15 @@ int? _readOptionalInt(Map<String, dynamic>? source, String key) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _normalizeScopeCode(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith('scope:')) {
|
||||
return lowered;
|
||||
}
|
||||
return 'scope:$lowered';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class AuthPermission {
|
||||
Map<String, Set<PermissionAction>> toPermissionMap() {
|
||||
final normalized = PermissionResources.normalize(resource);
|
||||
final actionSet = <PermissionAction>{};
|
||||
final isScope = normalized.startsWith('scope:');
|
||||
for (final raw in actions) {
|
||||
final parsed = _parseAction(raw);
|
||||
if (parsed == null) {
|
||||
@@ -22,6 +23,9 @@ class AuthPermission {
|
||||
}
|
||||
actionSet.add(parsed);
|
||||
}
|
||||
if (actionSet.isEmpty && isScope) {
|
||||
actionSet.add(PermissionAction.view);
|
||||
}
|
||||
if (actionSet.isEmpty) {
|
||||
return <String, Set<PermissionAction>>{};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../domain/entities/inventory_detail.dart';
|
||||
import '../domain/entities/inventory_filters.dart';
|
||||
import '../domain/entities/inventory_summary_list_result.dart';
|
||||
import '../domain/repositories/inventory_repository.dart';
|
||||
|
||||
/// 재고 현황 API를 호출하는 애플리케이션 서비스.
|
||||
class InventoryService {
|
||||
const InventoryService({required InventoryRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final InventoryRepository _repository;
|
||||
|
||||
/// 재고 요약 목록을 조회한다.
|
||||
Future<InventorySummaryListResult> fetchSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) {
|
||||
return _repository.listSummaries(filter: filter);
|
||||
}
|
||||
|
||||
/// 특정 제품 상세를 조회한다.
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) {
|
||||
return _repository.fetchDetail(productId, filter: filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../domain/entities/inventory_counterparty.dart';
|
||||
import '../../domain/entities/inventory_event.dart';
|
||||
import '../../domain/entities/inventory_product.dart';
|
||||
import '../../domain/entities/inventory_transaction_reference.dart';
|
||||
import '../../domain/entities/inventory_vendor.dart';
|
||||
import '../../domain/entities/inventory_warehouse.dart';
|
||||
import '../../domain/entities/inventory_warehouse_balance.dart';
|
||||
|
||||
part 'inventory_common_dtos.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryVendorDto {
|
||||
const InventoryVendorDto({this.id, this.vendorName});
|
||||
|
||||
final int? id;
|
||||
final String? vendorName;
|
||||
|
||||
factory InventoryVendorDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryVendorDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryVendorDtoToJson(this);
|
||||
|
||||
InventoryVendor toEntity() {
|
||||
return InventoryVendor(id: id, name: (vendorName ?? '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryProductDto {
|
||||
const InventoryProductDto({
|
||||
required this.id,
|
||||
this.productCode,
|
||||
this.productName,
|
||||
this.vendor,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String? productCode;
|
||||
final String? productName;
|
||||
final InventoryVendorDto? vendor;
|
||||
|
||||
factory InventoryProductDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryProductDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryProductDtoToJson(this);
|
||||
|
||||
InventoryProduct toEntity() {
|
||||
return InventoryProduct(
|
||||
id: id,
|
||||
code: (productCode ?? '').trim(),
|
||||
name: (productName ?? '').trim(),
|
||||
vendor: vendor?.toEntity(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryWarehouseDto {
|
||||
const InventoryWarehouseDto({
|
||||
required this.id,
|
||||
this.warehouseCode,
|
||||
this.warehouseName,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String? warehouseCode;
|
||||
final String? warehouseName;
|
||||
|
||||
factory InventoryWarehouseDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryWarehouseDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryWarehouseDtoToJson(this);
|
||||
|
||||
InventoryWarehouse toEntity() => InventoryWarehouse(
|
||||
id: id,
|
||||
code: (warehouseCode ?? '').trim(),
|
||||
name: (warehouseName ?? '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryWarehouseBalanceDto {
|
||||
const InventoryWarehouseBalanceDto({
|
||||
required this.warehouse,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
final InventoryWarehouseDto warehouse;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int quantity;
|
||||
|
||||
factory InventoryWarehouseBalanceDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryWarehouseBalanceDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryWarehouseBalanceDtoToJson(this);
|
||||
|
||||
InventoryWarehouseBalance toEntity() => InventoryWarehouseBalance(
|
||||
warehouse: warehouse.toEntity(),
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class InventoryCounterpartyDto {
|
||||
const InventoryCounterpartyDto({this.type, this.name});
|
||||
|
||||
final String? type;
|
||||
final String? name;
|
||||
|
||||
factory InventoryCounterpartyDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryCounterpartyDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryCounterpartyDtoToJson(this);
|
||||
|
||||
InventoryCounterparty toEntity() {
|
||||
final normalized = (type ?? '').toLowerCase();
|
||||
InventoryCounterpartyType resolvedType;
|
||||
switch (normalized) {
|
||||
case 'vendor':
|
||||
resolvedType = InventoryCounterpartyType.vendor;
|
||||
break;
|
||||
case 'customer':
|
||||
resolvedType = InventoryCounterpartyType.customer;
|
||||
break;
|
||||
default:
|
||||
resolvedType = InventoryCounterpartyType.unknown;
|
||||
break;
|
||||
}
|
||||
return InventoryCounterparty(type: resolvedType, name: name?.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class InventoryTransactionRefDto {
|
||||
const InventoryTransactionRefDto({required this.id, this.transactionNo});
|
||||
|
||||
final int id;
|
||||
final String? transactionNo;
|
||||
|
||||
factory InventoryTransactionRefDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryTransactionRefDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryTransactionRefDtoToJson(this);
|
||||
|
||||
InventoryTransactionReference toEntity() => InventoryTransactionReference(
|
||||
id: id,
|
||||
transactionNo: (transactionNo ?? '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake)
|
||||
class InventoryEventLineRefDto {
|
||||
const InventoryEventLineRefDto({
|
||||
required this.id,
|
||||
this.lineNo,
|
||||
this.quantity,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int? lineNo;
|
||||
@JsonKey(fromJson: _parseNullableQuantity)
|
||||
final int? quantity;
|
||||
|
||||
factory InventoryEventLineRefDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryEventLineRefDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryEventLineRefDtoToJson(this);
|
||||
|
||||
InventoryEventLineReference toEntity() => InventoryEventLineReference(
|
||||
id: id,
|
||||
lineNo: lineNo ?? 0,
|
||||
quantity: quantity ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryEventDto {
|
||||
const InventoryEventDto({
|
||||
required this.eventId,
|
||||
required this.eventKind,
|
||||
required this.eventLabel,
|
||||
required this.deltaQuantity,
|
||||
required this.occurredAt,
|
||||
this.counterparty,
|
||||
this.warehouse,
|
||||
this.transaction,
|
||||
this.line,
|
||||
});
|
||||
|
||||
final int eventId;
|
||||
final String eventKind;
|
||||
final String eventLabel;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int deltaQuantity;
|
||||
final DateTime occurredAt;
|
||||
final InventoryCounterpartyDto? counterparty;
|
||||
final InventoryWarehouseDto? warehouse;
|
||||
final InventoryTransactionRefDto? transaction;
|
||||
final InventoryEventLineRefDto? line;
|
||||
|
||||
factory InventoryEventDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryEventDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryEventDtoToJson(this);
|
||||
|
||||
InventoryEvent toEntity() => InventoryEvent(
|
||||
eventId: eventId,
|
||||
eventKind: eventKind,
|
||||
eventLabel: eventLabel,
|
||||
deltaQuantity: deltaQuantity,
|
||||
occurredAt: occurredAt,
|
||||
counterparty: counterparty?.toEntity(),
|
||||
warehouse: warehouse?.toEntity(),
|
||||
transaction: transaction?.toEntity(),
|
||||
line: line?.toEntity(),
|
||||
);
|
||||
}
|
||||
|
||||
int _parseQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final sanitized = value.replaceAll(',', '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = num.tryParse(sanitized);
|
||||
if (parsed != null) {
|
||||
return parsed.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int? _parseNullableQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return _parseQuantity(value);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'inventory_common_dtos.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
InventoryVendorDto _$InventoryVendorDtoFromJson(Map<String, dynamic> json) =>
|
||||
InventoryVendorDto(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
vendorName: json['vendor_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryVendorDtoToJson(InventoryVendorDto instance) =>
|
||||
<String, dynamic>{'id': instance.id, 'vendor_name': instance.vendorName};
|
||||
|
||||
InventoryProductDto _$InventoryProductDtoFromJson(Map<String, dynamic> json) =>
|
||||
InventoryProductDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
productCode: json['product_code'] as String?,
|
||||
productName: json['product_name'] as String?,
|
||||
vendor: json['vendor'] == null
|
||||
? null
|
||||
: InventoryVendorDto.fromJson(json['vendor'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryProductDtoToJson(
|
||||
InventoryProductDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'product_code': instance.productCode,
|
||||
'product_name': instance.productName,
|
||||
'vendor': instance.vendor?.toJson(),
|
||||
};
|
||||
|
||||
InventoryWarehouseDto _$InventoryWarehouseDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryWarehouseDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
warehouseCode: json['warehouse_code'] as String?,
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryWarehouseDtoToJson(
|
||||
InventoryWarehouseDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'warehouse_code': instance.warehouseCode,
|
||||
'warehouse_name': instance.warehouseName,
|
||||
};
|
||||
|
||||
InventoryWarehouseBalanceDto _$InventoryWarehouseBalanceDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryWarehouseBalanceDto(
|
||||
warehouse: InventoryWarehouseDto.fromJson(
|
||||
json['warehouse'] as Map<String, dynamic>,
|
||||
),
|
||||
quantity: _parseQuantity(json['quantity']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryWarehouseBalanceDtoToJson(
|
||||
InventoryWarehouseBalanceDto instance,
|
||||
) => <String, dynamic>{
|
||||
'warehouse': instance.warehouse.toJson(),
|
||||
'quantity': instance.quantity,
|
||||
};
|
||||
|
||||
InventoryCounterpartyDto _$InventoryCounterpartyDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryCounterpartyDto(
|
||||
type: json['type'] as String?,
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryCounterpartyDtoToJson(
|
||||
InventoryCounterpartyDto instance,
|
||||
) => <String, dynamic>{'type': instance.type, 'name': instance.name};
|
||||
|
||||
InventoryTransactionRefDto _$InventoryTransactionRefDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryTransactionRefDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
transactionNo: json['transaction_no'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryTransactionRefDtoToJson(
|
||||
InventoryTransactionRefDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'transaction_no': instance.transactionNo,
|
||||
};
|
||||
|
||||
InventoryEventLineRefDto _$InventoryEventLineRefDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryEventLineRefDto(
|
||||
id: (json['id'] as num).toInt(),
|
||||
lineNo: (json['line_no'] as num?)?.toInt(),
|
||||
quantity: _parseNullableQuantity(json['quantity']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryEventLineRefDtoToJson(
|
||||
InventoryEventLineRefDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'line_no': instance.lineNo,
|
||||
'quantity': instance.quantity,
|
||||
};
|
||||
|
||||
InventoryEventDto _$InventoryEventDtoFromJson(Map<String, dynamic> json) =>
|
||||
InventoryEventDto(
|
||||
eventId: (json['event_id'] as num).toInt(),
|
||||
eventKind: json['event_kind'] as String,
|
||||
eventLabel: json['event_label'] as String,
|
||||
deltaQuantity: _parseQuantity(json['delta_quantity']),
|
||||
occurredAt: DateTime.parse(json['occurred_at'] as String),
|
||||
counterparty: json['counterparty'] == null
|
||||
? null
|
||||
: InventoryCounterpartyDto.fromJson(
|
||||
json['counterparty'] as Map<String, dynamic>,
|
||||
),
|
||||
warehouse: json['warehouse'] == null
|
||||
? null
|
||||
: InventoryWarehouseDto.fromJson(
|
||||
json['warehouse'] as Map<String, dynamic>,
|
||||
),
|
||||
transaction: json['transaction'] == null
|
||||
? null
|
||||
: InventoryTransactionRefDto.fromJson(
|
||||
json['transaction'] as Map<String, dynamic>,
|
||||
),
|
||||
line: json['line'] == null
|
||||
? null
|
||||
: InventoryEventLineRefDto.fromJson(
|
||||
json['line'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryEventDtoToJson(InventoryEventDto instance) =>
|
||||
<String, dynamic>{
|
||||
'event_id': instance.eventId,
|
||||
'event_kind': instance.eventKind,
|
||||
'event_label': instance.eventLabel,
|
||||
'delta_quantity': instance.deltaQuantity,
|
||||
'occurred_at': instance.occurredAt.toIso8601String(),
|
||||
'counterparty': instance.counterparty?.toJson(),
|
||||
'warehouse': instance.warehouse?.toJson(),
|
||||
'transaction': instance.transaction?.toJson(),
|
||||
'line': instance.line?.toJson(),
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../domain/entities/inventory_detail.dart';
|
||||
import 'inventory_common_dtos.dart';
|
||||
|
||||
part 'inventory_detail_response.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryDetailResponse {
|
||||
const InventoryDetailResponse({required this.data});
|
||||
|
||||
final InventoryDetailDataDto data;
|
||||
|
||||
factory InventoryDetailResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryDetailResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryDetailResponseToJson(this);
|
||||
|
||||
InventoryDetail toEntity() => data.toEntity();
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventoryDetailDataDto {
|
||||
const InventoryDetailDataDto({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
List<InventoryWarehouseBalanceDto>? warehouseBalances,
|
||||
List<InventoryEventDto>? recentEvents,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
}) : warehouseBalances = warehouseBalances ?? const [],
|
||||
recentEvents = recentEvents ?? const [];
|
||||
|
||||
final InventoryProductDto product;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int totalQuantity;
|
||||
@JsonKey(defaultValue: <InventoryWarehouseBalanceDto>[])
|
||||
final List<InventoryWarehouseBalanceDto> warehouseBalances;
|
||||
@JsonKey(defaultValue: <InventoryEventDto>[])
|
||||
final List<InventoryEventDto> recentEvents;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
factory InventoryDetailDataDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventoryDetailDataDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventoryDetailDataDtoToJson(this);
|
||||
|
||||
InventoryDetail toEntity() => InventoryDetail(
|
||||
product: product.toEntity(),
|
||||
totalQuantity: totalQuantity,
|
||||
warehouseBalances: warehouseBalances
|
||||
.map((balance) => balance.toEntity())
|
||||
.toList(growable: false),
|
||||
recentEvents: recentEvents
|
||||
.map((event) => event.toEntity())
|
||||
.toList(growable: false),
|
||||
updatedAt: updatedAt,
|
||||
lastRefreshedAt: lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
int _parseQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final sanitized = value.replaceAll(',', '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = num.tryParse(sanitized);
|
||||
if (parsed != null) {
|
||||
return parsed.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'inventory_detail_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
InventoryDetailResponse _$InventoryDetailResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryDetailResponse(
|
||||
data: InventoryDetailDataDto.fromJson(json['data'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryDetailResponseToJson(
|
||||
InventoryDetailResponse instance,
|
||||
) => <String, dynamic>{'data': instance.data.toJson()};
|
||||
|
||||
InventoryDetailDataDto _$InventoryDetailDataDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventoryDetailDataDto(
|
||||
product: InventoryProductDto.fromJson(
|
||||
json['product'] as Map<String, dynamic>,
|
||||
),
|
||||
totalQuantity: _parseQuantity(json['total_quantity']),
|
||||
warehouseBalances:
|
||||
(json['warehouse_balances'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => InventoryWarehouseBalanceDto.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
recentEvents:
|
||||
(json['recent_events'] as List<dynamic>?)
|
||||
?.map((e) => InventoryEventDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
updatedAt: json['updated_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updated_at'] as String),
|
||||
lastRefreshedAt: json['last_refreshed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_refreshed_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventoryDetailDataDtoToJson(
|
||||
InventoryDetailDataDto instance,
|
||||
) => <String, dynamic>{
|
||||
'product': instance.product.toJson(),
|
||||
'total_quantity': instance.totalQuantity,
|
||||
'warehouse_balances': instance.warehouseBalances
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
'recent_events': instance.recentEvents.map((e) => e.toJson()).toList(),
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'last_refreshed_at': instance.lastRefreshedAt?.toIso8601String(),
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../../domain/entities/inventory_summary.dart';
|
||||
import '../../domain/entities/inventory_summary_list_result.dart';
|
||||
import 'inventory_common_dtos.dart';
|
||||
|
||||
part 'inventory_summary_response.g.dart';
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventorySummaryResponse {
|
||||
const InventorySummaryResponse({
|
||||
List<InventorySummaryItemDto>? items,
|
||||
this.page = 1,
|
||||
this.pageSize = 0,
|
||||
this.total = 0,
|
||||
this.lastRefreshedAt,
|
||||
}) : items = items ?? const [];
|
||||
|
||||
@JsonKey(defaultValue: <InventorySummaryItemDto>[])
|
||||
final List<InventorySummaryItemDto> items;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int total;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
factory InventorySummaryResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventorySummaryResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventorySummaryResponseToJson(this);
|
||||
|
||||
InventorySummaryListResult toEntity() {
|
||||
final summaries = items
|
||||
.map((item) => item.toEntity())
|
||||
.toList(growable: false);
|
||||
final paginated = PaginatedResult<InventorySummary>(
|
||||
items: summaries,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: paginated,
|
||||
lastRefreshedAt:
|
||||
lastRefreshedAt ??
|
||||
(summaries.isNotEmpty ? summaries.first.lastRefreshedAt : null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
|
||||
class InventorySummaryItemDto {
|
||||
const InventorySummaryItemDto({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
List<InventoryWarehouseBalanceDto>? warehouseBalances,
|
||||
this.recentEvent,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
}) : warehouseBalances = warehouseBalances ?? const [];
|
||||
|
||||
final InventoryProductDto product;
|
||||
@JsonKey(fromJson: _parseQuantity)
|
||||
final int totalQuantity;
|
||||
@JsonKey(defaultValue: <InventoryWarehouseBalanceDto>[])
|
||||
final List<InventoryWarehouseBalanceDto> warehouseBalances;
|
||||
final InventoryEventDto? recentEvent;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
factory InventorySummaryItemDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$InventorySummaryItemDtoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$InventorySummaryItemDtoToJson(this);
|
||||
|
||||
InventorySummary toEntity() {
|
||||
final balances = warehouseBalances
|
||||
.map((balance) => balance.toEntity())
|
||||
.toList(growable: false);
|
||||
return InventorySummary(
|
||||
product: product.toEntity(),
|
||||
totalQuantity: totalQuantity,
|
||||
warehouseBalances: balances,
|
||||
recentEvent: recentEvent?.toEntity(),
|
||||
updatedAt: updatedAt,
|
||||
lastRefreshedAt: lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _parseQuantity(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final sanitized = value.replaceAll(',', '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = num.tryParse(sanitized);
|
||||
if (parsed != null) {
|
||||
return parsed.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'inventory_summary_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
InventorySummaryResponse _$InventorySummaryResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventorySummaryResponse(
|
||||
items:
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => InventorySummaryItemDto.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (json['page_size'] as num?)?.toInt() ?? 0,
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
lastRefreshedAt: json['last_refreshed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_refreshed_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventorySummaryResponseToJson(
|
||||
InventorySummaryResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'items': instance.items.map((e) => e.toJson()).toList(),
|
||||
'page': instance.page,
|
||||
'page_size': instance.pageSize,
|
||||
'total': instance.total,
|
||||
'last_refreshed_at': instance.lastRefreshedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
InventorySummaryItemDto _$InventorySummaryItemDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => InventorySummaryItemDto(
|
||||
product: InventoryProductDto.fromJson(
|
||||
json['product'] as Map<String, dynamic>,
|
||||
),
|
||||
totalQuantity: _parseQuantity(json['total_quantity']),
|
||||
warehouseBalances:
|
||||
(json['warehouse_balances'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => InventoryWarehouseBalanceDto.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
recentEvent: json['recent_event'] == null
|
||||
? null
|
||||
: InventoryEventDto.fromJson(
|
||||
json['recent_event'] as Map<String, dynamic>,
|
||||
),
|
||||
updatedAt: json['updated_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['updated_at'] as String),
|
||||
lastRefreshedAt: json['last_refreshed_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_refreshed_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$InventorySummaryItemDtoToJson(
|
||||
InventorySummaryItemDto instance,
|
||||
) => <String, dynamic>{
|
||||
'product': instance.product.toJson(),
|
||||
'total_quantity': instance.totalQuantity,
|
||||
'warehouse_balances': instance.warehouseBalances
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
'recent_event': instance.recentEvent?.toJson(),
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'last_refreshed_at': instance.lastRefreshedAt?.toIso8601String(),
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:superport_v2/core/network/api_client.dart';
|
||||
import 'package:superport_v2/core/network/api_routes.dart';
|
||||
|
||||
import '../../domain/entities/inventory_detail.dart';
|
||||
import '../../domain/entities/inventory_filters.dart';
|
||||
import '../../domain/entities/inventory_summary_list_result.dart';
|
||||
import '../../domain/repositories/inventory_repository.dart';
|
||||
import '../dtos/inventory_detail_response.dart';
|
||||
import '../dtos/inventory_summary_response.dart';
|
||||
|
||||
/// 재고 현황 API를 호출하는 원격 저장소 구현체.
|
||||
class InventoryRepositoryRemote implements InventoryRepository {
|
||||
InventoryRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _summaryPath = ApiRoutes.inventorySummary;
|
||||
|
||||
@override
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) async {
|
||||
final effectiveFilter = filter ?? const InventorySummaryFilter();
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_summaryPath,
|
||||
query: effectiveFilter.toQuery(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final body = response.data ?? const <String, dynamic>{};
|
||||
return InventorySummaryResponse.fromJson(body).toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) async {
|
||||
final effectiveFilter = filter ?? const InventoryDetailFilter();
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.inventorySummaryDetail(productId),
|
||||
query: effectiveFilter.toQuery(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final body = response.data ?? const <String, dynamic>{};
|
||||
return InventoryDetailResponse.fromJson(body).toEntity();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 재고 이벤트와 연결된 거래처 유형.
|
||||
enum InventoryCounterpartyType { vendor, customer, unknown }
|
||||
|
||||
/// 재고 이벤트의 거래처 정보를 표현한다.
|
||||
class InventoryCounterparty {
|
||||
const InventoryCounterparty({required this.type, this.name});
|
||||
|
||||
final InventoryCounterpartyType type;
|
||||
final String? name;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'inventory_event.dart';
|
||||
import 'inventory_product.dart';
|
||||
import 'inventory_warehouse_balance.dart';
|
||||
|
||||
/// 재고 현황 단건 조회 결과.
|
||||
class InventoryDetail {
|
||||
const InventoryDetail({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
required this.warehouseBalances,
|
||||
required this.recentEvents,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
});
|
||||
|
||||
final InventoryProduct product;
|
||||
final int totalQuantity;
|
||||
final List<InventoryWarehouseBalance> warehouseBalances;
|
||||
final List<InventoryEvent> recentEvents;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'inventory_counterparty.dart';
|
||||
import 'inventory_transaction_reference.dart';
|
||||
import 'inventory_warehouse.dart';
|
||||
|
||||
/// 재고 변동 이벤트 요약/상세 정보.
|
||||
class InventoryEvent {
|
||||
const InventoryEvent({
|
||||
required this.eventId,
|
||||
required this.eventKind,
|
||||
required this.eventLabel,
|
||||
required this.deltaQuantity,
|
||||
required this.occurredAt,
|
||||
this.counterparty,
|
||||
this.warehouse,
|
||||
this.transaction,
|
||||
this.line,
|
||||
});
|
||||
|
||||
/// 이벤트 식별자.
|
||||
final int eventId;
|
||||
|
||||
/// 이벤트 종류(`receipt`, `issue`, `rental_out`, `rental_return`).
|
||||
final String eventKind;
|
||||
|
||||
/// 현지화된 이벤트 라벨.
|
||||
final String eventLabel;
|
||||
|
||||
/// 수량 증감.
|
||||
final int deltaQuantity;
|
||||
|
||||
/// 발생 시각(UTC).
|
||||
final DateTime occurredAt;
|
||||
|
||||
/// 거래처 정보.
|
||||
final InventoryCounterparty? counterparty;
|
||||
|
||||
/// 이벤트가 발생한 창고 정보.
|
||||
final InventoryWarehouse? warehouse;
|
||||
|
||||
/// 연결된 전표 정보.
|
||||
final InventoryTransactionReference? transaction;
|
||||
|
||||
/// 연결된 라인 정보.
|
||||
final InventoryEventLineReference? line;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/// 재고 요약 목록 조회 필터.
|
||||
class InventorySummaryFilter {
|
||||
const InventorySummaryFilter({
|
||||
this.page = 1,
|
||||
this.pageSize = 50,
|
||||
this.query,
|
||||
this.productName,
|
||||
this.vendorName,
|
||||
this.warehouseId,
|
||||
this.includeEmpty = false,
|
||||
this.updatedSince,
|
||||
this.sort,
|
||||
this.order,
|
||||
});
|
||||
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final String? query;
|
||||
final String? productName;
|
||||
final String? vendorName;
|
||||
final int? warehouseId;
|
||||
final bool includeEmpty;
|
||||
final DateTime? updatedSince;
|
||||
final String? sort;
|
||||
final String? order;
|
||||
|
||||
/// API 요청에 사용할 쿼리 파라미터 맵을 생성한다.
|
||||
Map<String, dynamic> toQuery() {
|
||||
final queryMap = <String, dynamic>{'page': page, 'page_size': pageSize};
|
||||
void put(String key, dynamic value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value is String) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
queryMap[key] = trimmed;
|
||||
return;
|
||||
}
|
||||
queryMap[key] = value;
|
||||
}
|
||||
|
||||
put('q', query);
|
||||
put('product_name', productName);
|
||||
put('vendor_name', vendorName);
|
||||
if (warehouseId != null) {
|
||||
queryMap['warehouse_id'] = warehouseId;
|
||||
}
|
||||
if (includeEmpty) {
|
||||
queryMap['include_empty'] = 'true';
|
||||
}
|
||||
if (updatedSince != null) {
|
||||
queryMap['updated_since'] = updatedSince!.toUtc().toIso8601String();
|
||||
}
|
||||
put('sort', sort);
|
||||
final normalizedOrder = order?.trim().toLowerCase();
|
||||
if (normalizedOrder != null && normalizedOrder.isNotEmpty) {
|
||||
queryMap['order'] = normalizedOrder;
|
||||
}
|
||||
return queryMap;
|
||||
}
|
||||
}
|
||||
|
||||
/// 재고 단건 조회 필터.
|
||||
class InventoryDetailFilter {
|
||||
const InventoryDetailFilter({this.warehouseId, this.eventLimit = 20});
|
||||
|
||||
final int? warehouseId;
|
||||
final int eventLimit;
|
||||
|
||||
Map<String, dynamic> toQuery() {
|
||||
final map = <String, dynamic>{'event_limit': eventLimit};
|
||||
if (warehouseId != null) {
|
||||
map['warehouse_id'] = warehouseId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'inventory_vendor.dart';
|
||||
|
||||
/// 재고 요약/상세 뷰에서 공통으로 사용하는 제품 정보.
|
||||
class InventoryProduct {
|
||||
const InventoryProduct({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.vendor,
|
||||
});
|
||||
|
||||
/// 제품 식별자.
|
||||
final int id;
|
||||
|
||||
/// 제품 코드.
|
||||
final String code;
|
||||
|
||||
/// 제품 명칭.
|
||||
final String name;
|
||||
|
||||
/// 공급사 정보. 없을 수도 있다.
|
||||
final InventoryVendor? vendor;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'inventory_event.dart';
|
||||
import 'inventory_product.dart';
|
||||
import 'inventory_warehouse_balance.dart';
|
||||
|
||||
/// 재고 현황 목록 항목 엔티티.
|
||||
class InventorySummary {
|
||||
const InventorySummary({
|
||||
required this.product,
|
||||
required this.totalQuantity,
|
||||
required this.warehouseBalances,
|
||||
this.recentEvent,
|
||||
this.updatedAt,
|
||||
this.lastRefreshedAt,
|
||||
});
|
||||
|
||||
final InventoryProduct product;
|
||||
final int totalQuantity;
|
||||
final List<InventoryWarehouseBalance> warehouseBalances;
|
||||
final InventoryEvent? recentEvent;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? lastRefreshedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import 'inventory_summary.dart';
|
||||
|
||||
/// 재고 요약 목록과 뷰 리프레시 메타데이터를 함께 담는 결과 모델.
|
||||
class InventorySummaryListResult {
|
||||
const InventorySummaryListResult({
|
||||
required this.result,
|
||||
this.lastRefreshedAt,
|
||||
});
|
||||
|
||||
final PaginatedResult<InventorySummary> result;
|
||||
final DateTime? lastRefreshedAt;
|
||||
|
||||
InventorySummaryListResult copyWith({
|
||||
PaginatedResult<InventorySummary>? result,
|
||||
DateTime? lastRefreshedAt,
|
||||
}) {
|
||||
return InventorySummaryListResult(
|
||||
result: result ?? this.result,
|
||||
lastRefreshedAt: lastRefreshedAt ?? this.lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// 재고 이벤트가 속한 전표 정보를 요약한 참조 모델.
|
||||
class InventoryTransactionReference {
|
||||
const InventoryTransactionReference({
|
||||
required this.id,
|
||||
required this.transactionNo,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String transactionNo;
|
||||
}
|
||||
|
||||
/// 재고 이벤트 라인 정보 참조 모델.
|
||||
class InventoryEventLineReference {
|
||||
const InventoryEventLineReference({
|
||||
required this.id,
|
||||
required this.lineNo,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int lineNo;
|
||||
final int quantity;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 재고 요약에서 사용하는 공급사 정보를 표현하는 값 객체.
|
||||
class InventoryVendor {
|
||||
const InventoryVendor({this.id, required this.name});
|
||||
|
||||
/// 공급사 식별자. 미정의일 수 있다.
|
||||
final int? id;
|
||||
|
||||
/// 공급사 명칭.
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 재고 현황에서 참조하는 창고 정보를 표현한다.
|
||||
class InventoryWarehouse {
|
||||
const InventoryWarehouse({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
/// 창고 식별자.
|
||||
final int id;
|
||||
|
||||
/// 창고 코드.
|
||||
final String code;
|
||||
|
||||
/// 창고 명칭.
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'inventory_warehouse.dart';
|
||||
|
||||
/// 특정 창고의 재고 수량을 나타내는 모델.
|
||||
class InventoryWarehouseBalance {
|
||||
const InventoryWarehouseBalance({
|
||||
required this.warehouse,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
/// 창고 정보.
|
||||
final InventoryWarehouse warehouse;
|
||||
|
||||
/// 창고 내 잔량.
|
||||
final int quantity;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../entities/inventory_detail.dart';
|
||||
import '../entities/inventory_filters.dart';
|
||||
import '../entities/inventory_summary_list_result.dart';
|
||||
|
||||
/// 재고 현황 데이터를 제공하는 저장소 인터페이스.
|
||||
abstract class InventoryRepository {
|
||||
/// 재고 요약 목록을 조회한다.
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
});
|
||||
|
||||
/// 특정 제품의 상세 정보를 조회한다.
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
|
||||
import '../../../summary/application/inventory_service.dart';
|
||||
import '../../../summary/domain/entities/inventory_detail.dart';
|
||||
import '../../../summary/domain/entities/inventory_filters.dart';
|
||||
|
||||
/// 재고 현황 단건 상태를 관리하는 컨트롤러.
|
||||
class InventoryDetailController extends ChangeNotifier {
|
||||
InventoryDetailController({required InventoryService service})
|
||||
: _service = service;
|
||||
|
||||
final InventoryService _service;
|
||||
final Map<int, _InventoryDetailState> _states = {};
|
||||
|
||||
InventoryDetail? detailOf(int productId) => _states[productId]?.detail;
|
||||
|
||||
InventoryDetailFilter filterOf(int productId) =>
|
||||
_states[productId]?.filter ?? const InventoryDetailFilter();
|
||||
|
||||
bool isLoading(int productId) => _states[productId]?.isLoading ?? false;
|
||||
|
||||
String? errorOf(int productId) => _states[productId]?.errorMessage;
|
||||
|
||||
/// 단건 상세를 조회한다. [force]가 true면 캐시 여부와 관계없이 재조회한다.
|
||||
Future<void> fetch(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
bool force = false,
|
||||
}) async {
|
||||
final current = _states[productId];
|
||||
final effectiveFilter =
|
||||
filter ?? current?.filter ?? const InventoryDetailFilter();
|
||||
if (!force &&
|
||||
current != null &&
|
||||
current.detail != null &&
|
||||
!_hasFilterChanged(current.filter, effectiveFilter) &&
|
||||
!current.isLoading &&
|
||||
current.errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_states[productId] =
|
||||
(current ?? _InventoryDetailState(filter: effectiveFilter)).copyWith(
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
filter: effectiveFilter,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final detail = await _service.fetchDetail(
|
||||
productId,
|
||||
filter: effectiveFilter,
|
||||
);
|
||||
_states[productId] = _states[productId]!.copyWith(
|
||||
detail: detail,
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
filter: effectiveFilter,
|
||||
);
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_states[productId] = _states[productId]!.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: failure.describe(),
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 이벤트 개수 제한을 변경하고 다시 조회한다.
|
||||
Future<void> updateEventLimit(int productId, int limit) {
|
||||
final current = filterOf(productId);
|
||||
final next = InventoryDetailFilter(
|
||||
warehouseId: current.warehouseId,
|
||||
eventLimit: limit,
|
||||
);
|
||||
return fetch(productId, filter: next, force: true);
|
||||
}
|
||||
|
||||
/// 특정 창고 기준으로 상세를 조회한다.
|
||||
Future<void> updateWarehouseFilter(int productId, int? warehouseId) {
|
||||
final current = filterOf(productId);
|
||||
final next = InventoryDetailFilter(
|
||||
warehouseId: warehouseId,
|
||||
eventLimit: current.eventLimit,
|
||||
);
|
||||
return fetch(productId, filter: next, force: true);
|
||||
}
|
||||
|
||||
void clearError(int productId) {
|
||||
final state = _states[productId];
|
||||
if (state == null || state.errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
_states[productId] = state.copyWith(errorMessage: null);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _hasFilterChanged(
|
||||
InventoryDetailFilter previous,
|
||||
InventoryDetailFilter next,
|
||||
) {
|
||||
return previous.warehouseId != next.warehouseId ||
|
||||
previous.eventLimit != next.eventLimit;
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryDetailState {
|
||||
const _InventoryDetailState({
|
||||
required this.filter,
|
||||
this.detail,
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final InventoryDetailFilter filter;
|
||||
final InventoryDetail? detail;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
_InventoryDetailState copyWith({
|
||||
InventoryDetailFilter? filter,
|
||||
InventoryDetail? detail,
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return _InventoryDetailState(
|
||||
filter: filter ?? this.filter,
|
||||
detail: detail ?? this.detail,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
|
||||
import '../../../summary/application/inventory_service.dart';
|
||||
import '../../../summary/domain/entities/inventory_filters.dart';
|
||||
import '../../../summary/domain/entities/inventory_summary.dart';
|
||||
|
||||
/// 재고 현황 목록 상태를 관리하는 컨트롤러.
|
||||
class InventorySummaryController extends ChangeNotifier {
|
||||
InventorySummaryController({required InventoryService service})
|
||||
: _service = service;
|
||||
|
||||
static const int defaultPageSize = 50;
|
||||
|
||||
final InventoryService _service;
|
||||
|
||||
PaginatedResult<InventorySummary>? _result;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
int _page = 1;
|
||||
int _pageSize = defaultPageSize;
|
||||
String _query = '';
|
||||
String? _productName;
|
||||
String? _vendorName;
|
||||
int? _warehouseId;
|
||||
bool _includeEmpty = false;
|
||||
DateTime? _updatedSince;
|
||||
String? _sort;
|
||||
String? _order;
|
||||
DateTime? _lastRefreshedAt;
|
||||
|
||||
PaginatedResult<InventorySummary>? get result => _result;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
int get page => _page;
|
||||
int get pageSize => _pageSize;
|
||||
String get query => _query;
|
||||
String? get productName => _productName;
|
||||
String? get vendorName => _vendorName;
|
||||
int? get warehouseId => _warehouseId;
|
||||
bool get includeEmpty => _includeEmpty;
|
||||
DateTime? get updatedSince => _updatedSince;
|
||||
String? get sort => _sort;
|
||||
String? get order => _order;
|
||||
DateTime? get lastRefreshedAt => _lastRefreshedAt;
|
||||
|
||||
/// 목록을 조회한다.
|
||||
Future<void> fetch({int? page}) async {
|
||||
final targetPage = page ?? _page;
|
||||
_setLoading(true);
|
||||
_errorMessage = null;
|
||||
try {
|
||||
final filter = _buildFilter(targetPage);
|
||||
final response = await _service.fetchSummaries(filter: filter);
|
||||
final paginated = response.result;
|
||||
_result = paginated;
|
||||
_lastRefreshedAt = response.lastRefreshedAt;
|
||||
_page = paginated.page;
|
||||
if (paginated.pageSize > 0) {
|
||||
_pageSize = paginated.pageSize;
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 조건으로 다시 조회한다.
|
||||
Future<void> refresh() => fetch(page: _page);
|
||||
|
||||
void updateQuery(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (_query == trimmed) {
|
||||
return;
|
||||
}
|
||||
_query = trimmed;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateProductName(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (_productName == trimmed) {
|
||||
return;
|
||||
}
|
||||
_productName = trimmed?.isEmpty ?? true ? null : trimmed;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateVendorName(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (_vendorName == trimmed) {
|
||||
return;
|
||||
}
|
||||
_vendorName = trimmed?.isEmpty ?? true ? null : trimmed;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateWarehouse(int? warehouseId) {
|
||||
if (_warehouseId == warehouseId) {
|
||||
return;
|
||||
}
|
||||
_warehouseId = warehouseId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleIncludeEmpty(bool value) {
|
||||
if (_includeEmpty == value) {
|
||||
return;
|
||||
}
|
||||
_includeEmpty = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateUpdatedSince(DateTime? value) {
|
||||
if (_updatedSince == value) {
|
||||
return;
|
||||
}
|
||||
_updatedSince = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateSort(String? value, {String? order}) {
|
||||
var changed = false;
|
||||
if (_sort != value) {
|
||||
_sort = value;
|
||||
changed = true;
|
||||
}
|
||||
if (order != null && _order != order) {
|
||||
_order = order;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void updatePageSize(int size) {
|
||||
if (size <= 0 || _pageSize == size) {
|
||||
return;
|
||||
}
|
||||
_pageSize = size;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (_errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
InventorySummaryFilter _buildFilter(int targetPage) {
|
||||
return InventorySummaryFilter(
|
||||
page: targetPage < 1 ? 1 : targetPage,
|
||||
pageSize: _pageSize,
|
||||
query: _query.isEmpty ? null : _query,
|
||||
productName: _productName,
|
||||
vendorName: _vendorName,
|
||||
warehouseId: _warehouseId,
|
||||
includeEmpty: _includeEmpty,
|
||||
updatedSince: _updatedSince,
|
||||
sort: _sort,
|
||||
order: _order,
|
||||
);
|
||||
}
|
||||
|
||||
void _setLoading(bool value) {
|
||||
if (_isLoading == value) {
|
||||
return;
|
||||
}
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,12 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../../core/constants/app_sections.dart';
|
||||
import '../../../../core/network/api_error.dart';
|
||||
import '../../../../core/network/failure.dart';
|
||||
import '../../../../core/permissions/permission_bootstrapper.dart';
|
||||
import '../../../../core/permissions/permission_manager.dart';
|
||||
import '../../../auth/application/auth_service.dart';
|
||||
import '../../../auth/domain/entities/auth_session.dart';
|
||||
import '../../../auth/domain/entities/login_request.dart';
|
||||
import '../../../masters/group/domain/entities/group.dart';
|
||||
import '../../../masters/group/domain/repositories/group_repository.dart';
|
||||
import '../../../masters/group_permission/application/permission_synchronizer.dart';
|
||||
import '../../../masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
|
||||
/// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다.
|
||||
@@ -356,66 +355,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
|
||||
Future<void> _applyPermissions(AuthSession session) async {
|
||||
final manager = PermissionScope.of(context);
|
||||
manager.clearServerPermissions();
|
||||
|
||||
final aggregated = <String, Set<PermissionAction>>{};
|
||||
for (final permission in session.permissions) {
|
||||
final map = permission.toPermissionMap();
|
||||
for (final entry in map.entries) {
|
||||
aggregated
|
||||
.putIfAbsent(entry.key, () => <PermissionAction>{})
|
||||
.addAll(entry.value);
|
||||
}
|
||||
}
|
||||
if (aggregated.isNotEmpty) {
|
||||
manager.applyServerPermissions(aggregated);
|
||||
return;
|
||||
}
|
||||
|
||||
await _synchronizePermissions(groupId: session.user.primaryGroupId);
|
||||
}
|
||||
|
||||
Future<void> _synchronizePermissions({int? groupId}) async {
|
||||
final manager = PermissionScope.of(context);
|
||||
manager.clearServerPermissions();
|
||||
|
||||
final groupRepository = GetIt.I<GroupRepository>();
|
||||
int? targetGroupId = groupId;
|
||||
|
||||
if (targetGroupId == null) {
|
||||
final defaultGroups = await groupRepository.list(
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
isDefault: true,
|
||||
);
|
||||
var targetGroup = _firstGroupWithId(defaultGroups.items);
|
||||
|
||||
if (targetGroup == null) {
|
||||
final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1);
|
||||
targetGroup = _firstGroupWithId(fallbackGroups.items);
|
||||
}
|
||||
targetGroupId = targetGroup?.id;
|
||||
}
|
||||
|
||||
if (targetGroupId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final permissionRepository = GetIt.I<GroupPermissionRepository>();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: permissionRepository,
|
||||
manager: manager,
|
||||
final bootstrapper = PermissionBootstrapper(
|
||||
manager: PermissionScope.of(context),
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
groupPermissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
);
|
||||
await synchronizer.syncForGroup(targetGroupId);
|
||||
}
|
||||
|
||||
Group? _firstGroupWithId(List<Group> groups) {
|
||||
for (final group in groups) {
|
||||
if (group.id != null) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
await bootstrapper.apply(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@ class PermissionSynchronizer {
|
||||
|
||||
/// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다.
|
||||
Future<void> syncForGroup(int groupId) async {
|
||||
final permissionMap = await fetchPermissionMap(groupId);
|
||||
_manager.applyServerPermissions(permissionMap);
|
||||
}
|
||||
|
||||
/// 지정한 [groupId]의 메뉴 권한을 조회해 맵 형태로 반환한다.
|
||||
Future<Map<String, Set<PermissionAction>>> fetchPermissionMap(
|
||||
int groupId,
|
||||
) async {
|
||||
final collected = await _collectPermissions(groupId);
|
||||
if (collected.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
return buildPermissionMap(collected);
|
||||
}
|
||||
|
||||
Future<List<GroupPermission>> _collectPermissions(int groupId) async {
|
||||
final collected = <GroupPermission>[];
|
||||
var page = 1;
|
||||
|
||||
@@ -45,7 +61,6 @@ class PermissionSynchronizer {
|
||||
page += 1;
|
||||
}
|
||||
|
||||
final permissionMap = buildPermissionMap(collected);
|
||||
_manager.applyServerPermissions(permissionMap);
|
||||
return collected;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,8 @@ class UserDetailDialogResult {
|
||||
}
|
||||
|
||||
typedef UserCreateCallback = Future<UserAccount?> Function(UserInput input);
|
||||
typedef UserUpdateCallback = Future<UserAccount?> Function(
|
||||
int id,
|
||||
UserInput input,
|
||||
);
|
||||
typedef UserUpdateCallback =
|
||||
Future<UserAccount?> Function(int id, UserInput input);
|
||||
typedef UserDeleteCallback = Future<bool> Function(int id);
|
||||
typedef UserRestoreCallback = Future<UserAccount?> Function(int id);
|
||||
typedef UserResetPasswordCallback = Future<UserAccount?> Function(int id);
|
||||
@@ -141,10 +139,8 @@ Future<UserDetailDialogResult?> showUserDetailDialog({
|
||||
id: _UserDetailSections.overview,
|
||||
label: '상세',
|
||||
icon: LucideIcons.info,
|
||||
builder: (_) => _UserOverviewSection(
|
||||
user: detailUser,
|
||||
dateFormat: dateFormat,
|
||||
),
|
||||
builder: (_) =>
|
||||
_UserOverviewSection(user: detailUser, dateFormat: dateFormat),
|
||||
),
|
||||
if (isDetail)
|
||||
SuperportDetailDialogSection(
|
||||
@@ -217,9 +213,7 @@ Future<UserDetailDialogResult?> showUserDetailDialog({
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '이메일',
|
||||
value: detailUser.email?.isEmpty ?? true
|
||||
? '-'
|
||||
: detailUser.email!,
|
||||
value: detailUser.email?.isEmpty ?? true ? '-' : detailUser.email!,
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '연락처',
|
||||
@@ -229,17 +223,13 @@ Future<UserDetailDialogResult?> showUserDetailDialog({
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비고',
|
||||
value: detailUser.note?.isEmpty ?? true
|
||||
? '-'
|
||||
: detailUser.note!,
|
||||
value: detailUser.note?.isEmpty ?? true ? '-' : detailUser.note!,
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비밀번호 변경일시',
|
||||
value: detailUser.passwordUpdatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(
|
||||
detailUser.passwordUpdatedAt!.toLocal(),
|
||||
),
|
||||
: dateFormat.format(detailUser.passwordUpdatedAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '생성일시',
|
||||
@@ -285,10 +275,7 @@ class _UserDetailSections {
|
||||
|
||||
/// 사용자 주요 정보를 표시하는 섹션이다.
|
||||
class _UserOverviewSection extends StatelessWidget {
|
||||
const _UserOverviewSection({
|
||||
required this.user,
|
||||
required this.dateFormat,
|
||||
});
|
||||
const _UserOverviewSection({required this.user, required this.dateFormat});
|
||||
|
||||
final UserAccount user;
|
||||
final intl.DateFormat dateFormat;
|
||||
@@ -339,10 +326,7 @@ class _UserOverviewSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
rows[i].value,
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
child: Text(rows[i].value, style: theme.textTheme.small),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -426,8 +410,9 @@ class _UserSecurityContentState extends State<_UserSecurityContent> {
|
||||
label: '비밀번호 변경일시',
|
||||
value: widget.user.passwordUpdatedAt == null
|
||||
? '-'
|
||||
: widget.dateFormat
|
||||
.format(widget.user.passwordUpdatedAt!.toLocal()),
|
||||
: widget.dateFormat.format(
|
||||
widget.user.passwordUpdatedAt!.toLocal(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_KeyValueColumn(
|
||||
@@ -571,8 +556,7 @@ class _UserFormState extends State<_UserForm> {
|
||||
_groupIdNotifier = ValueNotifier<int?>(user?.group?.id);
|
||||
_isActiveNotifier = ValueNotifier<bool>(user?.isActive ?? true);
|
||||
|
||||
if (_groupIdNotifier.value == null &&
|
||||
widget.groupOptions.length == 1) {
|
||||
if (_groupIdNotifier.value == null && widget.groupOptions.length == 1) {
|
||||
_groupIdNotifier.value = widget.groupOptions.first.id;
|
||||
}
|
||||
}
|
||||
@@ -613,8 +597,7 @@ class _UserFormState extends State<_UserForm> {
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_employeeError != null)
|
||||
_ErrorText(_employeeError!),
|
||||
if (_employeeError != null) _ErrorText(_employeeError!),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -37,6 +37,9 @@ import 'features/dashboard/data/repositories/dashboard_repository_remote.dart';
|
||||
import 'features/dashboard/domain/repositories/dashboard_repository.dart';
|
||||
import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart';
|
||||
import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import 'features/inventory/summary/application/inventory_service.dart';
|
||||
import 'features/inventory/summary/data/repositories/inventory_repository_remote.dart';
|
||||
import 'features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
|
||||
import 'features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart';
|
||||
import 'features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart';
|
||||
@@ -236,6 +239,12 @@ void _registerApprovalDependencies() {
|
||||
|
||||
void _registerInventoryDependencies() {
|
||||
sl
|
||||
..registerLazySingleton<InventoryRepository>(
|
||||
() => InventoryRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
)
|
||||
..registerLazySingleton<InventoryService>(
|
||||
() => InventoryService(repository: sl<InventoryRepository>()),
|
||||
)
|
||||
..registerLazySingleton<InventoryLookupRepository>(
|
||||
() => InventoryLookupRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'core/config/environment.dart';
|
||||
import 'core/permissions/permission_bootstrapper.dart';
|
||||
import 'core/permissions/permission_manager.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'core/theme/superport_shad_theme.dart';
|
||||
import 'core/theme/theme_controller.dart';
|
||||
import 'features/auth/application/auth_service.dart';
|
||||
import 'features/masters/group/domain/repositories/group_repository.dart';
|
||||
import 'features/masters/group_permission/domain/repositories/group_permission_repository.dart';
|
||||
import 'injection_container.dart';
|
||||
|
||||
/// Superport 애플리케이션 진입점. 환경 초기화 후 앱 위젯을 실행한다.
|
||||
@@ -50,6 +55,7 @@ class _SuperportAppState extends State<SuperportApp> {
|
||||
GetIt.I.unregister<PermissionManager>();
|
||||
}
|
||||
GetIt.I.registerSingleton<PermissionManager>(_permissionManager);
|
||||
unawaited(_restorePermissions());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,4 +96,18 @@ class _SuperportAppState extends State<SuperportApp> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _restorePermissions() async {
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final session = authService.session;
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
final bootstrapper = PermissionBootstrapper(
|
||||
manager: _permissionManager,
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
groupPermissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
);
|
||||
await bootstrapper.apply(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AppShell extends StatelessWidget {
|
||||
final filteredPages = <AppPageDescriptor>[
|
||||
for (final section in appSections)
|
||||
for (final page in section.pages)
|
||||
if (manager.can(page.path, PermissionAction.view)) page,
|
||||
if (_hasPageAccess(manager, page)) page,
|
||||
];
|
||||
final pages = filteredPages.isEmpty ? allAppPages : filteredPages;
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
@@ -404,6 +404,19 @@ int _selectedIndex(String location, List<AppPageDescriptor> pages) {
|
||||
return prefix == -1 ? 0 : prefix;
|
||||
}
|
||||
|
||||
bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) {
|
||||
final requirements = <String>{page.path, ...page.extraRequiredResources};
|
||||
for (final resource in requirements) {
|
||||
if (resource.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (!manager.can(resource, PermissionAction.view)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼.
|
||||
class _AccountMenuButton extends StatelessWidget {
|
||||
const _AccountMenuButton({required this.service});
|
||||
|
||||
256
pubspec.lock
256
pubspec.lock
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "91.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.4.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +49,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.10.1"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +137,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +161,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -129,6 +225,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -255,6 +359,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.7.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -263,6 +375,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "16.2.2"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -279,6 +399,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -300,6 +428,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -308,6 +444,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -396,6 +548,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -484,6 +644,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pretty_dio_logger:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -500,6 +668,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
shadcn_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -508,11 +692,43 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.31.7"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.8"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -537,6 +753,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -697,6 +921,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -705,6 +937,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -737,6 +985,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -47,6 +47,7 @@ dependencies:
|
||||
flutter_secure_storage: ^9.2.2
|
||||
url_launcher: ^6.3.0
|
||||
web: ^0.5.1
|
||||
json_annotation: ^4.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -54,6 +55,8 @@ dev_dependencies:
|
||||
pretty_dio_logger: ^1.3.1
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
build_runner: ^2.4.11
|
||||
json_serializable: ^6.8.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
||||
@@ -92,5 +92,30 @@ void main() {
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('scope 리소스도 권한 검사에 활용된다', () {
|
||||
final manager = PermissionManager();
|
||||
manager.applyServerPermissions({
|
||||
PermissionResources.inventoryScope: {PermissionAction.view},
|
||||
});
|
||||
|
||||
expect(
|
||||
manager.can(PermissionResources.inventoryScope, PermissionAction.view),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.inventoryScope, PermissionAction.edit),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('scope 권한이 없으면 기본적으로 거부된다', () {
|
||||
final manager = PermissionManager();
|
||||
|
||||
expect(
|
||||
manager.can(PermissionResources.inventoryScope, PermissionAction.view),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,11 +170,7 @@ void main() {
|
||||
|
||||
record = historyRepository.listResult!.items.first;
|
||||
|
||||
user = const AuthenticatedUser(
|
||||
id: 42,
|
||||
name: '결재자',
|
||||
employeeNo: 'E042',
|
||||
);
|
||||
user = const AuthenticatedUser(id: 42, name: '결재자', employeeNo: 'E042');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -199,148 +195,143 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다',
|
||||
(tester) async {
|
||||
await openDialog(tester);
|
||||
testWidgets('showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester);
|
||||
|
||||
expect(find.text('결재 이력 상세'), findsOneWidget);
|
||||
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
|
||||
expect(find.text('상태 타임라인'), findsOneWidget);
|
||||
expect(find.text('감사 로그'), findsOneWidget);
|
||||
expect(find.text('결재 이력 상세'), findsOneWidget);
|
||||
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
|
||||
expect(find.text('상태 타임라인'), findsOneWidget);
|
||||
expect(find.text('감사 로그'), findsOneWidget);
|
||||
|
||||
expect(
|
||||
find.textContaining(
|
||||
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('총 ${sampleApproval.steps.length}단계'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('승인'), findsWidgets);
|
||||
expect(
|
||||
find.textContaining(
|
||||
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('총 ${sampleApproval.steps.length}단계'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('승인'), findsWidgets);
|
||||
|
||||
expect(approvalRepository.listHistoryCalls, isNotEmpty);
|
||||
},
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다',
|
||||
(tester) async {
|
||||
await openDialog(tester);
|
||||
testWidgets('회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다', (tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수');
|
||||
expect(recallButton, findsOneWidget);
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수');
|
||||
expect(recallButton, findsOneWidget);
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 회수'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 회수'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '긴급 회수');
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '긴급 회수');
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '회수'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '회수'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.recallInputs, hasLength(1));
|
||||
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
},
|
||||
);
|
||||
expect(approvalRepository.recallInputs, hasLength(1));
|
||||
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다',
|
||||
(tester) async {
|
||||
final rejectedApproval = sampleApproval.copyWith(
|
||||
status: statusRejected,
|
||||
steps: sampleApproval.steps
|
||||
.map(
|
||||
(step) => step.stepOrder == 1
|
||||
? step.copyWith(
|
||||
status: statusRejected,
|
||||
decidedAt: DateTime(2024, 1, 10, 11),
|
||||
)
|
||||
: step,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
final resubmittedStatus = ApprovalStatus(
|
||||
id: 4,
|
||||
name: '재상신',
|
||||
color: '#6366F1',
|
||||
isTerminal: false,
|
||||
);
|
||||
final resubmittedApproval = rejectedApproval.copyWith(
|
||||
status: resubmittedStatus,
|
||||
updatedAt: DateTime(2024, 1, 10, 13, 10),
|
||||
);
|
||||
testWidgets('재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다', (
|
||||
tester,
|
||||
) async {
|
||||
final rejectedApproval = sampleApproval.copyWith(
|
||||
status: statusRejected,
|
||||
steps: sampleApproval.steps
|
||||
.map(
|
||||
(step) => step.stepOrder == 1
|
||||
? step.copyWith(
|
||||
status: statusRejected,
|
||||
decidedAt: DateTime(2024, 1, 10, 11),
|
||||
)
|
||||
: step,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
final resubmittedStatus = ApprovalStatus(
|
||||
id: 4,
|
||||
name: '재상신',
|
||||
color: '#6366F1',
|
||||
isTerminal: false,
|
||||
);
|
||||
final resubmittedApproval = rejectedApproval.copyWith(
|
||||
status: resubmittedStatus,
|
||||
updatedAt: DateTime(2024, 1, 10, 13, 10),
|
||||
);
|
||||
|
||||
approvalRepository
|
||||
..detail = rejectedApproval
|
||||
..resubmitResult = resubmittedApproval;
|
||||
record = record.copyWith(
|
||||
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
|
||||
toStatus: statusRejected,
|
||||
stepOrder: 2,
|
||||
);
|
||||
approvalRepository
|
||||
..detail = rejectedApproval
|
||||
..resubmitResult = resubmittedApproval;
|
||||
record = record.copyWith(
|
||||
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
|
||||
toStatus: statusRejected,
|
||||
stepOrder: 2,
|
||||
);
|
||||
|
||||
await openDialog(tester);
|
||||
await openDialog(tester);
|
||||
|
||||
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
|
||||
expect(resubmitButton, findsOneWidget);
|
||||
await tester.ensureVisible(resubmitButton);
|
||||
await tester.tap(resubmitButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
|
||||
expect(resubmitButton, findsOneWidget);
|
||||
await tester.ensureVisible(resubmitButton);
|
||||
await tester.tap(resubmitButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 재상신'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 재상신'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '재상신 메모');
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '재상신 메모');
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '재상신'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '재상신'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.resubmitInputs, hasLength(1));
|
||||
final input = approvalRepository.resubmitInputs.first;
|
||||
expect(input.note, '재상신 메모');
|
||||
expect(input.submission.steps.length, rejectedApproval.steps.length);
|
||||
expect(
|
||||
input.submission.steps.first.stepOrder,
|
||||
rejectedApproval.steps.first.stepOrder,
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
},
|
||||
);
|
||||
expect(approvalRepository.resubmitInputs, hasLength(1));
|
||||
final input = approvalRepository.resubmitInputs.first;
|
||||
expect(input.note, '재상신 메모');
|
||||
expect(input.submission.steps.length, rejectedApproval.steps.length);
|
||||
expect(
|
||||
input.submission.steps.first.stepOrder,
|
||||
rejectedApproval.steps.first.stepOrder,
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeApprovalRepository implements ApprovalRepository {
|
||||
|
||||
71
test/features/auth/data/dtos/auth_session_dto_test.dart
Normal file
71
test/features/auth/data/dtos/auth_session_dto_test.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/features/auth/data/dtos/auth_session_dto.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthSessionDto', () {
|
||||
test('permission_codes를 scope 권한으로 변환한다', () {
|
||||
final dto = AuthSessionDto.fromJson({
|
||||
'access_token': 'access',
|
||||
'refresh_token': 'refresh',
|
||||
'user': {'id': 1, 'name': '테스터'},
|
||||
'permission_codes': [
|
||||
'inventory.view',
|
||||
'scope:approval.manage',
|
||||
' APPROVAL.VIEW_ALL ',
|
||||
],
|
||||
});
|
||||
|
||||
final scopeResources = dto.permissions.map((p) => p.resource).where((r) {
|
||||
return r.startsWith('scope:');
|
||||
}).toSet();
|
||||
|
||||
expect(
|
||||
scopeResources,
|
||||
containsAll({
|
||||
'scope:inventory.view',
|
||||
'scope:approval.manage',
|
||||
'scope:approval.view_all',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('permission_scopes 응답도 scope 권한으로 적용한다', () {
|
||||
final dto = AuthSessionDto.fromJson({
|
||||
'access_token': 'access',
|
||||
'refresh_token': 'refresh',
|
||||
'user': {'id': 10, 'name': '권한계정'},
|
||||
'permissions': [
|
||||
{
|
||||
'resource': '/dashboard',
|
||||
'actions': ['view'],
|
||||
},
|
||||
],
|
||||
'permission_scopes': [
|
||||
{'scope_code': 'inventory.view'},
|
||||
{'code': 'approval.view_all'},
|
||||
{'scope': 'approval.approve'},
|
||||
],
|
||||
'group_permission_scopes': [
|
||||
'scope:report.export',
|
||||
{'name': 'report.view'},
|
||||
],
|
||||
});
|
||||
|
||||
final scopeResources = dto.permissions.map((p) => p.resource).where((r) {
|
||||
return r.startsWith('scope:');
|
||||
}).toSet();
|
||||
|
||||
expect(
|
||||
scopeResources,
|
||||
containsAll({
|
||||
'scope:inventory.view',
|
||||
'scope:approval.view_all',
|
||||
'scope:approval.approve',
|
||||
'scope:report.export',
|
||||
'scope:report.view',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,34 +1,24 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthPermission.toPermissionMap', () {
|
||||
test('백엔드 표준 문자열을 프런트 권한으로 매핑한다', () {
|
||||
final permission = AuthPermission(
|
||||
resource: '/approvals',
|
||||
actions: ['read', 'update', 'approve'],
|
||||
group('AuthPermission', () {
|
||||
test('scope 리소스는 actions가 비어도 view 권한을 부여한다', () {
|
||||
const permission = AuthPermission(
|
||||
resource: 'scope:inventory.view',
|
||||
actions: [],
|
||||
);
|
||||
|
||||
final result = permission.toPermissionMap();
|
||||
final map = permission.toPermissionMap();
|
||||
|
||||
expect(result, contains('/approvals'));
|
||||
final actions = result['/approvals']!;
|
||||
expect(actions.contains(PermissionAction.view), isTrue);
|
||||
expect(actions.contains(PermissionAction.edit), isTrue);
|
||||
expect(actions.contains(PermissionAction.approve), isTrue);
|
||||
});
|
||||
|
||||
test('알 수 없는 문자열은 무시해 빈 권한으로 반환한다', () {
|
||||
final permission = AuthPermission(
|
||||
resource: '/dashboard',
|
||||
actions: ['unknown', 'legacy'],
|
||||
expect(map.length, 1);
|
||||
expect(
|
||||
map[PermissionResources.inventoryScope],
|
||||
contains(PermissionAction.view),
|
||||
);
|
||||
|
||||
final result = permission.toPermissionMap();
|
||||
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
|
||||
class FakeInventoryRepository implements InventoryRepository {
|
||||
InventorySummaryListResult? summaryResult;
|
||||
InventoryDetail? detailResult;
|
||||
Object? summaryError;
|
||||
Object? detailError;
|
||||
InventorySummaryFilter? lastSummaryFilter;
|
||||
InventoryDetailFilter? lastDetailFilter;
|
||||
|
||||
@override
|
||||
Future<InventoryDetail> fetchDetail(
|
||||
int productId, {
|
||||
InventoryDetailFilter? filter,
|
||||
}) async {
|
||||
lastDetailFilter = filter;
|
||||
if (detailError != null) {
|
||||
throw detailError!;
|
||||
}
|
||||
return detailResult ?? buildDetail(productId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InventorySummaryListResult> listSummaries({
|
||||
InventorySummaryFilter? filter,
|
||||
}) async {
|
||||
lastSummaryFilter = filter;
|
||||
if (summaryError != null) {
|
||||
throw summaryError!;
|
||||
}
|
||||
return summaryResult ?? buildSummaryResult();
|
||||
}
|
||||
}
|
||||
|
||||
InventorySummaryListResult buildSummaryResult() {
|
||||
final product = InventoryProduct(
|
||||
id: 1,
|
||||
code: 'P-1',
|
||||
name: '장비',
|
||||
vendor: const InventoryVendor(id: 9, name: '벤더'),
|
||||
);
|
||||
final refreshedAt = DateTime.utc(2025, 1, 1, 12);
|
||||
final summary = InventorySummary(
|
||||
product: product,
|
||||
totalQuantity: 10,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: const InventoryWarehouse(id: 1, code: 'WH-1', name: '본사'),
|
||||
quantity: 10,
|
||||
),
|
||||
],
|
||||
recentEvent: null,
|
||||
updatedAt: DateTime.utc(2025, 1, 1),
|
||||
lastRefreshedAt: refreshedAt,
|
||||
);
|
||||
final paginated = PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: paginated,
|
||||
lastRefreshedAt: refreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail buildDetail(int productId) {
|
||||
final product = InventoryProduct(
|
||||
id: productId,
|
||||
code: 'P-$productId',
|
||||
name: '제품$productId',
|
||||
vendor: const InventoryVendor(id: 9, name: '벤더'),
|
||||
);
|
||||
return InventoryDetail(
|
||||
product: product,
|
||||
totalQuantity: 5,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: const InventoryWarehouse(id: 1, code: 'WH-1', name: '본사'),
|
||||
quantity: 5,
|
||||
),
|
||||
],
|
||||
recentEvents: const [],
|
||||
updatedAt: DateTime.utc(2025, 1, 2),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 2),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart';
|
||||
|
||||
import 'fake_inventory_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('InventoryDetailController', () {
|
||||
late FakeInventoryRepository repository;
|
||||
late InventoryDetailController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInventoryRepository();
|
||||
controller = InventoryDetailController(
|
||||
service: InventoryService(repository: repository),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch는 상세 정보를 로드하고 캐시한다', () async {
|
||||
await controller.fetch(1);
|
||||
|
||||
expect(controller.detailOf(1), isNotNull);
|
||||
expect(controller.isLoading(1), isFalse);
|
||||
expect(controller.errorOf(1), isNull);
|
||||
expect(repository.lastDetailFilter, isNotNull);
|
||||
});
|
||||
|
||||
test('동일 필터로 재요청 시 추가 호출을 건너뛴다', () async {
|
||||
await controller.fetch(2);
|
||||
repository.detailError = Exception('should not be thrown');
|
||||
|
||||
await controller.fetch(2);
|
||||
|
||||
expect(controller.errorOf(2), isNull);
|
||||
});
|
||||
|
||||
test('필터 변경 시 강제로 다시 조회한다', () async {
|
||||
await controller.fetch(3);
|
||||
repository.detailError = Exception('boom');
|
||||
|
||||
await controller.updateEventLimit(3, 50);
|
||||
|
||||
expect(controller.errorOf(3), contains('boom'));
|
||||
expect(repository.lastDetailFilter?.eventLimit, 50);
|
||||
});
|
||||
|
||||
test('오류를 명시적으로 초기화할 수 있다', () async {
|
||||
repository.detailError = Exception('boom');
|
||||
await controller.fetch(5, filter: const InventoryDetailFilter());
|
||||
|
||||
expect(controller.errorOf(5), isNotNull);
|
||||
|
||||
controller.clearError(5);
|
||||
|
||||
expect(controller.errorOf(5), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart';
|
||||
|
||||
import 'fake_inventory_repository.dart';
|
||||
|
||||
void main() {
|
||||
group('InventorySummaryController', () {
|
||||
late FakeInventoryRepository repository;
|
||||
late InventorySummaryController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeInventoryRepository();
|
||||
controller = InventorySummaryController(
|
||||
service: InventoryService(repository: repository),
|
||||
);
|
||||
});
|
||||
|
||||
test('fetch 저장 시 결과와 페이징 상태를 갱신한다', () async {
|
||||
repository.summaryResult = buildSummaryResult();
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.result, isNotNull);
|
||||
expect(controller.result!.items, isNotEmpty);
|
||||
expect(controller.isLoading, isFalse);
|
||||
expect(controller.errorMessage, isNull);
|
||||
expect(repository.lastSummaryFilter?.page, 1);
|
||||
expect(controller.lastRefreshedAt, DateTime.utc(2025, 1, 1, 12));
|
||||
});
|
||||
|
||||
test('쿼리/정렬/필터 업데이트가 상태에 반영된다', () {
|
||||
controller
|
||||
..updateQuery(' camera ')
|
||||
..updateProductName('렌즈')
|
||||
..updateVendorName('슈퍼')
|
||||
..updateWarehouse(7)
|
||||
..toggleIncludeEmpty(true)
|
||||
..updateSort('total_quantity', order: 'asc')
|
||||
..updatePageSize(30);
|
||||
|
||||
expect(controller.query, 'camera');
|
||||
expect(controller.productName, '렌즈');
|
||||
expect(controller.vendorName, '슈퍼');
|
||||
expect(controller.warehouseId, 7);
|
||||
expect(controller.includeEmpty, isTrue);
|
||||
expect(controller.sort, 'total_quantity');
|
||||
expect(controller.order, 'asc');
|
||||
expect(controller.pageSize, 30);
|
||||
});
|
||||
|
||||
test('요청 실패 시 오류 메시지를 저장한다', () async {
|
||||
repository.summaryError = Exception('boom');
|
||||
|
||||
await controller.fetch();
|
||||
|
||||
expect(controller.errorMessage, contains('boom'));
|
||||
expect(controller.isLoading, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_event.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
|
||||
class _MockInventoryRepository extends Mock implements InventoryRepository {}
|
||||
|
||||
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InventorySummaryListResult _buildSummaryResult() {
|
||||
final warehouse = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final summary = InventorySummary(
|
||||
product: InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
),
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse, quantity: 80),
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고'),
|
||||
quantity: 40,
|
||||
),
|
||||
],
|
||||
recentEvent: InventoryEvent(
|
||||
eventId: 900,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 30,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 0),
|
||||
warehouse: warehouse,
|
||||
),
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 15),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
),
|
||||
lastRefreshedAt: summary.lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail _buildDetail() {
|
||||
final warehouse1 = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final warehouse2 = InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고');
|
||||
return InventoryDetail(
|
||||
product: InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
),
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse1, quantity: 80),
|
||||
InventoryWarehouseBalance(warehouse: warehouse2, quantity: 40),
|
||||
],
|
||||
recentEvents: [
|
||||
InventoryEvent(
|
||||
eventId: 901,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 20,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 10),
|
||||
warehouse: warehouse1,
|
||||
),
|
||||
],
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 20),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
}
|
||||
|
||||
void _registerDependencies({
|
||||
required InventoryRepository inventoryRepository,
|
||||
required WarehouseRepository warehouseRepository,
|
||||
}) {
|
||||
GetIt.I.registerSingleton<InventoryService>(
|
||||
InventoryService(repository: inventoryRepository),
|
||||
);
|
||||
GetIt.I.registerSingleton<WarehouseRepository>(warehouseRepository);
|
||||
}
|
||||
|
||||
void _stubWarehouseList(_MockWarehouseRepository repository) {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeZipcode: any(named: 'includeZipcode'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final page = invocation.namedArguments[const Symbol('page')] as int? ?? 1;
|
||||
final items = page == 1
|
||||
? [Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '본사 창고')]
|
||||
: const <Warehouse>[];
|
||||
return PaginatedResult<Warehouse>(
|
||||
items: items,
|
||||
page: page,
|
||||
pageSize:
|
||||
invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20,
|
||||
total: items.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpInventoryPage(WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1600, 1200));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(
|
||||
routeUri: Uri(path: '/inventory/summary'),
|
||||
debugRowHeight: 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(const InventorySummaryFilter());
|
||||
registerFallbackValue(const InventoryDetailFilter());
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => _buildSummaryResult());
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => _buildDetail());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await binding.setSurfaceSize(null);
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('Inventory summary page matches golden', (tester) async {
|
||||
await _pumpInventoryPage(tester);
|
||||
|
||||
await expectLater(
|
||||
find.byType(InventorySummaryPage),
|
||||
matchesGoldenFile('goldens/inventory_summary_page_default.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Inventory detail sheet matches golden', (tester) async {
|
||||
await _pumpInventoryPage(tester);
|
||||
await tester.tap(find.text('테스트 장비'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(
|
||||
find.byType(InventorySummaryPage),
|
||||
matchesGoldenFile('goldens/inventory_summary_detail_sheet.png'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/application/inventory_service.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_counterparty.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_detail.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_event.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_filters.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_product.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_summary_list_result.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_vendor.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/domain/repositories/inventory_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
|
||||
class _MockInventoryRepository extends Mock implements InventoryRepository {}
|
||||
|
||||
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _registerDependencies({
|
||||
required InventoryRepository inventoryRepository,
|
||||
required WarehouseRepository warehouseRepository,
|
||||
}) {
|
||||
GetIt.I.registerSingleton<InventoryService>(
|
||||
InventoryService(repository: inventoryRepository),
|
||||
);
|
||||
GetIt.I.registerSingleton<WarehouseRepository>(warehouseRepository);
|
||||
}
|
||||
|
||||
void _stubWarehouseList(_MockWarehouseRepository repository) {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeZipcode: any(named: 'includeZipcode'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final page = invocation.namedArguments[const Symbol('page')] as int? ?? 1;
|
||||
final items = page == 1
|
||||
? [Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '본사 창고')]
|
||||
: const <Warehouse>[];
|
||||
return PaginatedResult<Warehouse>(
|
||||
items: items,
|
||||
page: page,
|
||||
pageSize:
|
||||
invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20,
|
||||
total: items.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
InventorySummaryListResult _buildSummaryResult() {
|
||||
final product = InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
);
|
||||
final warehouse = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final summary = InventorySummary(
|
||||
product: product,
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse, quantity: 80),
|
||||
InventoryWarehouseBalance(
|
||||
warehouse: InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고'),
|
||||
quantity: 40,
|
||||
),
|
||||
],
|
||||
recentEvent: InventoryEvent(
|
||||
eventId: 900,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 30,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 0),
|
||||
counterparty: const InventoryCounterparty(
|
||||
type: InventoryCounterpartyType.vendor,
|
||||
name: 'QA 파트너',
|
||||
),
|
||||
warehouse: warehouse,
|
||||
),
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 15),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
return InventorySummaryListResult(
|
||||
result: PaginatedResult<InventorySummary>(
|
||||
items: [summary],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
),
|
||||
lastRefreshedAt: summary.lastRefreshedAt,
|
||||
);
|
||||
}
|
||||
|
||||
InventoryDetail _buildDetail() {
|
||||
final warehouse1 = InventoryWarehouse(id: 1, code: 'WH-001', name: '본사');
|
||||
final warehouse2 = InventoryWarehouse(id: 2, code: 'WH-002', name: '보관창고');
|
||||
return InventoryDetail(
|
||||
product: InventoryProduct(
|
||||
id: 10,
|
||||
code: 'INV-10',
|
||||
name: '테스트 장비',
|
||||
vendor: const InventoryVendor(id: 55, name: '테스트 벤더'),
|
||||
),
|
||||
totalQuantity: 120,
|
||||
warehouseBalances: [
|
||||
InventoryWarehouseBalance(warehouse: warehouse1, quantity: 80),
|
||||
InventoryWarehouseBalance(warehouse: warehouse2, quantity: 40),
|
||||
],
|
||||
recentEvents: [
|
||||
InventoryEvent(
|
||||
eventId: 901,
|
||||
eventKind: 'receipt',
|
||||
eventLabel: '입고',
|
||||
deltaQuantity: 20,
|
||||
occurredAt: DateTime.utc(2025, 1, 3, 9, 10),
|
||||
counterparty: const InventoryCounterparty(
|
||||
type: InventoryCounterpartyType.vendor,
|
||||
name: 'QA 파트너',
|
||||
),
|
||||
warehouse: warehouse1,
|
||||
),
|
||||
],
|
||||
updatedAt: DateTime.utc(2025, 1, 3, 9, 20),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 5),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(const InventorySummaryFilter());
|
||||
registerFallbackValue(const InventoryDetailFilter());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('자동 새로고침 토글이 주기적 재조회 동작을 제어한다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
var listCallCount = 0;
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async {
|
||||
listCallCount += 1;
|
||||
return summaryResult;
|
||||
});
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(listCallCount, 1);
|
||||
expect(find.text('테스트 장비'), findsOneWidget);
|
||||
expect(find.textContaining('마지막 리프레시'), findsOneWidget);
|
||||
expect(find.text('자동 새로고침'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 31));
|
||||
await tester.pump();
|
||||
|
||||
expect(listCallCount, 2);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('자동 새로고침 전환'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pump(const Duration(seconds: 31));
|
||||
await tester.pump();
|
||||
|
||||
expect(listCallCount, 2);
|
||||
});
|
||||
|
||||
testWidgets('행을 탭하면 상세 시트에서 창고 차트와 최근 이벤트를 확인할 수 있다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => summaryResult);
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('테스트 장비'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('창고 잔량'), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsWidgets);
|
||||
expect(find.text('최근 이벤트'), findsOneWidget);
|
||||
expect(find.textContaining('거래처: QA 파트너'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('권한 오류가 발생하면 경고 배너를 노출한다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenThrow(Exception('재고 조회 권한이 없습니다.'));
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('재고 조회 권한이 없습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('검색 적용 시 입력값이 필터에 반영된다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
final capturedFilters = <InventorySummaryFilter>[];
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((invocation) async {
|
||||
final filter =
|
||||
invocation.namedArguments[const Symbol('filter')]
|
||||
as InventorySummaryFilter?;
|
||||
if (filter != null) {
|
||||
capturedFilters.add(filter);
|
||||
}
|
||||
return summaryResult;
|
||||
});
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(capturedFilters, isNotEmpty);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('inventory_filter_query_field')),
|
||||
'카메라',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const Key('inventory_filter_apply')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(capturedFilters.length, greaterThanOrEqualTo(2));
|
||||
final latest = capturedFilters.last;
|
||||
expect(latest.query, '카메라');
|
||||
expect(latest.page, 1);
|
||||
});
|
||||
|
||||
testWidgets('목록이 비어 있으면 안내 문구를 노출한다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final emptyResult = InventorySummaryListResult(
|
||||
result: PaginatedResult<InventorySummary>(
|
||||
items: const [],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
),
|
||||
lastRefreshedAt: DateTime.utc(2025, 1, 3, 9, 0),
|
||||
);
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => emptyResult);
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => _buildDetail());
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('조건에 맞는 재고 데이터가 없습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('총 수량 헤더를 탭하면 정렬 파라미터가 토글된다', (tester) async {
|
||||
final inventoryRepository = _MockInventoryRepository();
|
||||
final warehouseRepository = _MockWarehouseRepository();
|
||||
final summaryResult = _buildSummaryResult();
|
||||
final detail = _buildDetail();
|
||||
final recordedFilters = <InventorySummaryFilter>[];
|
||||
|
||||
when(
|
||||
() => inventoryRepository.listSummaries(filter: any(named: 'filter')),
|
||||
).thenAnswer((invocation) async {
|
||||
final filter =
|
||||
invocation.namedArguments[const Symbol('filter')]
|
||||
as InventorySummaryFilter?;
|
||||
if (filter != null) {
|
||||
recordedFilters.add(filter);
|
||||
}
|
||||
return summaryResult;
|
||||
});
|
||||
when(
|
||||
() =>
|
||||
inventoryRepository.fetchDetail(any(), filter: any(named: 'filter')),
|
||||
).thenAnswer((_) async => detail);
|
||||
_stubWarehouseList(warehouseRepository);
|
||||
_registerDependencies(
|
||||
inventoryRepository: inventoryRepository,
|
||||
warehouseRepository: warehouseRepository,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
InventorySummaryPage(routeUri: Uri(path: '/inventory/summary')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(recordedFilters, isNotEmpty);
|
||||
// 첫 정렬: 총 수량 헤더 탭 → 오름차순
|
||||
await tester.tap(find.text('총 수량').first);
|
||||
await tester.pumpAndSettle();
|
||||
final ascFilter = recordedFilters.last;
|
||||
expect(ascFilter.sort, 'total_quantity');
|
||||
expect(ascFilter.order, 'asc');
|
||||
|
||||
// 두 번째 탭 → 내림차순
|
||||
await tester.tap(find.text('총 수량').first);
|
||||
await tester.pumpAndSettle();
|
||||
final descFilter = recordedFilters.last;
|
||||
expect(descFilter.sort, 'total_quantity');
|
||||
expect(descFilter.order, 'desc');
|
||||
});
|
||||
}
|
||||
@@ -14,102 +14,157 @@ class _MockGroupPermissionRepository extends Mock
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('그룹 권한을 모두 불러와 PermissionManager에 적용한다', () async {
|
||||
final repository = _MockGroupPermissionRepository();
|
||||
final manager = PermissionManager();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: repository,
|
||||
manager: manager,
|
||||
pageSize: 1,
|
||||
);
|
||||
group('PermissionSynchronizer', () {
|
||||
test('그룹 권한을 모두 불러와 PermissionManager에 적용한다', () async {
|
||||
final repository = _MockGroupPermissionRepository();
|
||||
final manager = PermissionManager();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: repository,
|
||||
manager: manager,
|
||||
pageSize: 1,
|
||||
);
|
||||
|
||||
final permissionPage1 = GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 10,
|
||||
menuCode: 'INBOUND',
|
||||
menuName: '입고',
|
||||
path: '/inventory/inbound',
|
||||
),
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
);
|
||||
final permissionPage1 = GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 10,
|
||||
menuCode: 'INBOUND',
|
||||
menuName: '입고',
|
||||
path: '/inventory/inbound',
|
||||
),
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
);
|
||||
|
||||
final permissionPage2 = GroupPermission(
|
||||
id: 2,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 11,
|
||||
menuCode: 'OUTBOUND',
|
||||
menuName: '출고',
|
||||
path: '/inventory/outbound',
|
||||
),
|
||||
canCreate: false,
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canDelete: false,
|
||||
);
|
||||
final permissionPage2 = GroupPermission(
|
||||
id: 2,
|
||||
group: GroupPermissionGroup(id: 1, groupName: '관리자'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 11,
|
||||
menuCode: 'OUTBOUND',
|
||||
menuName: '출고',
|
||||
path: '/inventory/outbound',
|
||||
),
|
||||
canCreate: false,
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canDelete: false,
|
||||
);
|
||||
|
||||
when(
|
||||
() => repository.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((invocation) async {
|
||||
final page = invocation.namedArguments[#page] as int;
|
||||
if (page == 1) {
|
||||
when(
|
||||
() => repository.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((invocation) async {
|
||||
final page = invocation.namedArguments[#page] as int;
|
||||
if (page == 1) {
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [permissionPage1],
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
);
|
||||
}
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [permissionPage1],
|
||||
page: 1,
|
||||
items: [permissionPage2],
|
||||
page: 2,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
);
|
||||
}
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [permissionPage2],
|
||||
page: 2,
|
||||
pageSize: 1,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
await synchronizer.syncForGroup(1);
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: 1,
|
||||
groupId: 1,
|
||||
menuId: null,
|
||||
isActive: true,
|
||||
includeDeleted: false,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.edit,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.delete,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
await synchronizer.syncForGroup(1);
|
||||
|
||||
verify(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
test('fetchPermissionMap은 그룹 권한 맵을 반환한다', () async {
|
||||
final repository = _MockGroupPermissionRepository();
|
||||
final manager = PermissionManager();
|
||||
final synchronizer = PermissionSynchronizer(
|
||||
repository: repository,
|
||||
manager: manager,
|
||||
pageSize: 1,
|
||||
groupId: 1,
|
||||
menuId: null,
|
||||
isActive: true,
|
||||
includeDeleted: false,
|
||||
),
|
||||
).called(greaterThanOrEqualTo(1));
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.create,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(PermissionResources.stockTransactions, PermissionAction.edit),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can(
|
||||
PermissionResources.stockTransactions,
|
||||
PermissionAction.delete,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
when(
|
||||
() => repository.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 {
|
||||
return PaginatedResult<GroupPermission>(
|
||||
items: [
|
||||
GroupPermission(
|
||||
id: 1,
|
||||
group: GroupPermissionGroup(id: 99, groupName: 'Ops'),
|
||||
menu: GroupPermissionMenu(
|
||||
id: 33,
|
||||
menuCode: 'INVENTORY',
|
||||
menuName: '재고',
|
||||
path: '/inventory/summary',
|
||||
),
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 1,
|
||||
);
|
||||
});
|
||||
|
||||
final map = await synchronizer.fetchPermissionMap(10);
|
||||
|
||||
expect(
|
||||
map[PermissionResources.inventorySummary],
|
||||
contains(PermissionAction.view),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -439,10 +439,7 @@ void main() {
|
||||
expect(rowFinder, findsOneWidget);
|
||||
|
||||
final rowRect = tester.getRect(rowFinder);
|
||||
await tester.tapAt(
|
||||
rowRect.center,
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.tapAt(rowRect.center, kind: PointerDeviceKind.mouse);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SuperportDialog), findsOneWidget);
|
||||
@@ -450,16 +447,14 @@ void main() {
|
||||
await tester.tap(find.text('보안'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final resetButton =
|
||||
find.widgetWithText(ShadButton, '비밀번호 재설정').first;
|
||||
final resetButton = find.widgetWithText(ShadButton, '비밀번호 재설정').first;
|
||||
await tester.ensureVisible(resetButton);
|
||||
await tester.tap(resetButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('비밀번호 재설정'), findsWidgets);
|
||||
|
||||
final confirmButton =
|
||||
find.widgetWithText(ShadButton, '재설정').last;
|
||||
final confirmButton = find.widgetWithText(ShadButton, '재설정').last;
|
||||
await tester.ensureVisible(confirmButton);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Reference in New Issue
Block a user