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:
JiWoong Sul
2025-11-09 01:13:02 +09:00
parent 486ab8706f
commit 47cc62a33d
72 changed files with 5453 additions and 1021 deletions

15
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,15 @@
# 개요
- 변경 요약:
- 사용자 영향: 재고 현황 화면은 읽기 전용 모드(`inventory.view`)로 노출됩니다.
# 체크리스트
- [ ] UI 변경 스크린샷/영상 첨부
- [ ] 사용자 영향과 롤백 전략 설명
- [ ] 테스트 커맨드 실행 및 결과 공유
- [ ] `cargo test -- tests::inventory_summary`
- [ ] `flutter analyze`
- [ ] `flutter test --coverage`
# 참고
- 관련 이슈/문서:
- 기타 비고:

View File

@@ -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 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다.

View File

@@ -20,6 +20,8 @@
- `FEATURE_APPROVALS_ENABLED` — 기본값은 개발·운영 모두 `true`, 단 결재 백엔드가 준비되지 않았으면 `.env.*`에서 `false`로 내려 임시 비활성화한다.
- `FEATURE_STOCK_TRANSITIONS_ENABLED` — 재고 상태 전이(상신/승인/취소) 버튼 노출 제어. 운영 환경은 백엔드 배포 전까지 `false`로 유지하고, 개발 환경에서만 필요 시 `true`로 전환한다.
QA 토큰/스코프 발급 및 검증 절차는 `doc/qa/staging_transaction_flow.md`를 참고한다.
2) 의존성 설치
```

View File

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

View File

@@ -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에 필드가 모두 나타나는지, 탭이 최소 한 개만 남았을 때 동작하는지 확인.

View File

@@ -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`)가 발행된다.

View 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`)

View 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 라우팅/임계값 갱신.

View 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 동시 사용자까지 문제 없는지 모니터링한다.

View 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

View File

@@ -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`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.

View File

@@ -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: [

View File

@@ -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]);
}
}

View 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;
}
}

View File

@@ -41,6 +41,10 @@ class PermissionManager extends ChangeNotifier {
return server.contains(action);
}
if (key.startsWith('scope:')) {
return false;
}
return Environment.hasPermission(key, action.name);
}

View File

@@ -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;
}
}

View File

@@ -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',

View File

@@ -880,10 +880,7 @@ class _TemplateToolbar extends StatelessWidget {
);
if (!canApplyTemplate) {
applyButton = Tooltip(
message: '템플릿을 적용할 권한이 없습니다.',
child: applyButton,
);
applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton);
}
return Column(

View File

@@ -29,11 +29,7 @@ class ApprovalFormInitializer {
controller.setRequester(defaultRequester);
}
if (draft != null) {
await _applyDraft(
controller,
draft,
repository ?? _resolveRepository(),
);
await _applyDraft(controller, draft, repository ?? _resolveRepository());
}
}

View File

@@ -508,7 +508,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
}
idController.dispose();
}
}
class _InfoBadge extends StatelessWidget {

View File

@@ -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?,

View File

@@ -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';
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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(),
};

View File

@@ -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;
}

View File

@@ -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(),
};

View File

@@ -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;
}

View File

@@ -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(),
};

View File

@@ -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();
}
}

View File

@@ -0,0 +1,10 @@
/// 재고 이벤트와 연결된 거래처 유형.
enum InventoryCounterpartyType { vendor, customer, unknown }
/// 재고 이벤트의 거래처 정보를 표현한다.
class InventoryCounterparty {
const InventoryCounterparty({required this.type, this.name});
final InventoryCounterpartyType type;
final String? name;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
/// 재고 요약에서 사용하는 공급사 정보를 표현하는 값 객체.
class InventoryVendor {
const InventoryVendor({this.id, required this.name});
/// 공급사 식별자. 미정의일 수 있다.
final int? id;
/// 공급사 명칭.
final String name;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,15 @@
import 'inventory_warehouse.dart';
/// 특정 창고의 재고 수량을 나타내는 모델.
class InventoryWarehouseBalance {
const InventoryWarehouseBalance({
required this.warehouse,
required this.quantity,
});
/// 창고 정보.
final InventoryWarehouse warehouse;
/// 창고 내 잔량.
final int quantity;
}

View File

@@ -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,
});
}

View File

@@ -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,
);
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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!),
],
),
),

View File

@@ -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>()),
)

View File

@@ -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);
}
}

View File

@@ -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});

View File

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

View File

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

View File

@@ -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,
);
});
});
}

View File

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

View 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',
}),
);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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),
);
}

View File

@@ -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);
});
});
}

View File

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

View File

@@ -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'),
);
});
}

View File

@@ -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');
});
}

View File

@@ -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),
);
});
});
}

View File

@@ -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();