diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a2d0ae5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +# 개요 +- 변경 요약: +- 사용자 영향: 재고 현황 화면은 읽기 전용 모드(`inventory.view`)로 노출됩니다. + +# 체크리스트 +- [ ] UI 변경 스크린샷/영상 첨부 +- [ ] 사용자 영향과 롤백 전략 설명 +- [ ] 테스트 커맨드 실행 및 결과 공유 + - [ ] `cargo test -- tests::inventory_summary` + - [ ] `flutter analyze` + - [ ] `flutter test --coverage` + +# 참고 +- 관련 이슈/문서: +- 기타 비고: diff --git a/CHANGELOG.md b/CHANGELOG.md index a67fa39..55b636b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다. diff --git a/README.md b/README.md index f2682d8..523e33e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ - `FEATURE_APPROVALS_ENABLED` — 기본값은 개발·운영 모두 `true`, 단 결재 백엔드가 준비되지 않았으면 `.env.*`에서 `false`로 내려 임시 비활성화한다. - `FEATURE_STOCK_TRANSITIONS_ENABLED` — 재고 상태 전이(상신/승인/취소) 버튼 노출 제어. 운영 환경은 백엔드 배포 전까지 `false`로 유지하고, 개발 환경에서만 필요 시 `true`로 전환한다. +QA 토큰/스코프 발급 및 검증 절차는 `doc/qa/staging_transaction_flow.md`를 참고한다. + 2) 의존성 설치 ``` diff --git a/doc/API_CLIENT_SPEC.md b/doc/API_CLIENT_SPEC.md index 7a7accb..b89e75e 100644 --- a/doc/API_CLIENT_SPEC.md +++ b/doc/API_CLIENT_SPEC.md @@ -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` diff --git a/doc/detail_dialog_unification_plan.md b/doc/detail_dialog_unification_plan.md index 275d1df..e387999 100644 --- a/doc/detail_dialog_unification_plan.md +++ b/doc/detail_dialog_unification_plan.md @@ -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에 필드가 모두 나타나는지, 탭이 최소 한 개만 남았을 때 동작하는지 확인. diff --git a/doc/frontend_backend_alignment_report.md b/doc/frontend_backend_alignment_report.md index c2b145c..8b101a5 100644 --- a/doc/frontend_backend_alignment_report.md +++ b/doc/frontend_backend_alignment_report.md @@ -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`)가 발행된다. diff --git a/doc/inventory_management_feature_plan.md b/doc/inventory_management_feature_plan.md new file mode 100644 index 0000000..0ff3658 --- /dev/null +++ b/doc/inventory_management_feature_plan.md @@ -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`) diff --git a/doc/inventory_summary_audit_plan.md b/doc/inventory_summary_audit_plan.md new file mode 100644 index 0000000..5bf156e --- /dev/null +++ b/doc/inventory_summary_audit_plan.md @@ -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 라우팅/임계값 갱신. diff --git a/doc/qa/inventory/inventory_summary_e2e_checklist.md b/doc/qa/inventory/inventory_summary_e2e_checklist.md new file mode 100644 index 0000000..3cf9050 --- /dev/null +++ b/doc/qa/inventory/inventory_summary_e2e_checklist.md @@ -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 동시 사용자까지 문제 없는지 모니터링한다. diff --git a/doc/qa/inventory_data_replay.md b/doc/qa/inventory_data_replay.md new file mode 100644 index 0000000..cd5c137 --- /dev/null +++ b/doc/qa/inventory_data_replay.md @@ -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 " \ + "$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 기준으로 활용한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index af86f7f..f97ee58 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -1,13 +1,15 @@ # 간단 입·출고 + 결재 시스템 API 규격 (v4) -**기준 버전:** 2025-09-18 16:22:30Z (UTC) +**기준 버전:** 2025-01-04 15:00:00Z (UTC) 본 문서는 `stock_approval_system_spec_full_v4.md`의 데이터 모델과 비즈니스 규칙을 기반으로 한 REST API 구성을 정의한다. 기본 CRUD를 제공하며, 목록·상세 조회 시 FK로 연결된 주요 엔터티 정보를 함께 반환한다. 모든 엔드포인트는 소프트 삭제 컬럼(`is_deleted`)을 노출하지 않는다. --- ## 0. 구현 현황 요약 (2025-09-18 기준) -- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/users`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes` +- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/users`, `/groups`, `/menus`, `/group-menu-permissions`, `/permission-scopes`, `/group-permission-scopes`, `/zipcodes` +- 결재 플로우: `/approvals`, `/approval`(제출·전이·이력), `/approval-steps`, `/approval/history`, `/approval-drafts` +- 재고 현황: `/inventory/summary`, `/inventory/summary/{product_id}` — 재고 변동 이벤트 기반 Read-only 목록/단건 조회 - 각 자원은 `/api/v1/` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다. - 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`와 동일 값을 가진 `path`가 포함되며 각 항목은 `is_deleted`를 노출한다. `include=group,menu` 확장과 `include_deleted=true` 파라미터로 삭제 권한을 함께 조회할 수 있다. - 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다. @@ -37,6 +39,7 @@ - `403 FORBIDDEN` — 권한 부족. 결재 열람 제한 시 `APPROVAL_ACCESS_DENIED` 코드를 사용한다. - 에러 응답 예: `{ "error": { "code": 422, "message": "출고 트랜잭션에는 고객이 최소 1건 필요합니다.", "details": [...] } }`. - **CORS 정책:** 서버는 `config/default.toml`의 `[cors]` 설정을 사용해 허용 오리진을 제어한다. `allowed_origins`가 비어 있으면 모든 오리진을 허용하고, 값에 `http://localhost` 또는 `https://web.example.com:*`처럼 포트 와일드카드(`:*`)를 포함하면 동적 포트 환경에서도 `Access-Control-Allow-Origin`이 요청 오리진과 동일하게 반환된다. 허용 오리진에 일치하지 않으면 `400 BAD_REQUEST`가 응답된다. +- **권한 스코프:** 메뉴 기반 권한과 별도로 `permission_scopes`와 `group_permission_scopes`가 기능 권한을 관리한다. 로그인 응답의 `permissions` 배열에는 `scope:` 형식의 항목이 추가되며, `permission_codes` 필드에는 스코프 코드가 그대로 채워진다. 결재 관련 전역 권한은 `approval.manage`, `approval.view_all`, `approval.approve` 세 스코프로 제어하며, 재고 현황 조회는 읽기 전용 스코프 `inventory.view`를 요구한다. 프런트엔드는 해당 스코프를 기준으로 결재/재고 화면 접근 및 전이·표시 여부를 결정해야 한다. --- @@ -511,7 +514,7 @@ --- -## 4. 트랜잭션 API +## 4. 트랜잭션/재고 API 리소스: `/stock-transactions`, 보조 리소스: `/transaction-lines`, `/transaction-customers` ### 4.1 생성 (헤더 + 라인 + 고객 다건) @@ -540,17 +543,25 @@ ], "customers": [], "approval": { + "status_id": 2, "requested_by_id": 7, - "note": "입고 결재" + "note": "입고 결재", + "config": { + "template_id": 1201, + "steps": [ + { "step_order": 1, "approver_id": 21 }, + { "step_order": 2, "approver_id": 34, "note": "재무 확인" } + ] + } } } ``` 응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. `transaction_no` 및 `approval.approval_no`는 요청 시 생략하며, 서버가 각각 `TRX-YYYYMMDDNNNN`, `APP-YYYYMMDDNNNN` 패턴으로 생성한 값을 응답에서 확인한다. `approval` -블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다. +블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없고, `config`에는 템플릿 ID 또는 단계 배열 중 하나 이상이 반드시 포함돼야 한다. -> 기본 목록(`status` 미지정, `include_pending` 미사용)은 최종 승인 완료된 전표만 노출한다. 초안·상신 단계 전표는 `status=draft,submitted` 또는 `include_pending=true`로 별도 조회하거나 Approval Flow 화면에서 확인한다. +> 기본 목록(`status` 미지정, `include_pending` 미사용)과 대시보드 `recent_transactions` 카드는 최종 승인 완료된 전표만 노출한다. 초안·상신 단계 전표는 `status=draft,submitted` 또는 `include_pending=true`로 별도 조회하거나 Approval Flow 화면에서 확인한다. ### 4.2 목록 조회 `GET /stock-transactions?customer_id=301&include=lines,customers,approval` @@ -628,6 +639,10 @@ "approval": { "id": 5001, "approval_no": "APP-202511100001", + "status_id": 1, + "current_step_id": 7001, + "requester_id": 7, + "final_approver_id": 34, "status": { "id": 1, "name": "대기", @@ -657,11 +672,21 @@ "employee_id": "E2025001", "name": "김승인" }, + "final_approver": { + "id": 34, + "employee_id": "E2025020", + "name": "최최종" + }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "입고 결재", "template_name": "입고 결재 기본", + "metadata": { + "flow_version": "v2" + }, + "last_action_at": "2025-09-18T06:05:00Z", "is_active": true, + "is_deleted": false, "created_at": "2025-09-18T06:00:00Z", "updated_at": "2025-09-18T06:05:00Z" } @@ -745,6 +770,10 @@ "approval": { "id": 5001, "approval_no": "APP-202511100001", + "status_id": 1, + "current_step_id": 7001, + "requester_id": 7, + "final_approver_id": 34, "status": { "id": 1, "name": "대기", @@ -754,6 +783,7 @@ }, "current_step": { "id": 7001, + "request_id": 5001, "step_order": 1, "approver": { "id": 21, @@ -768,21 +798,34 @@ }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, - "note": null + "note": null, + "is_optional": false }, "requester": { "id": 7, "employee_id": "E2025001", "name": "김승인" }, + "final_approver": { + "id": 34, + "employee_id": "E2025020", + "name": "최최종" + }, + "summary": "11월 2주차 입고", + "note": "입고 결재", "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, - "note": "입고 결재", "template_name": "입고 결재 기본", + "metadata": { + "flow_version": "v2" + }, "steps": [ { "id": 7001, + "request_id": 5001, "step_order": 1, + "template_step_id": 41001, + "approver_role": "창고장", "approver": { "id": 21, "employee_id": "E2025002", @@ -796,15 +839,54 @@ }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, - "note": null + "action_at": null, + "note": null, + "is_optional": false, + "escalation_minutes": null, + "metadata": null + }, + { + "id": 7002, + "request_id": 5001, + "step_order": 2, + "template_step_id": 41002, + "approver_role": "재무", + "approver": { + "id": 34, + "employee_id": "E2025020", + "name": "최최종" + }, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "assigned_at": null, + "decided_at": null, + "action_at": null, + "note": "재무 확인", + "is_optional": false, + "escalation_minutes": 120, + "metadata": { + "reminder": "sms" + } } ], "histories": [ { "id": 91001, + "request_id": 5001, + "step_id": 7001, + "actor": { + "id": 7, + "employee_id": "E2025001", + "name": "김승인" + }, "action": { "id": 1, - "name": "상신" + "name": "상신", + "code": "submit" }, "from_status": null, "to_status": { @@ -813,16 +895,14 @@ "is_blocking_next": true, "is_terminal": false }, - "approver": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, "action_at": "2025-09-18T06:00:00Z", + "action_code": "submit", "note": null } ], + "last_action_at": "2025-09-18T06:05:00Z", "is_active": true, + "is_deleted": false, "created_at": "2025-09-18T06:00:00Z", "updated_at": "2025-09-18T06:05:00Z" } @@ -955,153 +1035,71 @@ 모든 액션은 `{ "data": { "id": 9001, "transaction_status": { ... }, "updated_at": "..." } }` 구조를 반환한다. `submit`은 초안 상태의 트랜잭션을 상신 상태로, 결재 현재 단계를 진행중으로 전환한다. `approve`는 결재 상태가 이미 승인(`approval_status_id = 승인`)으로 확정된 건을 재고 상태 `승인`으로 승격한다. `reject`는 상신/승인 상태의 건을 `반려` 상태로 내리고 결재 레코드도 반려로 남긴다. `cancel`은 상신된 건을 다시 초안 상태(또는 `취소` 상태가 존재할 경우 해당 상태)로 되돌리며, 결재 단계와 상태를 초기화한다. `complete` 는 결재 상태가 승인된 건에 한해 완료 상태로 변경한다. ---- - -## 5. 결재 API -리소스: `/approvals`, 보조 리소스: `/approval-steps`, `/approval-histories` - -- 단계 상태가 바뀔 때마다 `approvals.current_step_id`는 차기 단계의 ID로 갱신되고, 전체 결재 상태(`approval_status_id`) 역시 해당 단계 상태로 업데이트된다. -- 템플릿에서 복제된 단계는 모두 `대기` 상태로 저장되며 템플릿이 이후 수정돼도 기존 결재에는 반영되지 않는다. -- `GET /approvals/{id}/can-proceed`는 현재 단계의 상태에 매핑된 `is_blocking_next` 값이 `false`일 때 `true`를 반환한다. - -### 5.1 결재 생성 -`POST /approvals` -```json -{ - "transaction_id": 9001, - "approval_status_id": 1, - "requested_by_id": 7, - "note": "입고 결재" -} -``` -응답: -```json -{ - "data": { - "approval": { - "id": 5001, - "approval_no": "APP-202511100001", - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "current_step": null, - "requester": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, - "requested_at": "2025-09-18T06:00:00Z", - "decided_at": null, - "note": "입고 결재", - "is_active": true, - "created_at": "2025-09-18T06:00:00Z", - "updated_at": "2025-09-18T06:00:00Z" - } - } -} -``` -- `approval_no`는 서버가 자동 발급하는 읽기 전용 필드로 `APP-YYYYMMDDNNNN` 형식을 따른다. 클라이언트는 필드를 전송하지 않으며, 중복 방지는 서버에서 처리된다. -- 최초 생성 시 `approval_status_id`에는 `대기` 상태 ID를 전달하고, 서버는 동일 상태로 저장한다. -- 단계나 이력이 존재하면 `data.approval.steps`, `data.approval.histories`가 함께 반환된다. - -### 5.2 목록 조회 -`GET /approvals?include=steps,histories` - -- `include` (optional, string): `steps`, `histories`, `transaction`, `requested_by`를 콤마로 조합한다. -- **열람 권한:** 상신자 또는 이미 결재를 완료한 승인자만 목록을 조회할 수 있다. 향후 단계 승인자 및 관계없는 사용자가 호출하면 `403`과 `APPROVAL_ACCESS_DENIED` 코드를 반환하며, 응답 본문에는 `{ "error": { "code": 403, "message": "approval access denied" } }` 형식을 사용한다. +### 4.8 재고 현황 목록 (`GET /inventory/summary`) +- 요구 권한: `scope:inventory.view` + `group_menu_permissions`에서 `menu_code=inventory`의 `can_read=true`. +- 데이터 출처: `inventory_balance_snapshots` 마테뷰(5분 주기 리프레시). API는 동일 요청 내에서 2초 TTL 캐시를 적용한다. +- 쿼리 파라미터 + - `page`, `page_size` (기본 50) + - `q`: 제품 코드/명칭 부분 일치 + - `product_name`, `vendor_name`: 개별 필터 + - `warehouse_id`: 특정 창고 재고만 노출. 지정 시 `warehouse_balances`는 해당 창고 1건만 반환 + - `updated_since`: 증분 조회 (`inventory_balance_snapshots.updated_at` 기준) + - `include_empty`: `true`일 때 수량 0 창고도 반환 (기본 false) + - `sort`: `last_event_at`(기본), `product_name`, `vendor_name`, `total_quantity` + - `order`: `asc|desc` (기본 desc) ```json { "items": [ { - "id": 5001, - "approval_no": "APP-202511100001", - "transaction": { - "id": 9001, - "transaction_no": "TRX-202511100001" + "product": { + "id": 101, + "product_code": "P100", + "product_name": "샘플", + "vendor": { + "id": 10, + "vendor_name": "한빛상사" + } }, - "status": { - "id": 1, - "name": "대기", - "color": "#F97316", - "is_blocking_next": true, - "is_terminal": false - }, - "current_step": { - "id": 7001, - "step_order": 1, - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" - }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": null, - "note": null - }, - "requester": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, - "requested_at": "2025-09-18T06:00:00Z", - "decided_at": null, - "note": "입고 결재", - "is_active": true, - "is_deleted": false, - "created_at": "2025-09-18T06:00:00Z", - "updated_at": "2025-09-18T06:05:00Z", - "steps": [ + "total_quantity": 120, + "warehouse_balances": [ { - "id": 7001, - "step_order": 1, - "status": { + "warehouse": { "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false + "warehouse_code": "WH-001", + "warehouse_name": "1센터" }, - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" + "quantity": 80 + }, + { + "warehouse": { + "id": 2, + "warehouse_code": "WH-002", + "warehouse_name": "2센터" }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": null, - "note": null + "quantity": 40 } ], - "histories": [ - { - "id": 91001, - "action": { - "id": 1, - "name": "상신" - }, - "from_status": null, - "to_status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "approver": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, - "action_at": "2025-09-18T06:00:00Z", - "note": null - } - ] + "recent_event": { + "event_id": 15001, + "event_kind": "issue", + "event_label": "출고", + "delta_quantity": -20, + "counterparty": { + "type": "customer", + "name": "ABC물류" + }, + "warehouse": { + "id": 1, + "warehouse_code": "WH-001", + "warehouse_name": "1센터" + }, + "transaction": { + "id": 9100, + "transaction_no": "TRX-202511100001" + }, + "occurred_at": "2025-10-24T02:58:00Z" + }, + "updated_at": "2025-10-24T03:12:00Z" } ], "page": 1, @@ -1109,385 +1107,491 @@ "total": 1 } ``` +- `warehouse_balances`는 수량 내림차순으로 정렬된 객체 배열이며, 잔량이 0인 창고는 `include_empty=true`를 지정하지 않는 이상 숨긴다. +- `recent_event.event_kind` 값은 `receipt|issue|rental_out|rental_return`. 프런트는 `event_label`을 그대로 UI에 표시하면 된다. +- `recent_event.counterparty.type`은 `vendor|customer|unknown`. 거래처가 없을 경우 `unknown`이며 `name=null`. +- 감사 로그: 조회가 성공하면 `inventory.summary.viewed` 이벤트를 발행하고 `{ actor_id, filters, result_count, request_id }` 페이로드를 남긴다. `filters`에는 `page`, `page_size`, `warehouse_id`, `include_empty`, `sort`, `order`, `updated_since`가 포함된다. +- 오류 코드 + - `403 FORBIDDEN` — `inventory.view` 스코프 또는 `menu_code=inventory`의 읽기 권한이 없으면 `INVENTORY_SCOPE_REQUIRED`. + - `409 CONFLICT` — 마테뷰가 아직 준비되지 않았거나 갱신이 지연되면 `INVENTORY_SNAPSHOT_NOT_READY`. -### 5.3 단건 조회 -`GET /approvals/5001?include=steps,histories` -- 상신자, 이미 결재를 수행한 승인자, 시스템 감사 권한(`approval.view_all`)을 가진 사용자만 접근 가능하다. 향후 단계 승인자는 `403` (`APPROVAL_ACCESS_DENIED`) 응답을 받는다. +### 4.9 재고 현황 단건 (`GET /inventory/summary/{product_id}`) +- 요구 권한: `scope:inventory.view`. +- 쿼리 파라미터: `event_limit`(기본 20, 최대 100), `warehouse_id`(선택 — 특정 창고 히스토리만 반환). +- 응답은 제품 기본 정보와 최근 이벤트 배열을 포함한다. +```json +{ + "data": { + "product": { + "id": 101, + "product_code": "P100", + "product_name": "샘플", + "vendor": { + "id": 10, + "vendor_name": "한빛상사" + } + }, + "total_quantity": 120, + "warehouse_balances": [ + { + "warehouse": { + "id": 1, + "warehouse_code": "WH-001", + "warehouse_name": "1센터" + }, + "quantity": 80 + } + ], + "recent_events": [ + { + "event_id": 15001, + "event_kind": "issue", + "event_label": "출고", + "delta_quantity": -20, + "counterparty": { + "type": "customer", + "name": "ABC물류" + }, + "warehouse": { + "id": 1, + "warehouse_code": "WH-001", + "warehouse_name": "1센터" + }, + "transaction": { + "id": 9100, + "transaction_no": "TRX-202511100001" + }, + "line": { + "id": 12001, + "line_no": 1, + "quantity": 20 + }, + "occurred_at": "2025-10-24T02:58:00Z" + }, + { + "event_id": 14990, + "event_kind": "receipt", + "event_label": "입고", + "delta_quantity": 50, + "counterparty": { + "type": "vendor", + "name": "한빛상사" + }, + "warehouse": { + "id": 1, + "warehouse_code": "WH-001", + "warehouse_name": "1센터" + }, + "transaction": { + "id": 9050, + "transaction_no": "TRX-202511050010" + }, + "line": { + "id": 11990, + "line_no": 1, + "quantity": 50 + }, + "occurred_at": "2025-10-23T23:12:00Z" + } + ], + "updated_at": "2025-10-24T03:12:00Z", + "last_refreshed_at": "2025-10-24T03:10:00Z" + } +} +``` +- `recent_events`는 `event_occurred_at DESC`로 정렬된다. +- `line` 객체는 원본 `transaction_lines` 스냅샷을 제공한다. 삭제된 라인은 `is_deleted=true`인 경우 제외된다. +- `last_refreshed_at`은 뷰가 마지막으로 리프레시된 시각(UTC)이며, UI에서 새로고침 표시를 위해 사용한다. +- 감사 로그: 단건 조회 역시 `inventory.summary.viewed` 이벤트를 남기며 `filters`에는 `product_id`, `warehouse_id`, `event_limit`가 포함된다. +- 오류 코드 + - `403 FORBIDDEN` — `inventory.view` 스코프 또는 메뉴 권한이 없으면 `INVENTORY_SCOPE_REQUIRED`. + - `409 CONFLICT` — 제품별 스냅샷이 아직 생성되지 않았거나 리프레시되지 않은 경우 `INVENTORY_SNAPSHOT_NOT_READY`. + - `404 NOT_FOUND` — 존재하지 않는 제품 + +--- + +## 5. 결재 API +경로 요약: `/api/v1/approvals`(조회·단계 관리), `/api/v1/approval`(제출·상태 전이·이력), `/api/v1/approval-drafts`(임시 저장). + +- 결재는 항상 트랜잭션과 연결되며, `approval_no`는 `APP-YYYYMMDDNNNN` 형식으로 서버가 발급한다. +- 템플릿 기반 제출 시 단계는 `대기` 상태로 복제된다. 템플릿을 이후 수정해도 기존 결재에는 영향을 주지 않는다. +- 모든 전이 엔드포인트는 낙관적 잠금을 위해 `expected_updated_at`(필수)을 요구하며, 트랜잭션과 동기화가 필요한 경우 `transaction_expected_updated_at`을 함께 전달한다. 일치하지 않으면 `409 CONFLICT`가 발생하며 결재 버전이 어긋난 경우 `APPROVAL_VERSION_MISMATCH`, 전표 버전이 다를 때는 `TRANSACTION_VERSION_MISMATCH` 코드를 반환한다. 연결된 전표가 삭제됐거나 찾지 못하면 `409 CONFLICT`(`TRANSACTION_NOT_FOUND`)가 응답된다. +- 열람 권한: 상신자, 현재 단계 승인자, 이미 승인/반려를 완료한 승인자, `approval.manage` 보유자만 결재 상세와 이력에 접근할 수 있다. 조건을 충족하지 못하면 `403`(`APPROVAL_ACCESS_DENIED`). + +### 5.1 결재 제출 (`POST /approval/submit`) +```json +{ + "approval": { + "transaction_id": 91001, + "template_id": 1201, + "approval_status_id": 2, + "requested_by_id": 7, + "final_approver_id": 34, + "title": "입고 전표 결재", + "summary": "2025년 11월 2주차 입고", + "note": "선입고 재고 확인 필요", + "metadata": { + "flow_version": "v2", + "channel": "web" + } + }, + "steps": [ + { "step_order": 1, "approver_id": 21, "note": null }, + { "step_order": 2, "approver_id": 34, "note": "재무 확인" } + ] +} +``` +응답 (`ApprovalDetailResponse`): ```json { "data": { "id": 5001, - "approval_no": "APP-202511100001", + "approval_no": "APP-202501040001", + "transaction_id": 91001, + "template_id": 1201, + "status_id": 2, + "current_step_id": 73001, + "requester_id": 7, + "final_approver_id": 34, "transaction": { - "id": 9001, - "transaction_no": "TRX-202511100001" + "id": 91001, + "transaction_no": "TRX-202501040015", + "updated_at": "2025-01-04T05:05:00Z" + }, + "template": { + "id": 1201, + "template_code": "WH_IN_DEFAULT", + "template_name": "입고 결재 기본", + "version": 3 }, "status": { - "id": 1, - "name": "대기", + "id": 2, + "name": "상신", "color": "#F97316", "is_blocking_next": true, "is_terminal": false }, "current_step": { - "id": 7001, + "id": 73001, + "request_id": 5001, "step_order": 1, - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, "approver": { "id": 21, "employee_id": "E2025002", "name": "박검토" }, - "assigned_at": "2025-09-18T06:05:00Z", + "status": { + "id": 2, + "name": "진행", + "is_blocking_next": true, + "is_terminal": false + }, + "assigned_at": "2025-01-04T05:05:00Z", "decided_at": null, - "note": null + "note": null, + "is_optional": false }, "requester": { "id": 7, "employee_id": "E2025001", "name": "김승인" }, - "requested_at": "2025-09-18T06:00:00Z", + "final_approver": { + "id": 34, + "employee_id": "E2025020", + "name": "최최종" + }, + "title": "입고 전표 결재", + "summary": "2025년 11월 2주차 입고", + "note": "선입고 재고 확인 필요", + "requested_at": "2025-01-04T05:00:00Z", "decided_at": null, - "note": "입고 결재", + "cancelled_at": null, + "last_action_at": "2025-01-04T05:05:00Z", + "metadata": { + "flow_version": "v2", + "channel": "web" + }, + "template_name": "입고 결재 기본", + "is_active": true, + "is_deleted": false, + "created_at": "2025-01-04T05:00:00Z", + "updated_at": "2025-01-04T05:05:00Z", "steps": [ { - "id": 7001, + "id": 73001, + "request_id": 5001, "step_order": 1, + "template_step_id": 42001, + "approver_role": "창고장", + "approver": { + "id": 21, + "employee_id": "E2025002", + "name": "박검토" + }, + "status": { + "id": 2, + "name": "진행", + "is_blocking_next": true, + "is_terminal": false + }, + "assigned_at": "2025-01-04T05:05:00Z", + "decided_at": null, + "action_at": null, + "note": null, + "is_optional": false, + "escalation_minutes": null, + "metadata": null + }, + { + "id": 73002, + "request_id": 5001, + "step_order": 2, + "template_step_id": 42002, + "approver_role": "재무", + "approver": { + "id": 34, + "employee_id": "E2025020", + "name": "최최종" + }, "status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, + "assigned_at": null, + "decided_at": null, + "action_at": null, + "note": "재무 확인", + "is_optional": false, + "escalation_minutes": 120, + "metadata": { + "reminder": "sms" + } + } + ], + "histories": [ + { + "id": 98001, + "step_id": 73001, + "actor": { + "id": 7, + "employee_id": "E2025001", + "name": "김승인" + }, + "action": { + "id": 1, + "name": "상신", + "code": "submit" + }, + "from_status": null, + "to_status": { + "id": 2, + "name": "상신", + "is_blocking_next": true, + "is_terminal": false + }, + "action_at": "2025-01-04T05:00:00Z", + "action_code": "submit", + "note": null + } + ], + "draft": null + } +} +``` +- `steps[].status`는 결재 단계 상태 마스터(`approval_statuses`)를 따른다. +- `draft` 필드는 결재 재개 시 사용된 초안이 존재할 때에만 객체로 채워진다. + +### 5.2 결재 목록 (`GET /approvals`) +쿼리 파라미터: +- `status`: `draft,submitted,in_progress,approved,completed,rejected,recalled,cancelled` 중 콤마 구분. 기본값은 `approved,completed`. 한글 별칭(`승인`, `반려`, `임시`)과 영문 슬러그(`approved`, `rejected`, `submitted`)를 혼용해도 서버가 매핑한다. +- `include`: `transaction,template,steps,histories,draft`를 조합한다. `steps`/`histories`는 비용이 크므로 필요한 경우에만 사용. +- `include_pending=true` 설정 시 기본 상태 필터에 `draft,submitted,in_progress`가 추가된다. +- `transaction`과 `requester` 요약은 기본 응답에 항상 포함되므로 별도 `include` 없이도 반환된다. + +응답 예시(요약 전용): +```json +{ + "items": [ + { + "id": 5001, + "approval_no": "APP-202501040001", + "transaction_id": 91001, + "template_id": 1201, + "status_id": 2, + "current_step_id": 73001, + "requester_id": 7, + "final_approver_id": 34, + "transaction": { + "id": 91001, + "transaction_no": "TRX-202501040015", + "updated_at": "2025-01-04T05:05:00Z" + }, + "template": { + "id": 1201, + "template_code": "WH_IN_DEFAULT", + "template_name": "입고 결재 기본", + "version": 3 + }, + "status": { + "id": 2, + "name": "상신", + "color": "#F97316", + "is_blocking_next": true, + "is_terminal": false + }, + "current_step": { + "id": 73001, + "step_order": 1, + "status": { + "id": 2, + "name": "진행", + "is_blocking_next": true, + "is_terminal": false + }, "approver": { "id": 21, "employee_id": "E2025002", "name": "박검토" - }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": null, - "note": null - } - ], - "histories": [ - { - "id": 91001, - "action": { - "id": 1, - "name": "상신" - }, - "from_status": null, - "to_status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "approver": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, - "action_at": "2025-09-18T06:00:00Z", - "note": null - } - ], - "is_active": true, - "is_deleted": false, - "created_at": "2025-09-18T06:00:00Z", - "updated_at": "2025-09-18T06:05:00Z" + } + }, + "requester": { + "id": 7, + "employee_id": "E2025001", + "name": "김승인" + }, + "final_approver": { + "id": 34, + "employee_id": "E2025020", + "name": "최최종" + }, + "summary": "2025년 11월 2주차 입고", + "note": "선입고 재고 확인 필요", + "requested_at": "2025-01-04T05:00:00Z", + "decided_at": null, + "last_action_at": "2025-01-04T05:05:00Z", + "is_active": true, + "is_deleted": false, + "created_at": "2025-01-04T05:00:00Z", + "updated_at": "2025-01-04T05:05:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` +- `transaction.updated_at`은 전표 낙관적 잠금(재조회 시 버전 확인)에 활용된다. + +### 5.3 결재 상세 (`GET /approvals/{id}`) +- `include=steps,histories,transaction,template,draft` 조합으로 세부 정보를 요청한다. +- 상신자·승인자가 아닌 사용자가 접근하면 `403`. +응답 구조는 5.1과 동일하며, `draft`가 존재할 때 예시는 다음과 같다: +```json +{ + "data": { + "id": 5002, + "approval_no": "APP-202501040002", + "draft": { + "id": 88001, + "request_id": null, + "transaction_id": 91005, + "requester_id": 7, + "template_id": 1201, + "title": "입고 결재 초안", + "summary": "서류 미완료", + "status": "draft", + "saved_at": "2025-01-04T05:10:00Z", + "expires_at": "2025-01-06T05:10:00Z", + "session_key": "draft-session-123", + "step_count": 2 + } } } ``` -### 5.4 단계 구성 (배치 생성) -`POST /approvals/5001/steps` +### 5.4 결재 단계 일괄 구성 (`POST /approvals/{id}/steps`) ```json { "id": 5001, "steps": [ - { - "step_order": 1, - "approver_id": 21, - "note": null - }, - { - "step_order": 2, - "approver_id": 34, - "note": "재무 확인" - } + { "step_order": 1, "approver_id": 21 }, + { "step_order": 2, "approver_id": 34, "note": "재무 확인" } ] } ``` -응답: +응답(`ApprovalStepBatchResponse`): ```json { "data": { "approval_id": 5001, "steps": [ { - "id": 7001, - "approval_id": 5001, + "id": 73001, + "request_id": 5001, "step_order": 1, - "approver_id": 21, - "status_id": 1, - "step_status_id": 1, + "approver": { + "id": 21, + "employee_id": "E2025002", + "name": "박검토" + }, "status": { - "id": 1, - "name": "대기", + "id": 2, + "name": "진행", "is_blocking_next": true, "is_terminal": false }, - "assigned_at": "2025-09-18T06:05:00Z", + "assigned_at": "2025-01-04T05:05:00Z", "decided_at": null, "note": null, - "is_active": true + "is_optional": false }, { - "id": 7002, - "approval_id": 5001, - "step_order": 2, - "approver_id": 34, - "status_id": 1, - "step_status_id": 1, - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": null, - "note": "재무 확인", - "is_active": true - } - ], - "approval": { - "id": 5001, - "transaction_no": "TRX-202511100001", - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "current_step": { - "id": 7001, - "step_order": 1 - }, - "template_name": "입고 결재 기본", - "updated_at": "2025-09-18T06:05:00Z" - } - } -} -``` - -### 5.5 단계 일괄 수정/재배치 -`PATCH /approvals/5001/steps` -```json -{ - "id": 5001, - "steps": [ - { - "id": 7001, - "step_order": 1, - "note": "서류 확인 중" - }, - { - "id": 7002, - "step_order": 2, - "approver_id": 35 - } - ] -} -``` -응답: -```json -{ - "data": { - "approval_id": 5001, - "steps": [ - { - "id": 7001, - "approval_id": 5001, - "step_order": 1, - "approver_id": 21, - "status_id": 1, - "step_status_id": 1, - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": null, - "note": "서류 확인 중", - "is_active": true - }, - { - "id": 7002, - "approval_id": 5001, - "step_order": 2, - "approver_id": 35, - "status_id": 1, - "step_status_id": 1, - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": null, - "note": "재무 확인", - "is_active": true - } - ], - "approval": { - "id": 5001, - "transaction_no": "TRX-202511100001", - "status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "current_step": { - "id": 7001, - "step_order": 1 - }, - "template_name": "입고 결재 기본", - "updated_at": "2025-09-18T06:10:00Z" - } - } -} -``` -- `approval.transaction.updated_at` 필드는 전표(StockTransaction)의 최신 수정 시각(UTC)을 나타내며 회수·재상신 시 `transaction_expected_updated_at`로 전달해야 한다. - -### 5.6 단계 행위 -`POST /approval-steps/7001/actions` -```json -{ - "id": 7001, - "approval_action_id": 1, - "note": "승인합니다." -} -``` -응답: -```json -{ - "data": { - "approval": { - "id": 5001, - "transaction_no": "TRX-202511100001", - "status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - }, - "current_step": { - "id": 7002, + "id": 73002, + "request_id": 5001, "step_order": 2, "approver": { "id": 34, - "employee_id": "E2025003", - "name": "최검토" + "employee_id": "E2025020", + "name": "최최종" }, "status": { - "id": 3, - "name": "진행중", + "id": 1, + "name": "대기", "is_blocking_next": true, "is_terminal": false }, - "assigned_at": "2025-09-18T08:05:00Z", + "assigned_at": null, "decided_at": null, - "note": "재무 확인" - }, - "updated_at": "2025-09-18T08:05:00Z", - "histories": [ - { - "id": 91001, - "action": { - "id": 1, - "name": "승인" - }, - "action_at": "2025-09-18T08:05:00Z", - "note": "승인합니다.", - "from_status": { - "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false - }, - "to_status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - } - } - ] - }, - "step": { - "id": 7001, - "approval_id": 5001, - "step_order": 1, - "approver_id": 21, - "status_id": 2, - "step_status_id": 2, + "note": "재무 확인", + "is_optional": false + } + ], + "approval": { + "id": 5001, + "transaction_no": "TRX-202501040015", "status": { "id": 2, - "name": "진행중", + "name": "상신", "is_blocking_next": true, "is_terminal": false }, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": "2025-09-18T08:05:00Z", - "note": "승인합니다." - }, - "next_step": { - "id": 7002, - "step_order": 2, - "approver": { - "id": 34, - "employee_id": "E2025003", - "name": "최검토" + "current_step": { + "id": 73001, + "step_order": 1 }, - "status": { - "id": 3, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - }, - "assigned_at": "2025-09-18T08:05:00Z", - "decided_at": null, - "note": "재무 확인" - }, - "history": { - "id": 91001, - "approval_step_id": 7001, - "action": { - "id": 1, - "name": "승인" - }, - "note": "승인합니다.", - "action_at": "2025-09-18T08:05:00Z" + "template_name": "입고 결재 기본", + "updated_at": "2025-01-04T05:05:00Z" } } } ``` -응답에는 전후 상태(`from_status`, `to_status`), 차기 단계 정보가 포함되며, `approval_histories`에 기록된다. +`PATCH /approvals/{id}/steps`는 동일한 응답 구조를 반환하며, 요청에는 `id`와 수정할 필드만 포함하면 된다 (`steps[].approver_id`, `step_order`, `note`, `is_optional` 등). -### 5.7 결재 상태 확인 -`GET /approvals/5001/can-proceed` +### 5.5 결재 진행 가능 여부 (`GET /approvals/{id}/can-proceed`) +응답 예: ```json { "data": { @@ -1497,195 +1601,111 @@ } } ``` +- `can_proceed=false`일 경우 `reason`에 차단 사유(예: `blocking step pending`)가 채워진다. -### 5.8 결재 수정·삭제·복구 -- `PATCH /approvals/5001` +### 5.6 승인/반려 처리 (`POST /approval/approve`, `POST /approval/reject`) +요청 공통 구조 (`ApprovalDecisionRequest`): ```json { - "id": 5001, - "approval_status_id": 2, - "note": "보류 처리" + "approval_id": 5001, + "actor_id": 21, + "note": "검토 완료", + "expected_updated_at": "2025-01-04T05:05:00Z" } ``` - 응답은 `data.approval` 구조로 최신 요약을 반환한다. +- `actor_id`는 인증된 사용자 ID와 일치해야 한다. +- 성공 시 `ApprovalMutationResponse`가 반환된다: ```json { "data": { "approval": { "id": 5001, - "approval_no": "APP-202511100001", "status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, + "id": 3, + "name": "승인", + "is_blocking_next": false, "is_terminal": false }, "current_step": { - "id": 7002, + "id": 73002, "step_order": 2 }, - "requester": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, - "requested_at": "2025-09-18T06:00:00Z", - "decided_at": null, - "note": "보류 처리", - "template_name": "입고 결재 기본", - "updated_at": "2025-09-18T08:10:00Z" + "updated_at": "2025-01-04T05:06:30Z" } } } ``` -- `DELETE /approvals/5001` -- `POST /approvals/5001/restore` -### 5.9 결재 이력 조회 -`GET /approval-histories?approval_id=5001` +### 5.7 회수 및 재상신 (`POST /approval/recall`, `POST /approval/resubmit`) +- `recall` 요청은 다음 필드를 사용한다: +```json +{ + "approval_id": 5001, + "actor_id": 7, + "note": "자료 재정비", + "expected_updated_at": "2025-01-04T05:06:30Z", + "transaction_expected_updated_at": "2025-01-04T05:06:30Z" +} +``` +성공 시 `ApprovalMutationResponse`가 반환되고 결재 상태는 `recalled`로 변경된다. +- `resubmit`은 회수/반려 상태에서만 호출 가능하며 단계 배열이 필수다: +```json +{ + "approval_id": 5001, + "actor_id": 7, + "steps": [ + { "step_order": 1, "approver_id": 21 }, + { "step_order": 2, "approver_id": 34 } + ], + "note": "자료 보완 후 재상신", + "expected_updated_at": "2025-01-04T05:07:10Z", + "transaction_expected_updated_at": "2025-01-04T05:07:10Z" +} +``` +응답은 5.1과 동일한 `ApprovalDetailResponse` 구조로 최신 결재 상태를 반환한다. + +### 5.8 결재 이력 (`GET /approval/history`, `GET /approval/history/{id}`) +- 목록은 페이지네이션을 지원하며 `approval_id`, `step_id`, `action_code`, `from`, `to` 등을 필터로 받을 수 있다. +- `action.code`는 참조 행위가 남아 있을 때 `approval_actions.action_name`을 반환하고, 참조가 누락된 경우에도 `action_code` 값을 재사용해 식별 가능하도록 한다. +- 응답 예시: ```json { "items": [ { - "id": 91001, - "approval_id": 5001, - "approval_step_id": 7001, + "id": 98001, + "request_id": 5001, + "step_id": 73001, + "actor": { + "id": 7, + "employee_id": "E2025001", + "name": "김승인" + }, "action": { - "id": 3, - "name": "보류" - }, - "action_at": "2025-09-18T08:05:00Z", - "note": "보류 코멘트", - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" - }, - "from_status": { "id": 1, - "name": "대기", - "is_blocking_next": true, - "is_terminal": false + "name": "상신", + "code": "submit" }, + "from_status": null, "to_status": { "id": 2, - "name": "진행중", + "name": "상신", "is_blocking_next": true, "is_terminal": false }, - "approval": { - "id": 5001, - "approval_no": "APP-202511100001", - "status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - } - }, - "step": { - "id": 7001, - "approval_id": 5001, - "step_order": 1, - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" - }, - "status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - } - } + "action_at": "2025-01-04T05:00:00Z", + "action_code": "submit", + "note": null } ], "page": 1, "page_size": 50, - "total": 2 + "total": 1 } ``` +- 단건 조회는 `GET /approval/history/{id}`로 동일 필드를 반환한다. 권한 규칙은 결재 상세와 동일하다. -기본 응답에는 `approval`, `step`, `approval_action`, `approver`, `from_status`, `to_status` 서브 오브젝트가 포함되며, 추가 정보가 필요하지 않은 경우 `include` 파라미터를 생략해도 동일한 페이로드를 수신한다. `approval_action_id` 필터는 정수 ID 기준으로 동작하므로, 클라이언트는 사전에 제공된 행위 메타데이터로 코드 → ID 매핑을 수행한 뒤 요청해야 한다. - -### 5.10 단계 개별 CRUD -- `GET /approval-steps?approval_id=5001&include=approval,approver,status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 `approval`, `approver`, `status` 서브 오브젝트를 선택적으로 포함한다. -- `GET /approval-steps/7001?include=approval,approver,status` → `{ data: { ... } }`. -- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `status_id`(구 버전 호환용 `step_status_id`)를 생략하면 자동으로 `대기` 상태가 지정된다. -- `PATCH /approval-steps/{id}` → 갱신된 단계 요약을 반환한다. -- `DELETE /approval-steps/{id}` → `{ data: { id, deleted_at } }`. -- `POST /approval-steps/{id}/restore` → `{ data: { id, restored_at } }`. - -주요 필터 및 확장 파라미터: - -- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`(정수 ID), `status_id` -- `action_from`, `action_to` (ISO8601 UTC). 문자열 검색 파라미터 `q`는 2025-11-01 기준 제공되지 않으며, 도입 시 본 문서를 갱신한다. -- `sort=action_at|created_at|updated_at`, `order=asc|desc` -- `include` 기본값은 `approval,step,approval_action,approver,from_status,to_status`이며, `status` 토큰으로 응답을 확장할 수 있다. -- 응답은 `action` 오브젝트에 `name`/`code`를, 루트 레벨에 `action_code`를 포함하여 감사 행위 식별자를 일관되게 노출한다. -- 프런트엔드는 `approval_action_id` 정수 필터를 사용해야 하며, `approval_action.code`만으로는 필터링이 되지 않는다. - -`GET /approval-histories/91001?include=approval,step` -```json -{ - "data": { - "id": 91001, - "approval_id": 5001, - "approval_step_id": 7001, - "action": { - "id": 3, - "name": "보류", - "code": "comment" - }, - "action_at": "2025-09-18T08:05:00Z", - "action_code": "comment", - "note": "보류 코멘트", - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" - }, - "from_status": null, - "to_status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - }, - "approval": { - "id": 5001, - "approval_no": "APP-202511100001", - "status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - } - }, - "step": { - "id": 7001, - "approval_id": 5001, - "step_order": 1, - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" - }, - "status": { - "id": 2, - "name": "진행중", - "is_blocking_next": true, - "is_terminal": false - } - } - } -} -``` - -### 5.11 결재 초안 API (`/approval-drafts`) -- 상신자가 작성 중이던 결재 구성을 서버에 저장하고, 다른 세션에서 복구할 수 있도록 지원한다. -- 초안은 `requester_id`(상신자) 기준으로 구분되며 기본 목록은 유효(`status=active`) 초안만 반환한다. 만료된 초안을 함께 조회하려면 `include_expired=true`를 전달한다. +### 5.9 결재 초안 API (`/approval-drafts`) +- 초안은 상신자가 결재 편집을 중단했을 때 복구 지점을 제공한다. 만료 시각이 지나면 상태가 `expired`로 표시되며, `include_expired=true`를 지정하지 않으면 목록에서 제외된다. `GET /approval-drafts?requester_id=7` ```json @@ -1712,6 +1732,31 @@ } ``` +`GET /approval-drafts/88001?requester_id=7` +```json +{ + "id": 88001, + "requester_id": 7, + "transaction_id": 91005, + "template_id": 1201, + "payload": { + "title": "입고 결재 초안", + "summary": "서류 미완료", + "note": "재고 파악 필요", + "status": "draft", + "template_id": 1201, + "metadata": {"channel": "web"}, + "steps": [ + { "step_order": 1, "approver_id": 21, "is_optional": false }, + { "step_order": 2, "approver_id": 34, "is_optional": false, "note": "재무 확인" } + ] + }, + "saved_at": "2025-01-04T05:10:00Z", + "expires_at": "2025-01-06T05:10:00Z", + "session_key": "draft-session-123" +} +``` + `POST /approval-drafts` ```json { @@ -1728,34 +1773,12 @@ ] } ``` +응답은 저장된 초안 상세(`ApprovalDraftDetail`)이며, `DELETE /approval-drafts/{id}?requester_id=7`는 `204 No Content`를 반환한다. -`POST /approval-drafts/88001/restore` -```json -{ - "data": { - "id": 88001, - "requester_id": 7, - "transaction_id": 91005, - "template_id": 1201, - "payload": { - "title": "입고 결재 초안", - "summary": "서류 미완료", - "note": "재고 파악 필요", - "status": "draft", - "steps": [ - { "step_order": 1, "approver_id": 21, "is_optional": false }, - { "step_order": 2, "approver_id": 34, "is_optional": false, "note": "재무 확인" } - ] - }, - "saved_at": "2025-01-04T05:10:00Z", - "expires_at": "2025-01-06T05:10:00Z", - "session_key": "draft-session-123" - } -} -``` -- 초안 삭제는 `DELETE /approval-drafts/{id}?requester_id=<상신자 ID>`를 호출하며 `204 No Content`가 응답된다. - ---- +### 5.10 순응성 체크리스트 +- 결재 API는 모든 응답에서 `is_deleted`를 포함하지만 값은 읽기 전용이다. 소프트 삭제 복원은 `POST /approvals/{id}/restore`. +- 경로 `/approval/...` 엔드포인트는 행위(action)를 표현하므로 HTTP 동사를 분리하지 않는다. 대신 요청 본문에 `note`, `expected_updated_at` 등을 포함해 전이 정보를 전달한다. +- Prometheus 메트릭 `approval_*` 계열은 `/metrics`에서 노출되며, 승인/반려/회수 호출 시 `approval_failure_total` 가치가 증가하면 운영팀 알람 정책(B6-2/B6-3)을 따른다. ## 6. 결재 템플릿 API 리소스: `/approval/templates` @@ -1770,11 +1793,13 @@ "template_code": "AP_INBOUND", "template_name": "입고 결재 기본", "description": "입고 결재 2단계", + "version": 3, "created_by": { "id": 7, "employee_id": "E2025001", "name": "김승인" }, + "is_default": false, "is_active": true, "created_at": "2025-01-20T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z" @@ -1794,28 +1819,33 @@ "data": { "id": 3001, "template_code": "AP_INBOUND", - "template_name": "입고 결재 기본", - "description": "입고 결재 2단계", - "created_by": { - "id": 7, - "employee_id": "E2025001", - "name": "김승인" - }, - "steps": [ - { - "id": 9101, - "step_order": 1, - "approver": { - "id": 21, - "employee_id": "E2025002", - "name": "박검토" - }, - "note": null - } - ], - "is_active": true, - "created_at": "2025-01-20T00:00:00Z", - "updated_at": "2025-01-25T00:00:00Z" + "template_name": "입고 결재 기본", + "description": "입고 결재 2단계", + "version": 3, + "created_by": { + "id": 7, + "employee_id": "E2025001", + "name": "김승인" + }, + "is_default": false, + "steps": [ + { + "id": 9101, + "step_order": 1, + "approver": { + "id": 21, + "employee_id": "E2025002", + "name": "박검토" + }, + "approver_role": null, + "escalation_minutes": null, + "note": null, + "is_optional": false + } + ], + "is_active": true, + "created_at": "2025-01-20T00:00:00Z", + "updated_at": "2025-01-25T00:00:00Z" } } ``` @@ -1828,7 +1858,8 @@ "template_name": "출고 결재 기본", "description": "출고 결재 3단계", "created_by_id": 7, - "note": "표준 출고" + "note": "표준 출고", + "is_default": false } ``` @@ -1836,14 +1867,19 @@ ```json { "id": 3002, + "expected_version": 3, "steps": [ { "step_order": 1, - "approver_id": 34 + "approver_id": 34, + "approver_role": null, + "escalation_minutes": null, + "is_optional": false }, { "step_order": 2, - "approver_id": 55 + "approver_id": 55, + "is_optional": false } ] } @@ -1854,7 +1890,8 @@ { "id": 3002, "template_name": "출고 결재 확장", - "note": "정기 출고용" + "note": "정기 출고용", + "expected_version": 4 } ``` @@ -1862,17 +1899,20 @@ ```json { "id": 3002, + "expected_version": 4, "steps": [ { "id": 9105, "step_order": 1, - "approver_id": 36 + "approver_id": 36, + "is_optional": false } ] } ``` - 삭제/복구: `DELETE /approval/templates/{id}`, `POST /approval/templates/{id}/restore` +- 템플릿/단계 수정 시에는 `expected_version`을 전달해 낙관적 잠금을 적용하며, 불일치 시 `409 Conflict` (`approval template version mismatch`)를 반환한다. --- @@ -1985,7 +2025,7 @@ "transaction_no": "TRX-202511100001", "transaction_date": "2025-09-18", "transaction_type": "입고", - "status_name": "상신", + "status_name": "완료", "created_by": "김승인" } ], @@ -1998,9 +2038,10 @@ "requested_at": "2025-09-17T03:00:00Z" } ] - } +} } ``` +- `recent_transactions[]`는 최종 승인 상태(`승인`, `완료`)의 전표만 포함하며, 결재 진행 중 건은 제외된다. 대기·임시 전표는 `GET /stock-transactions?status=draft,submitted` 또는 `include_pending=true`로 별도 조회한다. --- @@ -2009,3 +2050,39 @@ - 배열 기반 다건 작업은 전체를 트랜잭션 처리해야 한다. 실패 시 롤백하고 부분 처리 결과를 반환하지 않는다. - `is_active` 변경은 권한·결재 등의 즉시성 요구를 고려하여 관련 캐시를 무효화한다. - 결재 단계 상태 전이는 `approval_statuses.is_blocking_next` 규칙을 준수해야 하며, 반려(`is_terminal=true`) 상태 시 결재를 종료한다. +- 감사 로그가 생성되면 `approval.audit.recorded` 이벤트를 Kafka(`event_bus.kafka.*` 설정)와 WebSocket 브로드캐스트(`event_bus.websocket.*`) 채널로 동시에 발행한다. 메시지는 아래와 같은 JSON 페이로드를 사용한다. + ```json + { + "event": "approval.audit.recorded", + "version": "1.0", + "emitted_at": "2025-09-18T06:01:12Z", + "request_id": 5001, + "audit_id": 91001, + "summary": { + "id": 91001, + "request_id": 5001, + "step_id": 7001, + "actor": { + "id": 7, + "employee_id": "E2025001", + "name": "김승인" + }, + "action": { + "id": 1, + "name": "상신", + "code": "submit" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "action_at": "2025-09-18T06:00:00Z", + "action_code": "submit", + "note": null, + "payload": null + } + } + ``` diff --git a/doc/stock_approval_system_spec_v4.md b/doc/stock_approval_system_spec_v4.md index fc1b732..33ac11c 100644 --- a/doc/stock_approval_system_spec_v4.md +++ b/doc/stock_approval_system_spec_v4.md @@ -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`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다. diff --git a/lib/core/constants/app_sections.dart b/lib/core/constants/app_sections.dart index faa0022..9428f03 100644 --- a/lib/core/constants/app_sections.dart +++ b/lib/core/constants/app_sections.dart @@ -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 extraRequiredResources; } /// 메뉴 섹션을 나타내는 데이터 클래스. @@ -30,6 +34,9 @@ const loginRoutePath = '/login'; /// 대시보드 라우트 경로. const dashboardRoutePath = '/dashboard'; +/// 재고 현황 라우트 경로. +const inventorySummaryRoutePath = '/inventory/summary'; + /// 네비게이션 구성을 정의한 섹션 목록. const appSections = [ AppSectionDescriptor( @@ -43,6 +50,18 @@ const appSections = [ ), ], ), + AppSectionDescriptor( + label: '재고', + pages: [ + AppPageDescriptor( + path: inventorySummaryRoutePath, + label: '재고 현황', + icon: lucide.LucideIcons.chartNoAxesColumnIncreasing, + summary: '제품별 총 재고, 창고 잔량, 최근 이벤트를 한 화면에서 확인합니다.', + extraRequiredResources: [PermissionResources.inventoryScope], + ), + ], + ), AppSectionDescriptor( label: '입·출고', pages: [ diff --git a/lib/core/network/api_routes.dart b/lib/core/network/api_routes.dart index 0d9f537..93ad8d0 100644 --- a/lib/core/network/api_routes.dart +++ b/lib/core/network/api_routes.dart @@ -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]); + } } diff --git a/lib/core/permissions/permission_bootstrapper.dart b/lib/core/permissions/permission_bootstrapper.dart new file mode 100644 index 0000000..1700691 --- /dev/null +++ b/lib/core/permissions/permission_bootstrapper.dart @@ -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 apply(AuthSession session) async { + _manager.clearServerPermissions(); + + final aggregated = >{}; + var hasMenuPermission = false; + + void merge(Map> map) { + if (map.isEmpty) { + return; + } + for (final entry in map.entries) { + final target = aggregated.putIfAbsent( + entry.key, + () => {}, + ); + 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 _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>> _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 _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 groups) { + for (final group in groups) { + if (group.id != null) { + return group; + } + } + return null; + } +} diff --git a/lib/core/permissions/permission_manager.dart b/lib/core/permissions/permission_manager.dart index ead20b1..a2d1b1a 100644 --- a/lib/core/permissions/permission_manager.dart +++ b/lib/core/permissions/permission_manager.dart @@ -41,6 +41,10 @@ class PermissionManager extends ChangeNotifier { return server.contains(action); } + if (key.startsWith('scope:')) { + return false; + } + return Environment.hasPermission(key, action.name); } diff --git a/lib/core/permissions/permission_resources.dart b/lib/core/permissions/permission_resources.dart index d9b1ccc..6a5a2f5 100644 --- a/lib/core/permissions/permission_resources.dart +++ b/lib/core/permissions/permission_resources.dart @@ -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; } } diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index d9d86b6..26f7850 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -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', diff --git a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart index f0960ea..748e383 100644 --- a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart +++ b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart @@ -880,10 +880,7 @@ class _TemplateToolbar extends StatelessWidget { ); if (!canApplyTemplate) { - applyButton = Tooltip( - message: '템플릿을 적용할 권한이 없습니다.', - child: applyButton, - ); + applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton); } return Column( diff --git a/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart b/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart index de4748c..c1b09f5 100644 --- a/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart +++ b/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart @@ -29,11 +29,7 @@ class ApprovalFormInitializer { controller.setRequester(defaultRequester); } if (draft != null) { - await _applyDraft( - controller, - draft, - repository ?? _resolveRepository(), - ); + await _applyDraft(controller, draft, repository ?? _resolveRepository()); } } diff --git a/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart b/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart index 66dec84..34c6da4 100644 --- a/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart +++ b/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart @@ -508,7 +508,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> { } idController.dispose(); } - } class _InfoBadge extends StatelessWidget { diff --git a/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart b/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart index 9d71ab9..05f4c9e 100644 --- a/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart +++ b/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart @@ -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?, diff --git a/lib/features/auth/data/dtos/auth_session_dto.dart b/lib/features/auth/data/dtos/auth_session_dto.dart index a8de8f8..5739e80 100644 --- a/lib/features/auth/data/dtos/auth_session_dto.dart +++ b/lib/features/auth/data/dtos/auth_session_dto.dart @@ -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.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> _readList(Map source, String key) { return const []; } +Set _readScopeCodes(Map source) { + final codes = {}; + + 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) { + 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 _readMap(Map source, String key) { final value = source[key]; if (value is Map) { @@ -162,3 +234,15 @@ int? _readOptionalInt(Map? 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'; +} diff --git a/lib/features/auth/domain/entities/auth_permission.dart b/lib/features/auth/domain/entities/auth_permission.dart index 581b50b..aef3562 100644 --- a/lib/features/auth/domain/entities/auth_permission.dart +++ b/lib/features/auth/domain/entities/auth_permission.dart @@ -15,6 +15,7 @@ class AuthPermission { Map> toPermissionMap() { final normalized = PermissionResources.normalize(resource); final actionSet = {}; + 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 >{}; } diff --git a/lib/features/inventory/summary/application/inventory_service.dart b/lib/features/inventory/summary/application/inventory_service.dart new file mode 100644 index 0000000..80e095b --- /dev/null +++ b/lib/features/inventory/summary/application/inventory_service.dart @@ -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 fetchSummaries({ + InventorySummaryFilter? filter, + }) { + return _repository.listSummaries(filter: filter); + } + + /// 특정 제품 상세를 조회한다. + Future fetchDetail( + int productId, { + InventoryDetailFilter? filter, + }) { + return _repository.fetchDetail(productId, filter: filter); + } +} diff --git a/lib/features/inventory/summary/data/dtos/inventory_common_dtos.dart b/lib/features/inventory/summary/data/dtos/inventory_common_dtos.dart new file mode 100644 index 0000000..f5ef0c2 --- /dev/null +++ b/lib/features/inventory/summary/data/dtos/inventory_common_dtos.dart @@ -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 json) => + _$InventoryVendorDtoFromJson(json); + + Map 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 json) => + _$InventoryProductDtoFromJson(json); + + Map 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 json) => + _$InventoryWarehouseDtoFromJson(json); + + Map 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 json) => + _$InventoryWarehouseBalanceDtoFromJson(json); + + Map 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 json) => + _$InventoryCounterpartyDtoFromJson(json); + + Map 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 json) => + _$InventoryTransactionRefDtoFromJson(json); + + Map 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 json) => + _$InventoryEventLineRefDtoFromJson(json); + + Map 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 json) => + _$InventoryEventDtoFromJson(json); + + Map 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); +} diff --git a/lib/features/inventory/summary/data/dtos/inventory_common_dtos.g.dart b/lib/features/inventory/summary/data/dtos/inventory_common_dtos.g.dart new file mode 100644 index 0000000..10a05e3 --- /dev/null +++ b/lib/features/inventory/summary/data/dtos/inventory_common_dtos.g.dart @@ -0,0 +1,150 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inventory_common_dtos.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +InventoryVendorDto _$InventoryVendorDtoFromJson(Map json) => + InventoryVendorDto( + id: (json['id'] as num?)?.toInt(), + vendorName: json['vendor_name'] as String?, + ); + +Map _$InventoryVendorDtoToJson(InventoryVendorDto instance) => + {'id': instance.id, 'vendor_name': instance.vendorName}; + +InventoryProductDto _$InventoryProductDtoFromJson(Map 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), + ); + +Map _$InventoryProductDtoToJson( + InventoryProductDto instance, +) => { + 'id': instance.id, + 'product_code': instance.productCode, + 'product_name': instance.productName, + 'vendor': instance.vendor?.toJson(), +}; + +InventoryWarehouseDto _$InventoryWarehouseDtoFromJson( + Map json, +) => InventoryWarehouseDto( + id: (json['id'] as num).toInt(), + warehouseCode: json['warehouse_code'] as String?, + warehouseName: json['warehouse_name'] as String?, +); + +Map _$InventoryWarehouseDtoToJson( + InventoryWarehouseDto instance, +) => { + 'id': instance.id, + 'warehouse_code': instance.warehouseCode, + 'warehouse_name': instance.warehouseName, +}; + +InventoryWarehouseBalanceDto _$InventoryWarehouseBalanceDtoFromJson( + Map json, +) => InventoryWarehouseBalanceDto( + warehouse: InventoryWarehouseDto.fromJson( + json['warehouse'] as Map, + ), + quantity: _parseQuantity(json['quantity']), +); + +Map _$InventoryWarehouseBalanceDtoToJson( + InventoryWarehouseBalanceDto instance, +) => { + 'warehouse': instance.warehouse.toJson(), + 'quantity': instance.quantity, +}; + +InventoryCounterpartyDto _$InventoryCounterpartyDtoFromJson( + Map json, +) => InventoryCounterpartyDto( + type: json['type'] as String?, + name: json['name'] as String?, +); + +Map _$InventoryCounterpartyDtoToJson( + InventoryCounterpartyDto instance, +) => {'type': instance.type, 'name': instance.name}; + +InventoryTransactionRefDto _$InventoryTransactionRefDtoFromJson( + Map json, +) => InventoryTransactionRefDto( + id: (json['id'] as num).toInt(), + transactionNo: json['transaction_no'] as String?, +); + +Map _$InventoryTransactionRefDtoToJson( + InventoryTransactionRefDto instance, +) => { + 'id': instance.id, + 'transaction_no': instance.transactionNo, +}; + +InventoryEventLineRefDto _$InventoryEventLineRefDtoFromJson( + Map json, +) => InventoryEventLineRefDto( + id: (json['id'] as num).toInt(), + lineNo: (json['line_no'] as num?)?.toInt(), + quantity: _parseNullableQuantity(json['quantity']), +); + +Map _$InventoryEventLineRefDtoToJson( + InventoryEventLineRefDto instance, +) => { + 'id': instance.id, + 'line_no': instance.lineNo, + 'quantity': instance.quantity, +}; + +InventoryEventDto _$InventoryEventDtoFromJson(Map 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, + ), + warehouse: json['warehouse'] == null + ? null + : InventoryWarehouseDto.fromJson( + json['warehouse'] as Map, + ), + transaction: json['transaction'] == null + ? null + : InventoryTransactionRefDto.fromJson( + json['transaction'] as Map, + ), + line: json['line'] == null + ? null + : InventoryEventLineRefDto.fromJson( + json['line'] as Map, + ), + ); + +Map _$InventoryEventDtoToJson(InventoryEventDto instance) => + { + '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(), + }; diff --git a/lib/features/inventory/summary/data/dtos/inventory_detail_response.dart b/lib/features/inventory/summary/data/dtos/inventory_detail_response.dart new file mode 100644 index 0000000..26ab43c --- /dev/null +++ b/lib/features/inventory/summary/data/dtos/inventory_detail_response.dart @@ -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 json) => + _$InventoryDetailResponseFromJson(json); + + Map toJson() => _$InventoryDetailResponseToJson(this); + + InventoryDetail toEntity() => data.toEntity(); +} + +@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true) +class InventoryDetailDataDto { + const InventoryDetailDataDto({ + required this.product, + required this.totalQuantity, + List? warehouseBalances, + List? recentEvents, + this.updatedAt, + this.lastRefreshedAt, + }) : warehouseBalances = warehouseBalances ?? const [], + recentEvents = recentEvents ?? const []; + + final InventoryProductDto product; + @JsonKey(fromJson: _parseQuantity) + final int totalQuantity; + @JsonKey(defaultValue: []) + final List warehouseBalances; + @JsonKey(defaultValue: []) + final List recentEvents; + final DateTime? updatedAt; + final DateTime? lastRefreshedAt; + + factory InventoryDetailDataDto.fromJson(Map json) => + _$InventoryDetailDataDtoFromJson(json); + + Map 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; +} diff --git a/lib/features/inventory/summary/data/dtos/inventory_detail_response.g.dart b/lib/features/inventory/summary/data/dtos/inventory_detail_response.g.dart new file mode 100644 index 0000000..60d4a29 --- /dev/null +++ b/lib/features/inventory/summary/data/dtos/inventory_detail_response.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inventory_detail_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +InventoryDetailResponse _$InventoryDetailResponseFromJson( + Map json, +) => InventoryDetailResponse( + data: InventoryDetailDataDto.fromJson(json['data'] as Map), +); + +Map _$InventoryDetailResponseToJson( + InventoryDetailResponse instance, +) => {'data': instance.data.toJson()}; + +InventoryDetailDataDto _$InventoryDetailDataDtoFromJson( + Map json, +) => InventoryDetailDataDto( + product: InventoryProductDto.fromJson( + json['product'] as Map, + ), + totalQuantity: _parseQuantity(json['total_quantity']), + warehouseBalances: + (json['warehouse_balances'] as List?) + ?.map( + (e) => InventoryWarehouseBalanceDto.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + recentEvents: + (json['recent_events'] as List?) + ?.map((e) => InventoryEventDto.fromJson(e as Map)) + .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 _$InventoryDetailDataDtoToJson( + InventoryDetailDataDto instance, +) => { + '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(), +}; diff --git a/lib/features/inventory/summary/data/dtos/inventory_summary_response.dart b/lib/features/inventory/summary/data/dtos/inventory_summary_response.dart new file mode 100644 index 0000000..aa8fba8 --- /dev/null +++ b/lib/features/inventory/summary/data/dtos/inventory_summary_response.dart @@ -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? items, + this.page = 1, + this.pageSize = 0, + this.total = 0, + this.lastRefreshedAt, + }) : items = items ?? const []; + + @JsonKey(defaultValue: []) + final List items; + final int page; + final int pageSize; + final int total; + final DateTime? lastRefreshedAt; + + factory InventorySummaryResponse.fromJson(Map json) => + _$InventorySummaryResponseFromJson(json); + + Map toJson() => _$InventorySummaryResponseToJson(this); + + InventorySummaryListResult toEntity() { + final summaries = items + .map((item) => item.toEntity()) + .toList(growable: false); + final paginated = PaginatedResult( + 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? warehouseBalances, + this.recentEvent, + this.updatedAt, + this.lastRefreshedAt, + }) : warehouseBalances = warehouseBalances ?? const []; + + final InventoryProductDto product; + @JsonKey(fromJson: _parseQuantity) + final int totalQuantity; + @JsonKey(defaultValue: []) + final List warehouseBalances; + final InventoryEventDto? recentEvent; + final DateTime? updatedAt; + final DateTime? lastRefreshedAt; + + factory InventorySummaryItemDto.fromJson(Map json) => + _$InventorySummaryItemDtoFromJson(json); + + Map 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; +} diff --git a/lib/features/inventory/summary/data/dtos/inventory_summary_response.g.dart b/lib/features/inventory/summary/data/dtos/inventory_summary_response.g.dart new file mode 100644 index 0000000..0dd26ef --- /dev/null +++ b/lib/features/inventory/summary/data/dtos/inventory_summary_response.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'inventory_summary_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +InventorySummaryResponse _$InventorySummaryResponseFromJson( + Map json, +) => InventorySummaryResponse( + items: + (json['items'] as List?) + ?.map( + (e) => InventorySummaryItemDto.fromJson(e as Map), + ) + .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 _$InventorySummaryResponseToJson( + InventorySummaryResponse instance, +) => { + '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 json, +) => InventorySummaryItemDto( + product: InventoryProductDto.fromJson( + json['product'] as Map, + ), + totalQuantity: _parseQuantity(json['total_quantity']), + warehouseBalances: + (json['warehouse_balances'] as List?) + ?.map( + (e) => InventoryWarehouseBalanceDto.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + recentEvent: json['recent_event'] == null + ? null + : InventoryEventDto.fromJson( + json['recent_event'] as Map, + ), + 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 _$InventorySummaryItemDtoToJson( + InventorySummaryItemDto instance, +) => { + '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(), +}; diff --git a/lib/features/inventory/summary/data/repositories/inventory_repository_remote.dart b/lib/features/inventory/summary/data/repositories/inventory_repository_remote.dart new file mode 100644 index 0000000..f09dcfa --- /dev/null +++ b/lib/features/inventory/summary/data/repositories/inventory_repository_remote.dart @@ -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 listSummaries({ + InventorySummaryFilter? filter, + }) async { + final effectiveFilter = filter ?? const InventorySummaryFilter(); + final response = await _api.get>( + _summaryPath, + query: effectiveFilter.toQuery(), + options: Options(responseType: ResponseType.json), + ); + final body = response.data ?? const {}; + return InventorySummaryResponse.fromJson(body).toEntity(); + } + + @override + Future fetchDetail( + int productId, { + InventoryDetailFilter? filter, + }) async { + final effectiveFilter = filter ?? const InventoryDetailFilter(); + final response = await _api.get>( + ApiRoutes.inventorySummaryDetail(productId), + query: effectiveFilter.toQuery(), + options: Options(responseType: ResponseType.json), + ); + final body = response.data ?? const {}; + return InventoryDetailResponse.fromJson(body).toEntity(); + } +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_counterparty.dart b/lib/features/inventory/summary/domain/entities/inventory_counterparty.dart new file mode 100644 index 0000000..e9584f6 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_counterparty.dart @@ -0,0 +1,10 @@ +/// 재고 이벤트와 연결된 거래처 유형. +enum InventoryCounterpartyType { vendor, customer, unknown } + +/// 재고 이벤트의 거래처 정보를 표현한다. +class InventoryCounterparty { + const InventoryCounterparty({required this.type, this.name}); + + final InventoryCounterpartyType type; + final String? name; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_detail.dart b/lib/features/inventory/summary/domain/entities/inventory_detail.dart new file mode 100644 index 0000000..2e4eed3 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_detail.dart @@ -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 warehouseBalances; + final List recentEvents; + final DateTime? updatedAt; + final DateTime? lastRefreshedAt; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_event.dart b/lib/features/inventory/summary/domain/entities/inventory_event.dart new file mode 100644 index 0000000..0e9b70a --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_event.dart @@ -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; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_filters.dart b/lib/features/inventory/summary/domain/entities/inventory_filters.dart new file mode 100644 index 0000000..4af7f96 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_filters.dart @@ -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 toQuery() { + final queryMap = {'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 toQuery() { + final map = {'event_limit': eventLimit}; + if (warehouseId != null) { + map['warehouse_id'] = warehouseId; + } + return map; + } +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_product.dart b/lib/features/inventory/summary/domain/entities/inventory_product.dart new file mode 100644 index 0000000..0e69dda --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_product.dart @@ -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; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_summary.dart b/lib/features/inventory/summary/domain/entities/inventory_summary.dart new file mode 100644 index 0000000..3f8fa8b --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_summary.dart @@ -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 warehouseBalances; + final InventoryEvent? recentEvent; + final DateTime? updatedAt; + final DateTime? lastRefreshedAt; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_summary_list_result.dart b/lib/features/inventory/summary/domain/entities/inventory_summary_list_result.dart new file mode 100644 index 0000000..118da13 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_summary_list_result.dart @@ -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 result; + final DateTime? lastRefreshedAt; + + InventorySummaryListResult copyWith({ + PaginatedResult? result, + DateTime? lastRefreshedAt, + }) { + return InventorySummaryListResult( + result: result ?? this.result, + lastRefreshedAt: lastRefreshedAt ?? this.lastRefreshedAt, + ); + } +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_transaction_reference.dart b/lib/features/inventory/summary/domain/entities/inventory_transaction_reference.dart new file mode 100644 index 0000000..c35b573 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_transaction_reference.dart @@ -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; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_vendor.dart b/lib/features/inventory/summary/domain/entities/inventory_vendor.dart new file mode 100644 index 0000000..cd7c457 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_vendor.dart @@ -0,0 +1,10 @@ +/// 재고 요약에서 사용하는 공급사 정보를 표현하는 값 객체. +class InventoryVendor { + const InventoryVendor({this.id, required this.name}); + + /// 공급사 식별자. 미정의일 수 있다. + final int? id; + + /// 공급사 명칭. + final String name; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_warehouse.dart b/lib/features/inventory/summary/domain/entities/inventory_warehouse.dart new file mode 100644 index 0000000..79a9a18 --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_warehouse.dart @@ -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; +} diff --git a/lib/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart b/lib/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart new file mode 100644 index 0000000..57b26dd --- /dev/null +++ b/lib/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart @@ -0,0 +1,15 @@ +import 'inventory_warehouse.dart'; + +/// 특정 창고의 재고 수량을 나타내는 모델. +class InventoryWarehouseBalance { + const InventoryWarehouseBalance({ + required this.warehouse, + required this.quantity, + }); + + /// 창고 정보. + final InventoryWarehouse warehouse; + + /// 창고 내 잔량. + final int quantity; +} diff --git a/lib/features/inventory/summary/domain/repositories/inventory_repository.dart b/lib/features/inventory/summary/domain/repositories/inventory_repository.dart new file mode 100644 index 0000000..3fbf221 --- /dev/null +++ b/lib/features/inventory/summary/domain/repositories/inventory_repository.dart @@ -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 listSummaries({ + InventorySummaryFilter? filter, + }); + + /// 특정 제품의 상세 정보를 조회한다. + Future fetchDetail( + int productId, { + InventoryDetailFilter? filter, + }); +} diff --git a/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart b/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart new file mode 100644 index 0000000..cdf3f14 --- /dev/null +++ b/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart @@ -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 _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 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 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 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, + ); + } +} diff --git a/lib/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart b/lib/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart new file mode 100644 index 0000000..44ce890 --- /dev/null +++ b/lib/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart @@ -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? _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? 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 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 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(); + } +} diff --git a/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart b/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart new file mode 100644 index 0000000..b9e8332 --- /dev/null +++ b/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart @@ -0,0 +1,1049 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../core/common/models/paginated_result.dart'; +import '../../../../../core/constants/app_sections.dart'; +import '../../../../../widgets/app_layout.dart'; +import '../../../../../widgets/components/filter_bar.dart'; +import '../../../../../widgets/components/form_field.dart'; +import '../../../../../widgets/components/superport_date_picker.dart'; +import '../../../../../widgets/components/superport_table.dart'; +import '../../../summary/application/inventory_service.dart'; +import '../../../summary/domain/entities/inventory_event.dart'; +import '../../../summary/domain/entities/inventory_summary.dart'; +import '../../../summary/domain/entities/inventory_warehouse_balance.dart'; +import '../controllers/inventory_detail_controller.dart'; +import '../controllers/inventory_summary_controller.dart'; +import '../../../shared/widgets/warehouse_select_field.dart'; + +/// 재고 현황 목록/상세 화면. +const Map _inventorySummaryColumnWidths = { + 0: 56, + 1: 320, + 2: 200, + 3: 120, + 4: 260, + 5: 180, +}; + +class InventorySummaryPage extends StatefulWidget { + const InventorySummaryPage({ + super.key, + required this.routeUri, + this.debugRowHeight, + }); + + final Uri routeUri; + final double? debugRowHeight; + + @override + State createState() => _InventorySummaryPageState(); +} + +class _InventorySummaryPageState extends State { + late final InventorySummaryController _summaryController; + late final InventoryDetailController _detailController; + final TextEditingController _searchController = TextEditingController(); + final TextEditingController _productController = TextEditingController(); + final TextEditingController _vendorController = TextEditingController(); + + String _pendingQuery = ''; + String? _pendingProductName; + String? _pendingVendorName; + int? _pendingWarehouseId; + bool _pendingIncludeEmpty = false; + DateTime? _pendingUpdatedSince; + SuperportTableSortState? _sortState; + bool _syncedInitialFilters = false; + bool _autoRefreshEnabled = true; + Timer? _autoRefreshTimer; + + static const Duration _autoRefreshInterval = Duration(seconds: 30); + + static const Map _columnSortKeys = { + 1: 'product_name', + 2: 'vendor_name', + 3: 'total_quantity', + 5: 'last_event_at', + }; + + @override + void initState() { + super.initState(); + final service = GetIt.I(); + _summaryController = InventorySummaryController(service: service) + ..addListener(_onSummaryChanged); + _detailController = InventoryDetailController(service: service); + WidgetsBinding.instance.addPostFrameCallback((_) { + _summaryController.fetch(); + _syncPendingFilters(force: true); + _restartAutoRefreshTimer(); + }); + } + + @override + void dispose() { + _summaryController.removeListener(_onSummaryChanged); + _summaryController.dispose(); + _detailController.dispose(); + _searchController.dispose(); + _productController.dispose(); + _vendorController.dispose(); + _autoRefreshTimer?.cancel(); + super.dispose(); + } + + void _onSummaryChanged() { + if (!_syncedInitialFilters) { + _syncPendingFilters(force: true); + } + final summarySort = _summaryController.sort; + if (summarySort != null) { + final columnIndex = _columnSortKeys.entries + .firstWhere( + (entry) => entry.value == summarySort, + orElse: () => const MapEntry(-1, ''), + ) + .key; + if (columnIndex != -1) { + final ascending = (_summaryController.order ?? 'desc') == 'asc'; + _sortState = SuperportTableSortState( + columnIndex: columnIndex, + ascending: ascending, + ); + } + } + setState(() {}); + } + + void _syncPendingFilters({bool force = false}) { + if (!force && _syncedInitialFilters) { + return; + } + _pendingQuery = _summaryController.query; + _pendingProductName = _summaryController.productName; + _pendingVendorName = _summaryController.vendorName; + _pendingWarehouseId = _summaryController.warehouseId; + _pendingIncludeEmpty = _summaryController.includeEmpty; + _pendingUpdatedSince = _summaryController.updatedSince; + _searchController.text = _pendingQuery; + _productController.text = _pendingProductName ?? ''; + _vendorController.text = _pendingVendorName ?? ''; + _syncedInitialFilters = true; + } + + void _restartAutoRefreshTimer() { + _autoRefreshTimer?.cancel(); + if (!_autoRefreshEnabled) { + return; + } + _autoRefreshTimer = Timer.periodic(_autoRefreshInterval, (_) { + if (!mounted || _summaryController.isLoading) { + return; + } + _summaryController.refresh(); + }); + } + + void _toggleAutoRefresh(bool value) { + if (_autoRefreshEnabled == value) { + return; + } + setState(() => _autoRefreshEnabled = value); + _restartAutoRefreshTimer(); + } + + bool get _hasActiveFilters { + final controller = _summaryController; + return controller.query.isNotEmpty || + (controller.productName?.isNotEmpty ?? false) || + (controller.vendorName?.isNotEmpty ?? false) || + controller.warehouseId != null || + controller.includeEmpty || + controller.updatedSince != null; + } + + bool get _hasPendingChanges { + final controller = _summaryController; + final normalizedPendingProduct = _pendingProductName?.trim() ?? ''; + final normalizedPendingVendor = _pendingVendorName?.trim() ?? ''; + final normalizedControllerProduct = controller.productName ?? ''; + final normalizedControllerVendor = controller.vendorName ?? ''; + final sameDate = _isSameDay(controller.updatedSince, _pendingUpdatedSince); + return controller.query != _pendingQuery.trim() || + normalizedControllerProduct != normalizedPendingProduct || + normalizedControllerVendor != normalizedPendingVendor || + controller.warehouseId != _pendingWarehouseId || + controller.includeEmpty != _pendingIncludeEmpty || + !sameDate; + } + + Future _applyFilters() async { + _summaryController + ..updateQuery(_pendingQuery) + ..updateProductName(_pendingProductName) + ..updateVendorName(_pendingVendorName) + ..updateWarehouse(_pendingWarehouseId) + ..toggleIncludeEmpty(_pendingIncludeEmpty) + ..updateUpdatedSince(_pendingUpdatedSince); + await _summaryController.fetch(page: 1); + } + + Future _resetFilters() async { + setState(() { + _pendingQuery = ''; + _pendingProductName = null; + _pendingVendorName = null; + _pendingWarehouseId = null; + _pendingIncludeEmpty = false; + _pendingUpdatedSince = null; + _searchController.clear(); + _productController.clear(); + _vendorController.clear(); + _sortState = null; + }); + _summaryController + ..updateQuery('') + ..updateProductName(null) + ..updateVendorName(null) + ..updateWarehouse(null) + ..toggleIncludeEmpty(false) + ..updateUpdatedSince(null) + ..updateSort(null); + await _summaryController.fetch(page: 1); + } + + void _handleSortChanged(int columnIndex, bool ascending) { + final sortKey = _columnSortKeys[columnIndex]; + if (sortKey == null) { + return; + } + setState(() { + _sortState = SuperportTableSortState( + columnIndex: columnIndex, + ascending: ascending, + ); + }); + _summaryController + ..updateSort(sortKey, order: ascending ? 'asc' : 'desc') + ..fetch(page: 1); + } + + @override + Widget build(BuildContext context) { + final result = _summaryController.result; + final items = result?.items ?? const []; + final hasData = items.isNotEmpty; + final lastEventTime = hasData + ? items.first.recentEvent?.occurredAt ?? items.first.updatedAt + : null; + + return AppLayout( + title: '재고 현황', + subtitle: '제품별 총 수량, 창고 잔량, 최근 변동 이벤트를 모니터링합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '재고 현황'), + ], + actions: const [ + ShadBadge.outline( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text('View Only'), + ), + ), + ], + toolbar: Column( + children: [ + _buildMetaSection( + total: result?.total ?? 0, + lastEventTime: lastEventTime, + lastRefreshedAt: _summaryController.lastRefreshedAt, + ), + const SizedBox(height: 16), + _buildFilterBar(), + ], + ), + child: Column( + children: [ + if (_summaryController.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _ErrorBanner( + message: _summaryController.errorMessage!, + onClose: _summaryController.clearError, + ), + ), + _buildTable(items, result), + ], + ), + ); + } + + Widget _buildMetaSection({ + required int total, + DateTime? lastEventTime, + DateTime? lastRefreshedAt, + }) { + final lastEventLabel = lastEventTime != null + ? _formatDateTime(lastEventTime) + : '정보 없음'; + final refreshLabel = lastRefreshedAt != null + ? _formatDateTime(lastRefreshedAt) + : '정보 없음'; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _SummaryCard( + icon: lucide.LucideIcons.box, + label: '총 제품 수', + value: total.toString(), + ), + _SummaryCard( + icon: lucide.LucideIcons.activity, + label: '최근 이벤트 기준', + value: lastEventLabel, + ), + _AutoRefreshCard( + enabled: _autoRefreshEnabled, + lastRefreshedAt: refreshLabel, + interval: _autoRefreshInterval, + onChanged: _toggleAutoRefresh, + ), + ], + ); + } + + Widget _buildFilterBar() { + return FilterBar( + title: '검색 및 필터', + actionConfig: FilterBarActionConfig( + onApply: _applyFilters, + onReset: _resetFilters, + hasPendingChanges: _hasPendingChanges, + hasActiveFilters: _hasActiveFilters, + applyEnabled: _hasPendingChanges, + resetEnabled: _hasActiveFilters || _hasPendingChanges, + applyKey: const Key('inventory_filter_apply'), + resetKey: const Key('inventory_filter_reset'), + ), + children: [ + SizedBox( + width: 280, + child: ShadInput( + key: const Key('inventory_filter_query_field'), + controller: _searchController, + placeholder: const Text('제품명, 코드 검색'), + onChanged: (value) => setState(() => _pendingQuery = value), + leading: const Icon(lucide.LucideIcons.search, size: 16), + ), + ), + SizedBox( + width: 220, + child: ShadInput( + controller: _productController, + placeholder: const Text('정확 제품명'), + onChanged: (value) => setState( + () => _pendingProductName = value.trim().isEmpty ? null : value, + ), + leading: const Icon(lucide.LucideIcons.box, size: 16), + ), + ), + SizedBox( + width: 220, + child: ShadInput( + controller: _vendorController, + placeholder: const Text('벤더명'), + onChanged: (value) => setState( + () => _pendingVendorName = value.trim().isEmpty ? null : value, + ), + leading: const Icon(lucide.LucideIcons.factory, size: 16), + ), + ), + SizedBox( + width: 240, + child: InventoryWarehouseSelectField( + key: ValueKey(_pendingWarehouseId ?? -1), + includeAllOption: true, + initialWarehouseId: _pendingWarehouseId, + placeholder: const Text('창고 선택'), + onChanged: (option) { + setState(() { + _pendingWarehouseId = option == null || option.id == -1 + ? null + : option.id; + }); + }, + ), + ), + SizedBox( + width: 200, + child: SuperportDatePickerButton( + value: _pendingUpdatedSince, + placeholder: '업데이트 기준일', + onChanged: (date) { + setState(() { + _pendingUpdatedSince = DateTime( + date.year, + date.month, + date.day, + ); + }); + }, + ), + ), + SuperportSwitchField( + label: '0 수량 포함', + value: _pendingIncludeEmpty, + onChanged: (value) => setState(() => _pendingIncludeEmpty = value), + caption: '0개 창고도 함께 조회합니다.', + ), + ], + ); + } + + Widget _buildTable( + List items, + PaginatedResult? result, + ) { + final rows = >[]; + for (var index = 0; index < items.length; index++) { + rows.add(_buildRow(index, items[index])); + } + final pagination = result == null + ? null + : SuperportTablePagination( + currentPage: result.page, + totalPages: result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999), + totalItems: result.total, + pageSize: result.pageSize == 0 + ? InventorySummaryController.defaultPageSize + : result.pageSize, + pageSizeOptions: const [20, 50, 100], + ); + + return ShadCard( + padding: const EdgeInsets.all(0), + child: SizedBox( + width: double.infinity, + child: SuperportTable.fromCells( + rowHeight: widget.debugRowHeight ?? 72, + header: const [ + ShadTableCell.header(child: Text('#')), + ShadTableCell.header(child: Text('제품명 / 코드')), + ShadTableCell.header(child: Text('벤더')), + ShadTableCell.header(child: Text('총 수량')), + ShadTableCell.header(child: Text('최근 변동')), + ShadTableCell.header(child: Text('업데이트')), + ], + rows: rows, + columnSpanExtent: _columnSpanForIndex, + sortableColumns: _columnSortKeys.keys.toSet(), + sortState: _sortState, + onSortChanged: _handleSortChanged, + pagination: pagination, + onPageChange: (page) => _summaryController.fetch(page: page), + onPageSizeChange: (pageSize) { + _summaryController.updatePageSize(pageSize); + _summaryController.fetch(page: 1); + }, + onRowTap: (index) => _openDetail(items[index]), + isLoading: _summaryController.isLoading, + emptyLabel: _summaryController.isLoading + ? '재고 데이터를 불러오는 중입니다...' + : '조건에 맞는 재고 데이터가 없습니다.', + ), + ), + ); + } + + List _buildRow(int index, InventorySummary summary) { + final theme = ShadTheme.of(context); + final totalQuantity = summary.totalQuantity; + final displayTotal = intl.NumberFormat.decimalPattern().format( + totalQuantity, + ); + final totalColor = totalQuantity < 0 + ? theme.colorScheme.destructive + : theme.colorScheme.foreground; + final rowNumber = + ((_summaryController.page - 1) * _summaryController.pageSize) + + index + + 1; + + return [ + ShadTableCell(child: Text('$rowNumber')), + ShadTableCell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + summary.product.name, + style: ShadTheme.of( + context, + ).textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + Text(summary.product.code, style: theme.textTheme.muted), + ], + ), + ), + ShadTableCell(child: Text(summary.product.vendor?.name ?? '-')), + ShadTableCell( + child: Text( + displayTotal, + style: theme.textTheme.small.copyWith(color: totalColor), + ), + ), + ShadTableCell(child: _RecentEventCell(event: summary.recentEvent)), + ShadTableCell(child: Text(_formatDateTime(summary.updatedAt))), + ]; + } + + Future _openDetail(InventorySummary summary) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => + InventoryDetailSheet(controller: _detailController, summary: summary), + ); + } +} + +TableSpanExtent _columnSpanForIndex(int index) { + final width = _inventorySummaryColumnWidths[index]; + return FixedTableSpanExtent(width ?? 160); +} + +bool _isSameDay(DateTime? a, DateTime? b) { + if (a == null && b == null) { + return true; + } + if (a == null || b == null) { + return false; + } + return a.year == b.year && a.month == b.month && a.day == b.day; +} + +String _formatDateTime(DateTime? value) { + if (value == null) { + return '-'; + } + return intl.DateFormat('yyyy-MM-dd HH:mm').format(value.toLocal()); +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.muted), + Text(value, style: theme.textTheme.h3), + ], + ), + ], + ), + ), + ); + } +} + +class _AutoRefreshCard extends StatelessWidget { + const _AutoRefreshCard({ + required this.enabled, + required this.lastRefreshedAt, + required this.interval, + required this.onChanged, + }); + + final bool enabled; + final String lastRefreshedAt; + final Duration interval; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final intervalLabel = '${interval.inSeconds}초마다 자동 새로고침'; + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('자동 새로고침', style: theme.textTheme.muted), + const SizedBox(height: 4), + Text( + '마지막 리프레시: $lastRefreshedAt', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text(intervalLabel, style: theme.textTheme.muted), + ], + ), + ), + Semantics( + label: '자동 새로고침 전환', + value: enabled ? '사용 중' : '중지됨', + toggled: enabled, + child: ShadSwitch(value: enabled, onChanged: onChanged), + ), + ], + ), + ), + ); + } +} + +class _WarehouseChips extends StatelessWidget { + const _WarehouseChips({required this.balances}); + + final List balances; + + @override + Widget build(BuildContext context) { + if (balances.isEmpty) { + return const Text('-'); + } + return Wrap( + spacing: 6, + runSpacing: 6, + children: balances.take(3).map((balance) { + final label = '${balance.warehouse.name} · ${balance.quantity}'; + return ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ); + }).toList(), + ); + } +} + +class _WarehouseBalanceChart extends StatelessWidget { + const _WarehouseBalanceChart({required this.balances}); + + final List balances; + + @override + Widget build(BuildContext context) { + if (balances.isEmpty) { + return const Text('창고 잔량 데이터가 없습니다.'); + } + final theme = ShadTheme.of(context); + final maxQuantity = balances + .map((balance) => balance.quantity.abs()) + .fold(0, (max, value) => value > max ? value : max); + final safeMax = maxQuantity == 0 ? 1 : maxQuantity; + return Column( + children: balances.map((balance) { + final normalized = balance.quantity.abs() / safeMax; + final ratio = normalized.clamp(0, 1).toDouble(); + final barColor = balance.quantity < 0 + ? theme.colorScheme.destructive + : theme.colorScheme.primary; + final semanticsLabel = '${balance.warehouse.name} 잔량'; + final semanticsValue = '${balance.quantity}개'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MergeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(balance.warehouse.name, style: theme.textTheme.small), + Text('${balance.quantity}개', style: theme.textTheme.small), + ], + ), + ), + const SizedBox(height: 4), + Semantics( + label: semanticsLabel, + value: semanticsValue, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: ratio, + minHeight: 8, + backgroundColor: theme.colorScheme.mutedForeground + .withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(barColor), + ), + ), + ), + ], + ), + ); + }).toList(), + ); + } +} + +class _RecentEventCell extends StatelessWidget { + const _RecentEventCell({required this.event}); + + final InventoryEvent? event; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (event == null) { + return Semantics( + label: '최근 이벤트 없음', + child: Text('최근 이벤트 없음', style: theme.textTheme.muted), + ); + } + final delta = event!.deltaQuantity; + final deltaText = delta > 0 ? '+$delta' : '$delta'; + final deltaColor = delta > 0 + ? theme.colorScheme.primary + : delta < 0 + ? theme.colorScheme.destructive + : theme.colorScheme.foreground; + final icon = _eventIcon(event!.eventKind); + final occurredLabel = _formatDateTime(event!.occurredAt); + final changeDescriptor = delta > 0 + ? '증가' + : delta < 0 + ? '감소' + : '변화 없음'; + final counterpartyName = event!.counterparty?.name; + final semanticsValue = StringBuffer() + ..write('수량 ${delta.abs()}개 $changeDescriptor, 발생 $occurredLabel'); + if (counterpartyName != null) { + semanticsValue.write(', 거래처 $counterpartyName'); + } + return Semantics( + label: '최근 이벤트 ${event!.eventLabel}', + value: semanticsValue.toString(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(icon, size: 14, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + event!.eventLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Text( + deltaText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: deltaColor), + ), + ], + ), + const SizedBox(height: 4), + Text( + occurredLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.muted, + ), + if (counterpartyName != null) ...[ + const SizedBox(height: 2), + Text( + '거래처: $counterpartyName', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.muted, + ), + ], + ], + ), + ); + } +} + +class InventoryDetailSheet extends StatefulWidget { + const InventoryDetailSheet({ + super.key, + required this.controller, + required this.summary, + }); + + final InventoryDetailController controller; + final InventorySummary summary; + + @override + State createState() => _InventoryDetailSheetState(); +} + +class _InventoryDetailSheetState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.controller.fetch(widget.summary.product.id, force: true); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final productId = widget.summary.product.id; + final detail = widget.controller.detailOf(productId); + final filter = widget.controller.filterOf(productId); + final isLoading = widget.controller.isLoading(productId); + final error = widget.controller.errorOf(productId); + final theme = ShadTheme.of(context); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + maxChildSize: 0.95, + builder: (context, scrollController) { + final scrollBehavior = ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false); + final scrollbarTheme = ScrollbarTheme.of(context); + final resolvedThickness = + scrollbarTheme.thickness?.resolve({WidgetState.hovered}) ?? + scrollbarTheme.thickness?.resolve(const {}) ?? + 6.0; + + return ShadCard( + child: ScrollConfiguration( + behavior: scrollBehavior, + child: Scrollbar( + controller: scrollController, + thickness: resolvedThickness, + radius: scrollbarTheme.radius ?? const Radius.circular(999), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.summary.product.name, + style: theme.textTheme.h3, + ), + Text( + widget.summary.product.code, + style: theme.textTheme.muted, + ), + ], + ), + IconButton( + icon: const Icon(lucide.LucideIcons.x), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _SummaryCard( + icon: lucide.LucideIcons.box, + label: '총 수량', + value: '${widget.summary.totalQuantity}', + ), + _SummaryCard( + icon: lucide.LucideIcons.refreshCw, + label: '뷰 리프레시', + value: _formatDateTime(detail?.lastRefreshedAt), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + SizedBox( + width: 180, + child: ShadSelect( + key: ValueKey(filter.eventLimit), + initialValue: filter.eventLimit, + selectedOptionBuilder: (context, value) => + Text('$value건'), + onChanged: (value) { + if (value != null) { + widget.controller.updateEventLimit( + productId, + value, + ); + } + }, + options: [10, 20, 50, 100] + .map( + (limit) => ShadOption( + value: limit, + child: Text('$limit건'), + ), + ) + .toList(), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 220, + child: InventoryWarehouseSelectField( + includeAllOption: true, + initialWarehouseId: filter.warehouseId, + onChanged: (option) { + widget.controller.updateWarehouseFilter( + productId, + option == null || option.id == -1 + ? null + : option.id, + ); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + if (isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _ErrorBanner( + message: error, + onClose: () => + widget.controller.clearError(productId), + ), + ), + if (detail != null) ...[ + Text('창고 잔량', style: theme.textTheme.h3), + const SizedBox(height: 8), + _WarehouseBalanceChart( + balances: detail.warehouseBalances, + ), + const SizedBox(height: 12), + _WarehouseChips(balances: detail.warehouseBalances), + const SizedBox(height: 16), + Text('최근 이벤트', style: theme.textTheme.h3), + const SizedBox(height: 8), + if (detail.recentEvents.isEmpty) + Text( + '최근 이벤트가 없습니다.', + style: theme.textTheme.muted, + ) + else + Column( + children: detail.recentEvents.map((event) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + child: _RecentEventCell(event: event), + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ); + }, + ); + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message, required this.onClose}); + + final String message; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + lucide.LucideIcons.info, + size: 18, + color: theme.colorScheme.destructive, + ), + const SizedBox(width: 12), + Expanded(child: Text(message, style: theme.textTheme.small)), + ShadButton.ghost(onPressed: onClose, child: const Text('닫기')), + ], + ), + ), + ); + } +} + +IconData _eventIcon(String kind) { + switch (kind) { + case 'receipt': + return lucide.LucideIcons.packagePlus; + case 'issue': + return lucide.LucideIcons.packageMinus; + case 'rental_out': + return lucide.LucideIcons.share2; + case 'rental_return': + return lucide.LucideIcons.undo2; + default: + return lucide.LucideIcons.clock3; + } +} diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 1c7e1c9..550d558 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -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 { } Future _applyPermissions(AuthSession session) async { - final manager = PermissionScope.of(context); - manager.clearServerPermissions(); - - final aggregated = >{}; - for (final permission in session.permissions) { - final map = permission.toPermissionMap(); - for (final entry in map.entries) { - aggregated - .putIfAbsent(entry.key, () => {}) - .addAll(entry.value); - } - } - if (aggregated.isNotEmpty) { - manager.applyServerPermissions(aggregated); - return; - } - - await _synchronizePermissions(groupId: session.user.primaryGroupId); - } - - Future _synchronizePermissions({int? groupId}) async { - final manager = PermissionScope.of(context); - manager.clearServerPermissions(); - - final groupRepository = GetIt.I(); - 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(); - final synchronizer = PermissionSynchronizer( - repository: permissionRepository, - manager: manager, + final bootstrapper = PermissionBootstrapper( + manager: PermissionScope.of(context), + groupRepository: GetIt.I(), + groupPermissionRepository: GetIt.I(), ); - await synchronizer.syncForGroup(targetGroupId); - } - - Group? _firstGroupWithId(List groups) { - for (final group in groups) { - if (group.id != null) { - return group; - } - } - return null; + await bootstrapper.apply(session); } } diff --git a/lib/features/masters/group_permission/application/permission_synchronizer.dart b/lib/features/masters/group_permission/application/permission_synchronizer.dart index 1e83db9..f66e2bf 100644 --- a/lib/features/masters/group_permission/application/permission_synchronizer.dart +++ b/lib/features/masters/group_permission/application/permission_synchronizer.dart @@ -19,6 +19,22 @@ class PermissionSynchronizer { /// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다. Future syncForGroup(int groupId) async { + final permissionMap = await fetchPermissionMap(groupId); + _manager.applyServerPermissions(permissionMap); + } + + /// 지정한 [groupId]의 메뉴 권한을 조회해 맵 형태로 반환한다. + Future>> fetchPermissionMap( + int groupId, + ) async { + final collected = await _collectPermissions(groupId); + if (collected.isEmpty) { + return const {}; + } + return buildPermissionMap(collected); + } + + Future> _collectPermissions(int groupId) async { final collected = []; var page = 1; @@ -45,7 +61,6 @@ class PermissionSynchronizer { page += 1; } - final permissionMap = buildPermissionMap(collected); - _manager.applyServerPermissions(permissionMap); + return collected; } } diff --git a/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart b/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart index f5d2faf..b1c4902 100644 --- a/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart +++ b/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart @@ -35,10 +35,8 @@ class UserDetailDialogResult { } typedef UserCreateCallback = Future Function(UserInput input); -typedef UserUpdateCallback = Future Function( - int id, - UserInput input, -); +typedef UserUpdateCallback = + Future Function(int id, UserInput input); typedef UserDeleteCallback = Future Function(int id); typedef UserRestoreCallback = Future Function(int id); typedef UserResetPasswordCallback = Future Function(int id); @@ -141,10 +139,8 @@ Future 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 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 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(user?.group?.id); _isActiveNotifier = ValueNotifier(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!), ], ), ), diff --git a/lib/injection_container.dart b/lib/injection_container.dart index f73d249..69ece64 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -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( + () => InventoryRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => InventoryService(repository: sl()), + ) ..registerLazySingleton( () => InventoryLookupRepositoryRemote(apiClient: sl()), ) diff --git a/lib/main.dart b/lib/main.dart index e61a0c1..9bedb6c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { GetIt.I.unregister(); } GetIt.I.registerSingleton(_permissionManager); + unawaited(_restorePermissions()); } @override @@ -90,4 +96,18 @@ class _SuperportAppState extends State { ), ); } + + Future _restorePermissions() async { + final authService = GetIt.I(); + final session = authService.session; + if (session == null) { + return; + } + final bootstrapper = PermissionBootstrapper( + manager: _permissionManager, + groupRepository: GetIt.I(), + groupPermissionRepository: GetIt.I(), + ); + await bootstrapper.apply(session); + } } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index fd62c11..a49547c 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -35,7 +35,7 @@ class AppShell extends StatelessWidget { final filteredPages = [ 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 pages) { return prefix == -1 ? 0 : prefix; } +bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) { + final requirements = {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}); diff --git a/pubspec.lock b/pubspec.lock index 4ddf89f..54842dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" args: dependency: transitive description: @@ -33,6 +49,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + build: + dependency: transitive + description: + name: build + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3 + url: "https://pub.dev" + source: hosted + version: "2.10.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" characters: dependency: transitive description: @@ -41,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -49,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" collection: dependency: transitive description: @@ -57,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -73,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" dio: dependency: "direct main" description: @@ -129,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -255,6 +359,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.7.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" go_router: dependency: "direct main" description: @@ -263,6 +375,14 @@ packages: url: "https://pub.dev" source: hosted version: "16.2.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: transitive description: @@ -279,6 +399,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -300,6 +428,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" js: dependency: transitive description: @@ -308,6 +444,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + url: "https://pub.dev" + source: hosted + version: "6.11.1" leak_tracker: dependency: transitive description: @@ -396,6 +548,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -484,6 +644,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" pretty_dio_logger: dependency: "direct dev" description: @@ -500,6 +668,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" shadcn_ui: dependency: "direct main" description: @@ -508,11 +692,43 @@ packages: url: "https://pub.dev" source: hosted version: "0.31.7" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" source_span: dependency: transitive description: @@ -537,6 +753,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -697,6 +921,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" web: dependency: "direct main" description: @@ -705,6 +937,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webdriver: dependency: transitive description: @@ -737,6 +985,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.9.2 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 84363fb..3cc862d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/core/permissions/permission_manager_test.dart b/test/core/permissions/permission_manager_test.dart index 88cdbd1..f2e1a60 100644 --- a/test/core/permissions/permission_manager_test.dart +++ b/test/core/permissions/permission_manager_test.dart @@ -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, + ); + }); }); } diff --git a/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart b/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart index d71f910..fea7551 100644 --- a/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart +++ b/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart @@ -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 { diff --git a/test/features/auth/data/dtos/auth_session_dto_test.dart b/test/features/auth/data/dtos/auth_session_dto_test.dart new file mode 100644 index 0000000..52f487e --- /dev/null +++ b/test/features/auth/data/dtos/auth_session_dto_test.dart @@ -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', + }), + ); + }); + }); +} diff --git a/test/features/auth/domain/entities/auth_permission_test.dart b/test/features/auth/domain/entities/auth_permission_test.dart index 9837360..3e704c7 100644 --- a/test/features/auth/domain/entities/auth_permission_test.dart +++ b/test/features/auth/domain/entities/auth_permission_test.dart @@ -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); }); }); } diff --git a/test/features/inventory/summary/presentation/controllers/fake_inventory_repository.dart b/test/features/inventory/summary/presentation/controllers/fake_inventory_repository.dart new file mode 100644 index 0000000..b7c8d82 --- /dev/null +++ b/test/features/inventory/summary/presentation/controllers/fake_inventory_repository.dart @@ -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 fetchDetail( + int productId, { + InventoryDetailFilter? filter, + }) async { + lastDetailFilter = filter; + if (detailError != null) { + throw detailError!; + } + return detailResult ?? buildDetail(productId); + } + + @override + Future 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( + 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), + ); +} diff --git a/test/features/inventory/summary/presentation/controllers/inventory_detail_controller_test.dart b/test/features/inventory/summary/presentation/controllers/inventory_detail_controller_test.dart new file mode 100644 index 0000000..c679222 --- /dev/null +++ b/test/features/inventory/summary/presentation/controllers/inventory_detail_controller_test.dart @@ -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); + }); + }); +} diff --git a/test/features/inventory/summary/presentation/controllers/inventory_summary_controller_test.dart b/test/features/inventory/summary/presentation/controllers/inventory_summary_controller_test.dart new file mode 100644 index 0000000..e6ae6cf --- /dev/null +++ b/test/features/inventory/summary/presentation/controllers/inventory_summary_controller_test.dart @@ -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); + }); + }); +} diff --git a/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_isolatedDiff.png b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_isolatedDiff.png new file mode 100644 index 0000000..c3a423e Binary files /dev/null and b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_isolatedDiff.png differ diff --git a/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_maskedDiff.png b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_maskedDiff.png new file mode 100644 index 0000000..736db7e Binary files /dev/null and b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_maskedDiff.png differ diff --git a/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_masterImage.png b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_masterImage.png new file mode 100644 index 0000000..ad00885 Binary files /dev/null and b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_masterImage.png differ diff --git a/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_testImage.png b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_testImage.png new file mode 100644 index 0000000..8600859 Binary files /dev/null and b/test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_testImage.png differ diff --git a/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png new file mode 100644 index 0000000..ad00885 Binary files /dev/null and b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png differ diff --git a/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png new file mode 100644 index 0000000..ad00885 Binary files /dev/null and b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png differ diff --git a/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart b/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart new file mode 100644 index 0000000..e502af7 --- /dev/null +++ b/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart @@ -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( + 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(repository: inventoryRepository), + ); + GetIt.I.registerSingleton(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 []; + return PaginatedResult( + items: items, + page: page, + pageSize: + invocation.namedArguments[const Symbol('pageSize')] as int? ?? 20, + total: items.length, + ); + }); +} + +Future _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'), + ); + }); +} diff --git a/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart b/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart new file mode 100644 index 0000000..e07e226 --- /dev/null +++ b/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart @@ -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(repository: inventoryRepository), + ); + GetIt.I.registerSingleton(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 []; + return PaginatedResult( + 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( + 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 = []; + + 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( + 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 = []; + + 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'); + }); +} diff --git a/test/features/masters/group_permission/application/permission_synchronizer_test.dart b/test/features/masters/group_permission/application/permission_synchronizer_test.dart index e74cc85..640cb75 100644 --- a/test/features/masters/group_permission/application/permission_synchronizer_test.dart +++ b/test/features/masters/group_permission/application/permission_synchronizer_test.dart @@ -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( + items: [permissionPage1], + page: 1, + pageSize: 1, + total: 2, + ); + } return PaginatedResult( - items: [permissionPage1], - page: 1, + items: [permissionPage2], + page: 2, pageSize: 1, total: 2, ); - } - return PaginatedResult( - 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( + 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), + ); + }); }); } diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart index 436ad6e..9764e58 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -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();