From 47cc62a33d0ebd2558f4806a0869eb0b16e90356 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Sun, 9 Nov 2025 01:13:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EC=9A=94=EC=95=BD/=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=EB=A5=BC=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/features/inventory/summary 계층과 warehouse select 위젯을 추가해 목록/상세, 자동 새로고침, 필터, 상세 시트를 구현 - PermissionBootstrapper, scope 파서, 라우트 가드로 inventory.view 기반 권한 부여와 메뉴 노출을 통합(lib/core, lib/main.dart 등) - Inventory Summary API/QA/Audit 문서와 PR 템플릿, CHANGELOG를 신규 스펙과 검증 커맨드로 업데이트 - DTO 직렬화 의존성을 추가하고 Golden·Widget·단위 테스트를 작성했으며 flutter analyze / flutter test --coverage를 통과 --- .github/PULL_REQUEST_TEMPLATE.md | 15 + CHANGELOG.md | 7 + README.md | 2 + doc/API_CLIENT_SPEC.md | 94 ++ doc/detail_dialog_unification_plan.md | 10 + doc/frontend_backend_alignment_report.md | 9 + doc/inventory_management_feature_plan.md | 103 ++ doc/inventory_summary_audit_plan.md | 37 + .../inventory_summary_e2e_checklist.md | 40 + doc/qa/inventory_data_replay.md | 45 + doc/stock_approval_system_api_v4.md | 1375 +++++++++-------- doc/stock_approval_system_spec_v4.md | 63 +- lib/core/constants/app_sections.dart | 19 + lib/core/network/api_routes.dart | 10 + .../permissions/permission_bootstrapper.dart | 118 ++ lib/core/permissions/permission_manager.dart | 4 + .../permissions/permission_resources.dart | 35 +- lib/core/routing/app_router.dart | 17 + .../dialogs/approval_detail_dialog.dart | 5 +- .../utils/approval_form_initializer.dart | 6 +- .../widgets/approval_step_configurator.dart | 1 - .../dtos/approval_approver_candidate_dto.dart | 7 +- .../auth/data/dtos/auth_session_dto.dart | 90 +- .../auth/domain/entities/auth_permission.dart | 4 + .../application/inventory_service.dart | 27 + .../data/dtos/inventory_common_dtos.dart | 249 +++ .../data/dtos/inventory_common_dtos.g.dart | 150 ++ .../data/dtos/inventory_detail_response.dart | 84 + .../dtos/inventory_detail_response.g.dart | 59 + .../data/dtos/inventory_summary_response.dart | 112 ++ .../dtos/inventory_summary_response.g.dart | 77 + .../inventory_repository_remote.dart | 48 + .../entities/inventory_counterparty.dart | 10 + .../domain/entities/inventory_detail.dart | 22 + .../domain/entities/inventory_event.dart | 45 + .../domain/entities/inventory_filters.dart | 80 + .../domain/entities/inventory_product.dart | 23 + .../domain/entities/inventory_summary.dart | 22 + .../inventory_summary_list_result.dart | 24 + .../inventory_transaction_reference.dart | 23 + .../domain/entities/inventory_vendor.dart | 10 + .../domain/entities/inventory_warehouse.dart | 17 + .../entities/inventory_warehouse_balance.dart | 15 + .../repositories/inventory_repository.dart | 17 + .../inventory_detail_controller.dart | 137 ++ .../inventory_summary_controller.dart | 178 +++ .../pages/inventory_summary_page.dart | 1049 +++++++++++++ .../login/presentation/pages/login_page.dart | 68 +- .../application/permission_synchronizer.dart | 19 +- .../dialogs/user_detail_dialog.dart | 45 +- lib/injection_container.dart | 9 + lib/main.dart | 20 + lib/widgets/app_shell.dart | 15 +- pubspec.lock | 256 +++ pubspec.yaml | 3 + .../permissions/permission_manager_test.dart | 25 + .../approval_history_detail_dialog_test.dart | 251 ++- .../auth/data/dtos/auth_session_dto_test.dart | 71 + .../domain/entities/auth_permission_test.dart | 32 +- .../fake_inventory_repository.dart | 97 ++ .../inventory_detail_controller_test.dart | 59 + .../inventory_summary_controller_test.dart | 61 + ...tory_summary_page_default_isolatedDiff.png | Bin 0 -> 16596 bytes ...entory_summary_page_default_maskedDiff.png | Bin 0 -> 30798 bytes ...ntory_summary_page_default_masterImage.png | Bin 0 -> 29554 bytes ...ventory_summary_page_default_testImage.png | Bin 0 -> 23764 bytes .../inventory_summary_detail_sheet.png | Bin 0 -> 29554 bytes .../inventory_summary_page_default.png | Bin 0 -> 29554 bytes .../inventory_summary_page_golden_test.dart | 204 +++ .../pages/inventory_summary_page_test.dart | 409 +++++ .../permission_synchronizer_test.dart | 225 ++- .../presentation/pages/user_page_test.dart | 11 +- 72 files changed, 5453 insertions(+), 1021 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 doc/inventory_management_feature_plan.md create mode 100644 doc/inventory_summary_audit_plan.md create mode 100644 doc/qa/inventory/inventory_summary_e2e_checklist.md create mode 100644 doc/qa/inventory_data_replay.md create mode 100644 lib/core/permissions/permission_bootstrapper.dart create mode 100644 lib/features/inventory/summary/application/inventory_service.dart create mode 100644 lib/features/inventory/summary/data/dtos/inventory_common_dtos.dart create mode 100644 lib/features/inventory/summary/data/dtos/inventory_common_dtos.g.dart create mode 100644 lib/features/inventory/summary/data/dtos/inventory_detail_response.dart create mode 100644 lib/features/inventory/summary/data/dtos/inventory_detail_response.g.dart create mode 100644 lib/features/inventory/summary/data/dtos/inventory_summary_response.dart create mode 100644 lib/features/inventory/summary/data/dtos/inventory_summary_response.g.dart create mode 100644 lib/features/inventory/summary/data/repositories/inventory_repository_remote.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_counterparty.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_detail.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_event.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_filters.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_product.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_summary.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_summary_list_result.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_transaction_reference.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_vendor.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_warehouse.dart create mode 100644 lib/features/inventory/summary/domain/entities/inventory_warehouse_balance.dart create mode 100644 lib/features/inventory/summary/domain/repositories/inventory_repository.dart create mode 100644 lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart create mode 100644 lib/features/inventory/summary/presentation/controllers/inventory_summary_controller.dart create mode 100644 lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart create mode 100644 test/features/auth/data/dtos/auth_session_dto_test.dart create mode 100644 test/features/inventory/summary/presentation/controllers/fake_inventory_repository.dart create mode 100644 test/features/inventory/summary/presentation/controllers/inventory_detail_controller_test.dart create mode 100644 test/features/inventory/summary/presentation/controllers/inventory_summary_controller_test.dart create mode 100644 test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_isolatedDiff.png create mode 100644 test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_maskedDiff.png create mode 100644 test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_masterImage.png create mode 100644 test/features/inventory/summary/presentation/pages/failures/inventory_summary_page_default_testImage.png create mode 100644 test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png create mode 100644 test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png create mode 100644 test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart create mode 100644 test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart 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 0000000000000000000000000000000000000000..c3a423e37ec12195e944ee59b5a25911296f1b54 GIT binary patch literal 16596 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..736db7e1e69c2e3ea1607dbf2f3930f30bbe0f1b GIT binary patch literal 30798 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
    @o;7lS(GS6H>2_wTpJ-Aaom4JFyVLtG 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8600859c3aac2bec296b85ccb52e9ecd05116fd7 GIT binary patch literal 23764 zcmeIacR*9=wl};H6~_jM0)iA}6cweYbV3*h!3v`E5)c%m2m;a*f;uYFVgsZ*AiYGu z&;uyF4wUP%#JYVD}bbfAnA^f0ZkG9Ev0WJRcpF+3Y-1=JPblYVgFPsUOpd@t+__>6Qj*r`|4J# zK1x5Cm3O`7lr)(2!-J$BE>y4IVX5!I-DZSEtg78CH(MQpGU4! z`7rSyds?8ZNnDcM((>5+e?=n%a;In}qqUc@?uDy&wnnT_=!}{{pW5%_;EwhPi4ww! ziv3brYp->1U;+U;yg2S#6Bl@>uri#ke%eBm#fqRa)XjhMP~sEkxjf$D5|*-j+xpR# zcZcscal2EWpyx}7NLox%$V)wy8y~zV&;_k^sx-96eKc?iR|1Xl2+q zo@{tjgPd)7R9gWmoP^~IVpINN)Z-7?-Q(2hf=Pz2)~^;^Jbdxd*}Wd6fhb)e#lgAs zkGV1aki^$9PtR&fv^0+kpNw2ni{q>=D9;b>uQtG+?+ULNP5FiUk)`bRI@_?lRm!e? z$`CPdU}ID``A*S?)dMVR4FEshN$U*OJtA%Q)-Xg7Q8C?SV|rRiP%8DB#$v62Lig?E zTU(O=E*7PYK~}iVf!KubZ~=iYJMvcZ@~<7!39ai>hluf$5J`KQ%&obfalhml`!c64 zXi1r}L!Hbb=0O6~X3CvA63>z6o&+X1r=vT+e*K!f##SJ#qNmFGLZnIS;pmpSG7_op zwBo)_Jl|V1SL3)urJ5{rp$9^w^(G-5Is#TMRkf2N-BhaN5UkY%jSMI&?h}C zO?)ht0D`kubBm@BP;cU}XaoH-uh*TlK;H!mYgAjus0!LHdVtz~i`-{An-F_Z4xoa% zVqdiKRQdpPl8BBseeO6KDS)L_7EE@bJt3r@@Tce4qP8;%l$18(>`^=UpFH?kQqbvi zP9bSdRt&OB3r3qM4Ct3Vbm)k2&eSEg+>3+Ok7WTnXl$<14V;xyERQY+?0jsPc{SL- zFvbtC6}ZP)@7oaB`pqp0!_8`>{wrQE}+_0Kj zPd1F!iSy{d2T5nhGXmP*7@!S&wA~m)`1~e5!IWMX9kY!w-W(I!ZXgi!%fW9FB)M;i zdi3is%>K&&zd_?P`F?%?o35+%#NgUh|429mX@Vb%d z=|I$nph0yAHUoW$@Y9BVMTGAS&9Auj6mtE#=64?{j+nuG+Alx9&M-{wdWe7~wr|+^ z69bo1_-SeBc0G;f!%o^WUBQG(wp{|5WYrb#sc4|2psLoKVG{459R zt((*F?NqovWnJaPH;vBfB`6qWpG)Zpx%M;P_}POOpCI>n*gk)CW^gV;InKaMDaCuW z>JeNs%H+60*Asn&hL!%NXFLpgX9*)JcS&uf!o=;VK*UI2H0Vn&RXysxqvm7uH5Vg_A~yD?v;7vm{E1A4g0XeX;G1{_p1o9Wx{%||`$DnQBU?)WO5 z`m6=A&lRCOb$o)yjz&tXm80#};2)ev-i0-|`jImAha0E8K%bvFdX~_OHd7_yQHE}3 z)G8IC)(G=nugmkUPaFtid39>4ISviA1`otkErbf+yF_IfGRXEzSM{MtkH!2re1&IG zE47boJtRVbyhli~)6C%9sM(wf|&M z@RO}6VCd3?#(8=>nYO!`PRxFK7oVic!+Pown2BQ_D?ubG5Sv)Y8nosVdx@>8!mOuDm$0nBQt~jUyte$xFn=pt=8b9P z6UxNJn3n86FF>J$^b7NwZo3OYKn6(52@I#WxP&hsm$BfKgJdQl|0*O zQm)C?iNdG_5zf=Q(cI7WAuB;@;8@{?ZG3jX`aat;0^jR$l=WYxk={pm47$lQCk!Ze zEaC2PrTA}dwA73a1;xJ(u36tQepG0?mDQ-%W&mr_I+! z0;W*@Df`JfGHDJ@2HC~7Y>1DkUnVkrg+eS_=NDzi%sHNPWU~7%j(Fa zs;H=!e>TVQ13S~k@sYRB#A$8;B}YE!6t9*@n|+v}%kJ;^`K2Olb(POs>H_j57wgV` z5(y7Pfdf|a7w~GC=VrXxVOD(q=c~u|CHK3^m9&F=f0%tuc9j*rRRhf$fTP67W|82d zdlx%rBms9ZuIkUn1=VR>JRsi+#v_gcz=><>Y_)o*^DicB|7ybj$MvN55xIQ3xOpKW zxY74su!clR*-7`0`J%Kg(3ahFyhumPt(U2@Gc!klpq;@8T2ipvFzs_i#IVO&W@se| z=U8!{cCp|S+c&+6>JHYCz z@iHtA9FFZ99@NLsdoXCQ%6|TO7(g+bwNX8xJVOb`Y@*jwP0}N0% zH=bS{w}uDgSeA~Np;YgFe!BV)weHwq|KJ8*jUCObodXtk%B~WjV!C1&h8e2A)U#N$ zc;N)7W#C7vYIXYwRs)b!-3SJL(^vtf3*Dd#HnYPN-!us9-aw{;n%WCT-l_B)bV|TH z78;?x0>&K8R@MPb26w(QB~fL3i?kPFPDO+Em`t$k$%+JDc^3)tYA)ZkFC_YY?F{y= zO}ip`+H91dR-9vv3l7FO8#jQlXmry+F_IqFdHc*Mvq8dIYe6Nfrpf_a*HjSjw!@X$ zWrBa;!!S>Sc)N~HJm$JUsPo!Y>$Wwd^VZxLkg z>#rr<0YMrMi>-a#!%*Q6g2MIgZb%%@G9JO7CSM2?JOtU zXn9S2XY$`UfAm_OEvSr`FQqV4=KJiDuO0J2%+YDQwRytI5bEy|KC8Up-5=;Mne6F)p`}<}3p0$7j$<@Acp(>zrm0&5-T) z3ML{;zz`s)$0_{izU_2zFk}dlVo`ZgGkQc=u2@)fa==a#bdI=VCnE9UiNg<({^gz4$b z*G^|r1m{dOjh|jD6&|-exabExsp}82=#yyM>s-aL&&w|0Qtax^f|fzf$qEctmi56I z{ZsaJ_n5c#iYkbdnkuP@q+ZJ$O#!uD{z>8%Nq+vq!a{@Gp5$IvU716%ox3eWo`Gp% zGGD25m4HTcXs3tj@yLrNz20J6TFAW~`i$X3RB-3ngF3o8$ydSrm}Pzwf6(WC794C} zxS#9~G0!!AZ+^Z>|8Eh2(KT!9~y7{AtEDFOE6&YS#} z9`~La-SB>IaDOI!_)pPvjwn7q$_t z^!l_(^PZ8zd@oO*1~bOGf_x9d=DMRoJDzt;`uB)~iw3RwbK`6=jLvC$=WrDjtI%8H z9+$(ymUECDo1P6n*?q3^S!j=dodKUeo4EP~h(7!##!XzF1%b?$Jx*U2`3c71JD_YT zo2SRVF6FzDeZMa1+i%4_^8>E277RmSWEu)xx3Fn;zEhNGU_E2O5ol0P9u^iVm>=sW zfWi)bqDszc3Z+C|1Ebfr3X=)0^C|sd&o3$vl-O^p`Ghm~cCT(e52nBuOP_hM@k797 zo8iSrw~Gd2C{64Tbch4by}8<`EAxnxI|Kg>=kcF9y#I~FA#`z@s~QWxq`1?sSWJ4& z`k@E+NFOM7H}I-CHTh11WfnlUac|SJnw2@^p-L~}!zDHEmg#g&ubRs4vKuj;rinsq z8ecW8M*csBnE@sQFnhn@2V)aSZk8N%o159^-uzUzgWA`tUQ~w|op!ZK_lNvC8K2S* zJZ$1?-*XTe;DyxRZd{+G01qd62aD_O_>8De+L3iq2wn*@k+q5Qnr^BhR z(O-zadCCH!3SHjWd7M&md)-*Tkz0A~B4TXV6@h+xo(L+2Qliq#bRfiP`6s@?eGQ!{M(lisHM@>GGDxC`_p|gQ9c-5Csyj8aO!KfkGCWT5bfZ6Ajr6IK>P1_0caIFe zV+X>GO6nU>a`mR+)kku5>arCoyTvtLHLXonGK}2Uc(L-Z<&P48|TT|k{=xW!tbojc%$rFSzhrk)AZuxM;D0Wtg zghMX*SS2~W?%zy*=LTPzFPWJ^brAY|st97F7eWcWEQqUxdh-4J7U=|G;(98jovBodVQ3iv7y>Qmr)8&pIHOM9RyL0d4A z2<=#(bKps4gIU->j!GrA%|mEoo=Hqw9IkD0iPVX*6tS~Q5ko5|DTyHAJv{hu=5#Kp zv;(Q&JwG6zd&d;lqx$4I@O4|m0qn7Mc>i?AYTuhrULanE#&xi9vFgH7#^bnC{S^Fi zbi5K80!?g*kqfvG&v?;2R-|6JUwO#avtrI+v@4SOIOfVP?6~~1>iXO$LiVMXwf|aZ zS*5XY;cn3lkE!vbym#U8byPLjLy&sm*tTNSjl=n#3()J@bg!5mSuE z4Psl}vYq3iCLvERQ6C&F$hCwx$Xnk6bBEz@VxpqqaSRd~&%>x`3N@ch45$*DjHg9q z7(0yR4KmygV5wBAIE5X9K3D3~p1Kg1`nWmHF0rObS{#Q#XZe>+Pdio6KT>pZVMO0> zbF4U&vQ#C~Gjq9uG=$p;U+t9H$TD9);jLuiKzz^#F^r@a)5cyk8wv=NY^(sOcsyA8s*Gbf+npnPU8qjqM476N&QYDI3A%r#+#*=ssRZg_QVksZ3g0TKNEIh)yK>d|FePf87Qe;sim5sEB>-cucI@+}$U^LLpLG#&zL}eY82>K*KUhT#5uDo}^cCvt?*FK{s)05Ud?V5w( zyqe=58nC_WJHwMy;O*!i9XCzu({HFgZPQwF`Mu-b4)j-o zDz7X00WhkCQ2in)KXHYvEkQrze{+xTe#ODRCAsm3t9Z_*T84O9(+jrXz;rFxQhhu1 ztS*FY%YtT4fIP80UKfY_I?KjJ!TsEOj;>}SJ%2YO_&brx|AVhy`hkKyp4{)`FwD9e z1OURDz;t=B)N%p=rl;FWgZ)!tevR)x5!#O4OhVq2_%#(~9|rKa+6BvBXSt$&@8-yj zU$ft>oC;10EYCfhy-R%lBY? z`kZDG3a%-UdhOWuM`zt=6t#_TxY2y2Jd8aiu>5+%4E`xZ_Mf}l@W)VE+ncJ|H4TkPAGu2(%xKglqI97^^FiHc22AX z6Y{;seS34+0|jvv*^m2z&%pZAX=0=w;BnVmNk`^z0D}FxdXGutr0x4_Az%{AmNCaO zaSGRt+5aliU^zu332+4KSI2}3Tsb@0dH-T2Uz@l3k|tQynX8G5;~dl8?bO?;8__zj z9Q)sYX1`uYskYRl$8^*~M^^RS2gQ8q+7rrYkoFgMKY^J^Shuw>)MMTmFO6IW!JPKw z-$R=0#XoyCr!F3Dy@t6oHeum9M>%?Fz!`=xUjMSMN-R$K&g|O2M!-_Yy-{wg*79q3 zB(wv%wYsf%NQCG+89;;RG;BE7oncjL%U&c5lB!eH@cA8@zJ$Z0H4#=-4O1H~J?K6O z$zXWW#qUe)Bsd@92)&KX5Ir|w4YEmBxEH6*g1W%!L3JP2!MaNK^R}9`*u6AJwXF*c z7hpHpZe?ZW+!EtZ+t)WmSs#h6TEt}FoI+vchO!O)Xcf;+!iie>j(IP3@@JF(SPdxk zB2?ANRS+Ox$H|pDqdqC?1u}UdqEO^gGES4S3q4H@U%ZhxOkf+AYTI386B`T@8sH;g zXNC~>UN0pseVm__&2j%lw&)>V&Hdd=KB<(nKE<>n2QS_1*ULRqa?K*NUR&i{LHyAl zl2eVJ-BmcWchJKy^K(7FYwXtaNF(XY*tb7txx4V-_wK1a`TE4WcRR~Z7oMi%BCBmH zVjjhurVIsictRzPA!{j&cE5>npVu+Ok$}fF*HBR5R{`s%jQqT%(M=s{H{`-fq*S*6V2I$w2%lV8{nF_1 zX(Ie7fGDat|JnQ3Mb=(;~kqZ}>6?x}7x%{en5eJ$q4=sGlGQPIMGzQL_RF_0K) zJuDUxA<;VSwt62|=iCl?H$FLk&&t3krZ8(W=PJ%M|8-K0d$%+cJ8dUH7h-C( zt4{VvSw$n-v-q{xGo7DqN{^^@qpu(bmh+M8OGNcMl1IyAY4Jwala|>O;APb#L7dj~ zHc56vOKlU!d_9ZUSmxXA_+>!dAiJk>lF7tFOQly%%#CynxY3h%tVycmH6Il7tAKu; z1fx^J&96vGU#x%8v*qe|Kf;RTy@Cu@pBJy+U2kL3*V0fugmffAtJCY8(XIBZ%zJ=p zPX}WNDl^xIWRPiN5|)g@RI#edMu`wOr?c2mkxlhtd|9U)A`R7oc0T#z8QVy^mQk;} zcef>p43;H>ghobcyAsxnFQlw=6<^GXJ>D_DA*y;kTb;)lp0tLjX#D5?OQ{EF3^xeh z6JDso3p=ajqT>3GAJ-}CZpig?rc9ZrsJf;%cu^)4Id(@~v)$Y9n@qj73V_kmE}*K9 zfQw|FM^UB(THI|i;t83$kg6?gc zrli>KNNk)coR~2$#qVD?Bs(7pVxNt?VmtGTvY!8DJ%b+f(+$o4v??vJ{^a+BvdpGS zV=td@# z<2Hh#{c8x;Li+1f=sGv#m-$sW>$RHIW)tS2-4cNwU3P6hnN+PAk$QKG9Ei5|z5xGB zH@WN|FP6Eh_i@cB{e`|oj{_P3nXT8^D+l&1a`-gl{rTiO_M^YOb>JU!=f9sMU3Hqv z+i6@|xmWL=UCJPeVIx279u#u>6QR>p(e_5(z8#*q*V@0VZBDsSb=Ip7Enhy1JgUE+ z=IrHlGLO;Nuss($7bR-v?*8*H184-kvV>gJ;W}N4TOWHeraS7vi0IvC6sdaO#JC^m zlGurl8SlyHIpi(Zey-3A<)5oGogWk;yz@yXVV5m+^{J`ZU@+p+j?N6!gvb~;<*olX zCBjZ>es##|QSS)6dsn{cLInyrxe(PstE3$8SK!-gWnySFu^DD|i@UMACA-s6(RMnl z^a5&=;Z>}0sCPC$jUroL*q~Q?x+Oaw>DDRnr81fz5Eez>-gBes<7@%*XN_C)=nH<# z0Ccc**+kYAiSqH@j#{C>$@%AbEt=AI{Udiu$~cP}gf7(Z%I^vAZmYYlYhrurj+Ef! zpt@QVLEu$O;G>y$);GNSnO74Hz_I+>N_oca2*K_SH0WUSGO}8~i-#R{dr;}0ELtw4 z{!*O#{7sy#bZ>8fx@H8}z2mpt=j4LSt~LP$opZX`J+GIAwGQRlODUDn#UvWY-!(h@ zg)yu;&5G=HGZnq=&z?X!=j^N#QDVQhN61>6Hi zQssWtQAa&aRlvp@KK8P>K`g1 zk*l&eY1ZHX;GK7So3M{oMss}ig#Ga#4a$%wY2K$Qz`}VROBv}a_i=MP&Gi#Oz%Yak zdeb^f#`S0&Ch5~OC9uSAFI8S*p>MFJoN-f{-qGaaqodt{Vi@Fp9vYyNew>zk+;h{-PvBC~;%h5Kz3bI} zXz~m!DCBiNu;{_uvozJ2Lc&8C_(9EyZr!9%H_dmL;@Xm_2g=yh9gQWuSSM!UNhs5B z7>2$J?59kof+uz%aDSfuxhpiUPmFQH{VUlBEqkMWA)^=p$rOj^q|jn3cwJGi#&Rw+ z{4~YSOHONFs|#?fTeOmCbD(TF`HBQ>ESmU~M}oIIN$vd|DS@A+h%Kj)bRqTbtKd3JwODRsb$a2eD+N;sM3d-3-2( z#p}A(HYbskRT6ZD z{NtK0=R3eiAh|!|Tw!N0wbZ!PC)Pl2)$?@q^rx-PQkDJ*WBKFbD!waS=TX!tffVS( z36-J(5gZH|@H0l)n)#&JDQUa%G)QE#Wp|i&_Wn70Df3(%ZEcr(w9Yv)=m$bEc8%+9}8Z#fH_4O{N3p5@oFKc7Av(lX+H z;|qqh^sfc}$u!$_(|EUr5i3s%|Hw<}J;5ePemVyr-iM5Pm2@<5izI>044kcdC1lZ# zyYNRK=HQ&M%t!#W4z#b}2tmj0+NKEU%b`K^Z2!_+UwJ^F1p$QGW(Oe>m|zvy+0CkJ zL*{2h$giDg0!OZ1Ms#uVI=l~@OugOd^2x3vuk>R>f1$H%YX&(HOf87u(3Po=#D<5k#@TgXi(ijo1q##@@8i@1qK*1&O$plXc`ER|Mfepr-%6_l zW2pftm3Di*_U-SiI4aWmx<{Zx_mQ6SJqk{EJRdgmH3t|pY67}4a23nDgfkg7!r^P} za!>R0uWHivWz_^e>Q~I7^S(+SoYU3TPKT74+<8Ub{_JJpCKr>#ND)Ux(ayQj1+J9t zIdaPI0QGR^hSvylsVV}!Laf0Y@{LA~FGE7;z3ysOc)_N|CNr+0>=)4R`21fY>|_Wm z_w==|*&qD3pX6?t^aj2nW7yERLa3l`H#!YYZP=pc`j%195bhDNn~#_*s#022Zo+;++{C>hYEWSiYItHc2f7&@J)v z)1m~E2MG;d(ouCJO!yRq=cV{}8P8xyK2QMNSEub*qDh>mJ{KD#f2Z@!*G6KP!`#~# zMFzum={<19T$YlKFEZRfoRDfB?lEG%qY+)eJdk35u47iX0sHu=pIprYIhgP4K1u0|aV|bIrk#^Z0ryt5 z8uBc;3>ygk=dFRWx8x2=K28%f1w~2Ab)IYUsPijIpQ9o{0q0r|>Rx&@&bH*CPn_s( z{3E+C#z_LGfwz^`zY{oG-@_8p*XHwFwK+0N;uc^qqMN(2o~;~(65g#)`;LY~p%WMK{km9l?QueSMV`15wJ zuFByZoeML(HazV3-Dakghwqi0d!-nbA(?1lh5~JnJE&bW?+)F`eIxeH2)3q03Vt8m zE{s^BlnN{++|*2FaxPaX>F50c zK5YSmGB!6kd4{A&xPLsxL7`XKKYwHn%DjkTNUqUufQi-f#B5{m&OI#DzEB*rdJ`~T z%y-pCOS3@s0D#;~g|L*`OYto>x)q5mj)C_ce+`rVVJh=ig7t)NSw_7B0xOzSxio*) z8C|w`6+Y<}QA=cgfLQo5^s5NTDn!iWUNzhWRupu0Qj}7b+-lb5#Z&3Vj9BSp#XKhm zrGi+?sovQ?<%N&q)wp*1PBAVt9i>@3P8PCSNE2gz!A?=@v3W;5jXj66SBU+cntQbB zf8-=$MRU7Gk@CB({5#W72vQ5B_8~7+ZQsU3b6%IFIoVQQQQ^v5gmNWg2n>EFvndS^ zdG#AM1`QDFZrZ2-{;8BE_9f?HN=72IoWfXb#!2)o=ewen75^~n(VIg^K3V@tceG4% zd=IQ)`LE|z7WZ6kDdu4rD`%bEDj7APa^3iP`iGuAUGJ51 zr{7z+_p8gEs1SUI%K8Y=V$%iXd+>5lhM-MQn}umWjEC8pl7?;eSm%MM&`~H z8Z*SyE<$VIh5H+DO13J?N-UECUZ-l=T5}^O=T0rW&}^;rBCn+9ocH@fV~w3X1^R3# z47i*xrE8KbhI#E=dU|jQTfysGfmIU2uF6{<{;+c^x3Vrfe-HY}31j|=K+eDsI-S68RNW4vtmAh}vbPW1 zl3~h9ahi620s!Ku7y$eojOc%!##hzUO6ken;{RB=y8JbuqGS%uy7a@w2kgA zUQ+G1i|rg;U0o(6EC9d7RZ@ns6Dw>JNoKPbXbof7@O5aE3qJfH5A&+uZ>KtGI?q*5 zQ6z}*uLWJ|*aq2a9jrKwa#AOk0Y5GLq>H;zr%FO**cCLw`LowK;&6So6%v}?HK$WN zDo3|W<#}dDASjGFmf|*sDABx6|KAY{!$#ly53xq8*ML+~=FYzcusqEf%)5Y~r_q--!NK@E zU3s49Npr8rTBPPR$6vi=L`D2kxfRH=p8?ewEjkMS{QRtQITm(nQzHnU3@$eKb#8I< zC-F47qyO|cIes-1iLf7|VehBytk)MIq|Y@dy}Gij1;6_sboOPro{n&ZMwl3Bj)E9V zR?hJ%-T@q#;ct_?K9Pec$A_b#KHUxT)EPS1u{%HSI2^_by;ZR8$fQ9O-YBxe$7tfG zZfR)i|g%*gA-u1^bG>at~vX3VbL2$>&IC7L{&KY>K-RU9b%ez(q zjiQPJJRPv(6^y=SC(8i(91G8+Ke5A&o&Dlj=vf)aw(apVozhYG>T4wLcl+kKIF9iz zo48DeXsOK^TdqXx*nz%P0H?G2y#6V(>e~f~P)^_j0DC2a*f0OJyxM<}Igw;wYYf5x$zj zWJrUB%5qu1fb~99JoGuMil@isu!ry^;7g>K-^}!Cq%{o(QpF)xpByP-L;EZWt=*d% zUs0(^0Z90Q1Qyxdz@SgghcFkYOkuMBoiRd4Rx{^EPYmOLQ@3Eq5oi1hA6Uh-9djv> zDaOzL@Wb8T5?Oo$qkyp3f9xFfJiEYe-{n<(Nh<*_b;4Fue?O&35PKH@3&X6r5hLyE z;-|Vh5odtg2SSD`h>Y9I=Ba;Q9>8uV{D?y$`a9skLZI>1@4UirRgEdC?-Aq2bAYSE!=3cQ4v0Ea)Cz!mrqU-Z;MsLV=w~l za}A81A{?1U2XXB)B6em%{3JKm3RywHiDK|)0D`7jvRb`aBXG58b=|6}ICYZI`m;}! zE#SAV{sbS^KPaz4<+R0PJK)%UF#G z;%;|?E3;LMRPKXxv}g$Ut0hV95DP1-&}`^pJ}9Z=L}cBi!cweBg{jG$7e9aTu!r3V zSy`6=F`)H0OKXozeZKEGmMc^-{vLG)2i~;cb1PqX(5zaX-y9@G=XJHT2!8w7J^1oKHhA{|&@%rkMYmtAA|3?B zEeF2DGToee-s8bI&x<{Cwm2)GCNOW zuV(^S&0W=0#~w=32Y{>#EcsCw-~gegrWG=txuT2bpnbGoDICF!zA$dE%^?(vrm!=ui`j^5dP+#8x1<(4O=OD7i5Nxo@M;mVFpNpSa9t9kw z`b2C}T&O5Ca2PZq-_~J0on0UcTO9&`*;j1uE+DUrdy0Yukl!|d!1E>cE@wEn<KPvFetFZn;Lut4eaQ!shaP;D9DxY)lV`4IXeVzQ zYr4+2Nk-u(;k~@~pFPyBSXK5F;@>PFbrv2vKX@}?K4w1eIIrlQr!Q58Pa6%>2RkMQ zmKN5tV)`FVszcb&*T3@wwpjd~fbCa<@qZLX{d*J^pZ|y=x4$9o;zbE6sGHZq4RnXk zAl!HjB5#E~Ho9D2^E&KFoH!4wU(oJlL9Lmz@Hn3CqandOD-8<5uM7mwSr67@5* z7d(B4l&JgSB5oq9mwX%{$BsL1b`2&~m$}P_cfJeBj$W}llT*v9ly)LScvrUX=r~QT;N)`4 z`TKHa8T1_r>M&P-PDzaYwlc#`C1Fst3F|%Gu0<3;6g@Zh9 zwNgm|_fi$n3EBH|r5+*%~>bO1@T{VM9TY5jgu|~=H`-NK^6oFwr zExm;=ypX*p_(V5xZ6MErLS2KgM2?SA-T$CaB*^tu9H(Xne6ILT5_|zBw12!Ni{Eq4 z`y-j4Z0lCwGv-`4okQ_sQQ8b**PD~Z7RN71S9%kA=SC*YBH0JU%k}jpbiLD)^f6Q} zVRZn?^ukj7+6T)zaFjCU%K8o{w1@p`T~w9q{s^zx#116MIZph`WzH72NfKpg&=rEF zbrK=OmW=2%WBF4TsDt8Qe#D$fj=NS-du-loWT7{L;d4CJY|y*(gZ1v+hYpQBBv$#( zC>Fa))b{Ygb80dfIC2&9psckTJl{c&%nvCO;gWV(srYtY|kL;VMuTZ+Hn^Wi%6_T5f5JDhTH>Oat(wX6Iha z{L;A&cIF&kmluD3wmH5$nMgTJ3#?u%THMVy6{oJn9&Zi5d!zQK_y>*9o8)?XA|$Vv zO$c~v55LF0f7s4_-bnk^Eq<@=X)D6JqB6&n;|O8)_4GHqq36Vmc#{&Q)<5}NWm$_8 zQC>KU59Wd`YC1o%LrUNcZ$s#@Oi}#Fhi;u5ws&hN?;TT zezg3r4Az6zA>iQ;lL21hTJRYbZus*C@(; zeC+$If+tWFw$;0f%8FBR6~Am(nt^ZjJ&o7W;rIEby>9fgYez#~@675;nxzyV-N621tE=FzmHM>~D#Zq8 zq=YlP*)sua*Y@%o?KRM;%{Xf7b|z$p&{{dF6bJ0#H^6zF5DdK?dn7jPsWBJZa0T59 zNe(VJk#eF%c=r;c& h560F17EIv$BvhyG!Z}4AB-=k-IH#wbch=^?{|A*j=s^Gg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ad00885d7ec6758746dcf93c358b8471faf6f9c8 GIT binary patch literal 29554 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
      @o;7lS(GS6H>2_wTpJ-Aaom4JFyVLtG zUAT}+8`LpzST_+zQsa?pjv8$0pYdDR_Gqtx5kkPo0zIY6I1#Pwl(`1c;{D&{ZTwA} z_uUO{zpke+Os=2ZmB=R&L8Ao$MdRxzS5tF|*Ihq)v529)?Evcp3Kj*L54w?7l^6YhYlY zTXA292MSdEkc3B-!6)<|5unqVQBHe56pE)5Pgc#oEy zB(IW@H3y|tv&HDp9=rke{;v(4?;&Sk##sy$FM=dmRZ{>nRT_S>mKa)d4HucwWL!A^rq2hmK8`)uScdt2)V$hDq&BoF3mp&+(J7{5@j+p@MIud0an+>Vey*%{ zQ6}G1oyE9@2sDb%KLO!u#u?iE1$Ei96_H8Dd#j^u zjtYZfMBkGI7>wO=69F#x<;(f)W6+^BcAmtiGf|6C6G!&(Rx9%8{nY_J%R&Uvn^AMR zjAF_(J_xZ8v6`K>GD>*~$BVIUF~f_7-%O6&3`DnGp4V{MD!^1;eZIP!T8R`pSz2FSYI1OMy>B{iUdZu*y3b(H`wrGZv z7Lc^!Rc`~0@uo_#c2o{(9BjTRqbRAI&N=TjV)^{t@j5cLnN`N51K)!&h1v?Lu-U#A>~A$eaW| z8Ar0+)vJQR2z0+zO6%Pr(iK|}87o`)${@USVHt#mOVyVqr0?9ht0u~2wHjo1!Q~SO z*sVp@kBRhygTq~FRE0B)F7gU3NCz!NRe)N!4THd_&u6TbC&A8d^=(0z5w1h*@Ib1F zA0kvFe&MN;jSk7X)ltrjjLC6c=xYSpVpSvD0)4f&1`6LD-@~zG`x&Jc5#9>hFTYYI z<}O{xI2bkaIT3trr^IVQcEk8$WB<~FhK8mBr?Q*lb*t+1VQDlOZQ6~=d244oU_QIAtrZk(`s0wbb(++^vm+AN< zq=3v?u-@sDhnA%=LP~lv|4srnB(P|$0O7-T$hsc8n6O1tZNql|H`l-xfKyd2bZ0R| z#A;1{&8J6uWtQ4emuAkGW!60E75BCPXPG<*H0qHlT!oho`P9(#G^*k$ay#e<6B^|)mb{#v^g}o?W0&N)yzU2#^A~*Q z9O6>D9WQ0*e=UW)8UJwL5QuuKeIdzqJT!D#Y+8|#aeXJ|e#$~+52Z7)EIC$W((5=l zqt=j>E;c{uEm`0@%7Qd^y{{D1-5DHk5H&HIUU4Rjm=?|tEqoKgK09M13?1J{vLp z1d@L~APeGuyO?t*KoG)6G7$vsFL$P*(BqijY@i0S{Oq3VCTyLG^W6I~voZegP2gRN z!_tg1IPUB=3G@b2RrqUZ820{T&f@0Zoj=JUtfk9^7a-AUR8k_9xC9khYchZl6`3{Y z#`=bj8$wHNUSbZ0@g^iVa6>O$Xj>#<&!QL>+@z`;@Bro$1_J;NqlxDgU7@4L{+5G( z-m}l+>DkmSb9fi+hWUM(8LxTUi3Bw$)eR$Q0`=haMD@lIyQDds^@9f{f@(@n7tI59 zU8_O&qfgVlO>-Q(zbP@ZFi}YM*;pa)Ei6$J&XpBcvIAm2#+AZLcRp$~n&fV{V|Em! zNNTt$?_4xq0M0q^gADcYXx{nurt8Fu7tH;}-}3Pb8sE1NeiHCxAU4A9K3lq^9Bd`x zKZqPswcyo3LweZ9?;CeveUa>O8K)|{?Hw{!n zffg;U(16G}mt}jvEF)qfK7eV!bJ!3h#%C;-Q}hP=%UB-Xg{`iuBXhVA-fYaRH32*X zWrD`GiukOS4s@zk2mkT}ZXqa>A%+zKF|4&5Q17R4-JC8xT*{ zNMtX5E)JiF6o>%Zu8}UN%Bzy`?1p`7M*$7k#RVR@bP&pH?2JjMt3rR{+6+a1zRV^)0g&nvagn?2* z*uv@*$@`W0odoHh2m=pfN!$2ujz=^r&u+j5`tLnx_vGl;-3n10&Vn8xT zU&?MC^`4of-zWEbV@6w)JCyr1MjvNQepqSunuJe>bdOr{A;~MGvmKJAWk+_YOujnX z+}heITx@t2}T1COvE0bHrvt{8b)iXnt> z{+FqCE2moKh2a@$Bs&lKGG%w9fhVvtn^79=$~q z^}IFr#4&0k=AIVI)K&UQ10p1>^&1B0{UCM z0de6VR2cKc7Phu?A0CO)Wxx;&ff*_S++%Fx!5+qN%Bs_K=fx3_`{(mHYTE z+rF;Y20fQJg*n2`LVcFLF(~s14D{7NxK7m(goLM3dro2mWY4!yk|BS=liv0ElF8QE<-9Y+0{ z7&X?=vy!H6_-RpC_pkHwy5Qru>37&H*!}{x5%&ImZB~pc_?fV!TIB(Kmd4s?Bmd2i z%L-l32KO6BMoM5yG)DS;Mr{kri+nGBX=lG{H~qpTs;*w{+A~Sde4l`v>l>RCRG(Bo zE0(F?^$uwf{Jz|B=Z<|qNvEaVh%UL0{_U+1m10md4Tg<;Xr57Mjve0E?13bN>(v-o z;C-$kul7uzGU&iAe0eV87dJ7w`CZiwtkK-a(&h5(-j=c5X(vo4Li3t8?{0F(i zhdsOe?63CTvWJ+r>lSm$x@VW3O}Y>=u$>zdWxnVbDzm)}-flmP} z7w~-?ok3o_IUjmu#Vp%Ujuj5?`2KP=m6YJV=Fqcd;JvqlS?B7dyepbooih z)kszbR)bZfA1g#_AjySkxZJw}501(-WiOmj2fKyXRk+djKvrR(XYFOy~G7yI(?FGle@+?6u3pmn`8c?UI%4skV z(qa20jP7)=q4)I)p! z!H!P;y!j+uSBo6u;M%CvNNIg}aR6*f836WYE>n2fRS@&pq_ww{wv#ZeveGEyIr%C( z+@#bKtyVr(Xb{%_49r`7NJK+pWVNls^?L&z{Y69IqM3t(p!ZnC8cECXaBMkZ^&>T+ zI-l+=+}bd=~rsRRa**xh$4@xJ&?z&1v3K`<4 z*oQ!{&O8-^^VubM&)61%cuJTnk;K^HAoS+@drF0+>jDpt?p^PD*Mg-jl@t%KCTK** zUWaCq%_sH3i&9CMKFd~U7T)53fZ|+rx1DdvfGHT&{{e7r8WN{<+s z@nR!*cyjL2r~`Y&6Cj$P7(U6;KUuvsJ)W0avPjI6bZ0cHw>#@v+|u9^vy+(E$%s`9 z_~gxbUFWoS5AB)Kj~Dc3Uhg-7W>)X`SzC9*_^ucB`61PQK1$l<4X=J^ zec?hw@q&A6xhKh?APlv*71YL<&n?)K7~dlXF=RI*--*H1JGJZ&;_}r2Qk4sXHZd)_ zS>Ppl*i9NuLgA3*KVI3_a98(9RPbvFxn4eBu?^ymWXp}Be}HB<)FBT4>Fg8Vd4Ah<^GyJzep79NMqigHsy+H9IY>r3H9PbZnwP zLPDwp6eklE+OYd_!cDFx@A7ajs00E&{L*%@!wj%ewIlKuQG zZ!0QJX)HzuOGx97sN^{V_2qwcI1e~D_EU;FYC?3EQt*YU31`;-YVXQ}no836LuPQ4 zVFg7%+Y|={(Zmh?|WW18fVf&6gSiC{1tk4 zA(|TrgPE7*U8yG2y|JX_0cGMM?3JsB1c0woy(YPSlA_@J{^nu*AlO$G*4)4k&?|L6 zsk=7BTSpwR%dS1v5HF4=tDt%dBVFd^Pc`mJ$re_+mo21lkBXhq&0m9~>K?$s`ZKq- zd%hastql$mTfVtbrv09{7Aevg@hJ}1uq!)MSeL)*>D-5FC*wx65M=i<{6@ft5_sBB z8rZ$Wk}F?JO?*1-AQ>^C zy*_k=GJH91W?jO%_M1|4zT;O%Q(EShBlVL#khhcpQB#RZA6E{?*Cvx5$c>zn13)1u zke6?aG|>(_qSF>A=b;uewv`?*>(&Ht3pr;6JBPXEHnhN&ra}4LH7E#h=sI-Qj<+q^ zi5&p}{!d~JgsG_B8rQ+{Vmn}O6qEBL42E?1fUY2F@Tg-mx;P4j8UKT>6Y_gc^q1rs zc$ZJ4;XKXtr)S+I$r>N^)ba6)iw|{Zw_+GPJUj>A2q+XJZ+v4tQyIJ>0!*H}H-xxB zwcervTR?yeqMG{hp9T%eP>9nWvA@1zENC|N6+4w_yG{)-=N6uiTvCrHzb`(xw?mgX zpZYOWnc_8h&tI{Uh#Qbo(pIn3Hr*9!4n#E!O0%PgcQ=qVbY?+5(Po?_FrIt2?=9V_ z!ISrZ)A$Sl>p#gYscr~N$r*EVpC=k?>o`xEot~z)!YSZ^zy3xF;lf-~c0J>EX2gXS z<0+(IznjtPlVffE^1H{YtBwE`U$P9Hm`E|Ay}SV}Y8@m(>#nXU>NGCZhK3reZx;_b zvrg}7L{auYQC0CgkfZ^y2=0M3i|UlgF@!{;{NbXo+2yH!4LMIyQ2Tak(BLqZPL+AD zvXee9G>T=*J>i&|{t{;@g7m~>OV}qfD@3-|+CT+RpS#L|Ua@pWy(KoEw0qKs=#bIe6Q^WL3m<TWZp=fNCi@u`43dR&OTVQ@`Um>GWtu zU3_jiR^l&u%Nv1@?j~hs*66)&ugIXorAuUoU)Hc+1qgZTrHbpE-DlNu6uBA;{Ko1j z>h}7#E-iTP@Ah0Mdn#zIyLt||LiskZ$AU@tmP)6A@&VWF_BX#aeCtn?I)nkQ} z?lBpf7yEzB0|D_*7INR%x4`L@xD$?4=-OaVY=T6{b%#cu=V2eG-DL%^kz7_^I2q@VfQ07-{WEaEUV0# z8a=R5n_2ZfEN7JMcO3m`&YA#?Oea6kIJGQ@hs%GhR5I2#_++qiekH7xy=plZPndZS zCDbVP9Uv~aqlTl(CGO$~6@0uv;4xLGDlewsMeFi98vB~b1*1+_b?Ai6RLi|2etSWZq)qKWM{~vw%LzJSCm!v7E^+WpX z%D&QD`mhrX zlX6WDIu2Hnmi(0X_(d%?KKS*92Xpu7P^TCM{zVQ*s2sL6*x569<8H!0u8%}Li zK?j(&YX8VyZavvZa+t0$HG2<*YJ&L%IPf5N($pXptgs@yMD|sAhhonk)*kD690xP7 zRcqnhqhdBOGWmlXXE~yO56q(UYbDl()m&#ngpiV=gMge>VZ3EtZ#|BTa-6`a7o*9k z(a|CX*B(FA0)NV^EQ38V@d;is<}n*f8+pMq4n!Ts2`E919GH;sN_>L;N@^u_dUNYk ziW>|9ygti;`{mC6us0N#X+PFAuqLT}lHnVqBM(k7(zM{6T1y zkqjQX5JvFy#APydPlauLU;CoFN3*GZN?z~6Y|QxzF>daxN&8wAjfF+hPrjL%iAyz{ z@l5==vSu5~<%W4lsgUDaji+6dEBq0OkHQp%y$bF;)FrB3vDsOStCC?nfGsKQC~6E)b{`C=SOB0690T(K2AowaFkk*gwPFM$9~ z84MaATF&)*QX-G$O0K@2{VK%dE*0W(T;JC3NWu?uf<4S|m*pF}Vro)Xs&qYQxAZ99nCc?Un5z z0O!>6b|{H2R0Cfpm@K*N#4#erJ;=kw;S!3510Ul{WpvPJ^w=l4TJ0Q&4lt|x=z9E1 zp7vz-CC@VKFin-Y66J`heN+objJ}Df$P10$ve}%5WLkjTneg$ZT_C?!T6Mpdl|!i0 zP~ehY;EacsWoQ_av(+DcxSa6Lp3l(h8$*pTGaa+oaMzDDBrj>Z(nhNpxNC-SzZJ?m zO-6c5AcMlWqW#0T8gcPdrmcqpEL;u-Kcr-T*4a7DitFk}`>5m7eq^_2$9`zs@2J;2dGr1YlAY9`{+9ed-05no%?oTncnZF8Ul!4V@^Wm*ZcP$T{1K| zl`d_}8OZN(TL^h^=C<)>X1&i2WvY#fO8q-;X^;=_d<+U_IW=eebPZx#k(eaEUCu8S zj-BsW3&ZM5o~wzn<^GD^CMpGfL=dJEV1(d>kp-BA^Uv>}JnPX#nrVKeX%HlUnhwNd z`Psd?ow{A>j;)b?daPqE7FBS94DJekl8E9I%DKy8ISSHeKN_{otd@;`N&+dLA~P;C zax-K%xJmA)y%tP4{LpEA4ul+oZe4z4N(ZD9!6Q=EZXWEoqP*c4cm5&yfnr z@;e6Yh3V#6L_C@~TLY@UX^vI~8Q#j>)$sr~n9ALLjr0ID24*c$;GMi4tSQ zfx!V!eXGJAo5Gb*vGDNlV!yG5BQ*Off;D;m7XwO4jh?+LJ<&5_SSmB-Pp1AAnA zUgg@*_0e+9_Fy6e6o|}4fAgogq@CuhL&XQ#uynbEL@M~<0ehUrU*SzOm4Aw zrL4UVJPIsdbk|gYN@a~Y1Nz8-oV_TYoEe$1{6^^o;D1aAg3tW}-0a>L*&w?Giaolq zlT(aXqTjJo0v2(8JwBVyWZ3&vL?0hNC@J7oVGD#Ly0&&4s@m~jBCn`rYO({E;JsX73`;k2R~VBv<>In<>Ckd*+U{8xwAzZCw`V_($f f|2qrj*<6q9R0WoGYrzH~f}AuyYgBmr>aBkQHX7d# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ad00885d7ec6758746dcf93c358b8471faf6f9c8 GIT binary patch literal 29554 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
        @o;7lS(GS6H>2_wTpJ-Aaom4JFyVLtG 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 literal 0 HcmV?d00001 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();