feat(inventory): 재고 현황 요약/상세 플로우를 릴리스

- lib/features/inventory/summary 계층과 warehouse select 위젯을 추가해 목록/상세, 자동 새로고침, 필터, 상세 시트를 구현

- PermissionBootstrapper, scope 파서, 라우트 가드로 inventory.view 기반 권한 부여와 메뉴 노출을 통합(lib/core, lib/main.dart 등)

- Inventory Summary API/QA/Audit 문서와 PR 템플릿, CHANGELOG를 신규 스펙과 검증 커맨드로 업데이트

- DTO 직렬화 의존성을 추가하고 Golden·Widget·단위 테스트를 작성했으며 flutter analyze / flutter test --coverage를 통과
This commit is contained in:
JiWoong Sul
2025-11-09 01:13:02 +09:00
parent 486ab8706f
commit 47cc62a33d
72 changed files with 5453 additions and 1021 deletions

View File

@@ -82,3 +82,97 @@ return ApprovalDto.parsePaginated(response.data ?? const {});
- [x] 모든 Remote Repository가 ApiClient를 사용하도록 마이그레이션했다.
- [x] 에러/토큰/재시도 정책을 위젯 및 도메인 테스트에 연결했다.
- [x] 문서와 코드가 동기화되었으며, 변경 시 `tool/sync_stock_docs.sh --check`를 사용한다.
## 14) Inventory Summary API (신규)
### 14.1 목록 `GET /api/v1/inventory/summary`
- **권한**: `scope:inventory.view` + `menu_code=inventory` `can_read=true`
- **Query 파라미터**
- `page`, `page_size` (기본 1/50, 최대 200)
- `q`, `product_name`, `vendor_name`
- `warehouse_id`, `include_empty`, `updated_since`
- `sort`: `last_event_at|product_name|vendor_name|total_quantity`
- `order`: `asc|desc`
- **응답 스키마**
```json
{
"items": [
{
"product": {
"id": 101,
"product_code": "INV-DEMO-001",
"product_name": "QA 데모 장비",
"vendor": { "id": 10, "vendor_name": "Inventory Demo Vendor" }
},
"total_quantity": 145,
"warehouse_balances": [
{
"warehouse": {
"id": 1,
"warehouse_code": "INV-QA-A",
"warehouse_name": "QA 1센터"
},
"quantity": 115
}
],
"recent_event": {
"event_id": 15001,
"event_kind": "issue",
"event_label": "출고",
"delta_quantity": -20,
"counterparty": { "type": "customer", "name": "Inventory QA 고객" },
"warehouse": { "id": 1, "warehouse_code": "INV-QA-A", "warehouse_name": "QA 1센터" },
"transaction": { "id": 9100, "transaction_no": "INV-ISS-001" },
"occurred_at": "2025-10-24T02:58:00Z"
},
"updated_at": "2025-10-24T03:12:00Z"
}
],
"page": 1,
"page_size": 50,
"total": 1
}
```
- **오류**: `403 INVENTORY_SCOPE_REQUIRED`, `409 INVENTORY_SNAPSHOT_NOT_READY`
### 14.2 단건 `GET /api/v1/inventory/summary/{product_id}`
- **Query**: `event_limit`(1~100, 기본 20), `warehouse_id`
- **응답**
```json
{
"data": {
"product": { "id": 101, "product_code": "INV-DEMO-001", "product_name": "QA 데모 장비",
"vendor": { "id": 10, "vendor_name": "Inventory Demo Vendor" }
},
"total_quantity": 145,
"warehouse_balances": [
{
"warehouse": { "id": 1, "warehouse_code": "INV-QA-A", "warehouse_name": "QA 1센터" },
"quantity": 115
}
],
"recent_events": [
{
"event_id": 15001,
"event_kind": "issue",
"event_label": "출고",
"delta_quantity": -20,
"counterparty": { "type": "customer", "name": "Inventory QA 고객" },
"warehouse": { "id": 1, "warehouse_code": "INV-QA-A", "warehouse_name": "QA 1센터" },
"transaction": { "id": 9100, "transaction_no": "INV-ISS-001" },
"line": { "id": 12001, "line_no": 1, "quantity": 20 },
"occurred_at": "2025-10-24T02:58:00Z"
}
],
"updated_at": "2025-10-24T03:12:00Z",
"last_refreshed_at": "2025-10-24T03:10:00Z"
}
}
```
- **오류**: `403 INVENTORY_SCOPE_REQUIRED`, `409 INVENTORY_SNAPSHOT_NOT_READY`, `404 NOT_FOUND`
### 14.3 프런트 TODO
- DTO/JSON 직렬화: `InventorySummaryResponse`, `InventoryDetailResponse``build_runner` 재생성
- 상태관리: `InventorySummaryController`, `InventoryDetailController` (Pagination, 필터, `event_limit`)
- UI: 리스트(테이블) + 상세 시트, `warehouse_balances` 시각화, `recent_event` 배지
- 테스트: 위젯/Golden/통합 + `flutter analyze`, `flutter test --coverage`

View File

@@ -92,6 +92,16 @@
- [x] 재고 입출고/대여 팝업: 재고 상세 공통 다이얼로그를 `SuperportDetailDialog`로 교체하고 입고/출고/대여 레코드의 상태·창고·금액/수량·반납 예정일을 metadata로 옮겼다. 탭은 라인 품목(고객/관계 포함)만 남겨 info1과 행위 영역이 분리되었다.
- [ ] 나머지 결재/마스터 팝업: 동일한 패턴으로 순차 대응 예정.
#### 4.3.1 재고 현황 상세 시트 적용 스냅샷
- Inventory Summary 화면(`lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart`)은 통합 가이드를 따라 summary/metadata/본문을 재구성했다.
- summary 카드: 제품명·코드, 총 수량, 뷰 리프레시 시각만 남겨 정보1에서 전체 상태를 빠르게 파악한다.
- metadata 영역: 창고 선택, 이벤트 개수(`event_limit`), 0 수량 포함 토글, 오류 배너가 모두 상단에 배치돼 중복 필드를 없앴다.
- 본문: 창고 잔량 그래프 + Tag, 최근 이벤트 타임라인만 유지하며 모든 필드는 한 번만 노출된다.
- Golden 산출물
- 목록 기본 상태: `test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png`
- 상세 시트 오픈 상태: `test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png`
- 검증 명령: `flutter test --update-goldens test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart`
### 4.4 검증 & 테스트
- [ ] 기존 위젯 테스트 업데이트: overview 탭 삭제, metadata 렌더링 검증 등.
- [ ] 신규 테스트 케이스 추가: summary+metadata에 필드가 모두 나타나는지, 탭이 최소 한 개만 남았을 때 동작하는지 확인.

View File

@@ -13,6 +13,7 @@
| 4 | 보고서 Export(PDF/XLSX) 스트리밍·메타데이터 | ✅ 해결 | 감사 로그 확인 및 다운로드 UI 메타 필드 적용 |
| 5 | 그룹-메뉴 권한 `route_path`·`is_deleted`·`include_deleted` | ✅ 해결 | 편집 화면에 삭제 항목/경로 노출 및 회귀 테스트 |
| 6 | Prometheus 지표(`approval_flow_action_*`) 및 감사 로그 | ✅ 해결 | Ops 대시보드/알림 구성안 수립 |
| 7 | 재고 현황 API (`/inventory/summary`) 계약 및 RBAC | 🟡 진행중 | 백엔드 구현 완료 → 프런트 DTO/화면/권한 플로우 동기화 (`doc/inventory_management_feature_plan.md`) |
아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다.
@@ -30,6 +31,14 @@
- 결재 전이 API가 `expected_updated_at`, `transaction_expected_updated_at`을 요구하며 최신 `data.transaction`/`data.approval`을 반환한다. 프런트는 낙관적 잠금 실패 시 메시지를 문서에 맞춰 노출해야 한다.
- 기본 목록은 승인·완료 상태만 반환하고, 초안·상신 전표는 `status=draft,submitted` 또는 `include_pending=true`로 별도 조회한다. (`backend/src/domain/stock_transactions.rs:74`, `backend/src/adapters/repositories/stock_transactions.rs:45`)
## 재고 현황 API
- 백엔드가 `inventory_balance_events_view`/`inventory_balance_snapshots` 마테뷰와 `/api/v1/inventory/summary` 목록/단건 API를 구현했다. 응답 스키마는 `stock_approval_system_api_v4.md` §4.8~4.9 및 `doc/API_CLIENT_SPEC.md`(백엔드, 프런트 모두)에 정리되어 있다.
- 응답 필드: 목록/단건 모두 `product_id`, `product_code`, `product_name`, `vendor_name`, `total_quantity`, `warehouse_balances[] { warehouse_id, warehouse_code, warehouse_name, quantity }`, `recent_event_*`(kind, delta, counterparty, warehouse_id/name, transaction_id/no, event_at), `updated_at`, `refreshed_at`을 반환한다. UI는 리스트에서 총계·주요 창고·최근 변동 요약을, 상세에서 전체 `warehouse_balances`와 최근 이벤트 타임라인을 노출해야 한다. (출처: `stock_approval_system_spec_v4.md` §§3.24~3.25)
- 데이터 해석: `inventory_balance_events_view`는 5분 주기로 리프레시되는 마테뷰이며 `delta_quantity`는 입고/반납=양수·출고/대여=음수 규칙을 따른다. `event_kind`(receipt, issue, rental_out, rental_return 등)는 뱃지/아이콘으로 구분하고, `counterparty_name`/`recent_event_warehouse_name`은 현지화된 라벨 텍스트와 함께 표기해야 한다.
- 권한: 메뉴 권한(`menu_code=inventory`) + 스코프 `inventory.view`. 프런트 라우트 가드/사이드 메뉴 노출은 `permissions` 배열의 `scope:inventory.view` 보유 여부를 기준으로 삼는다.
- 캐시/리프레시: 서버 2초 TTL 캐시 + 마테뷰 리프레시 스크립트(`script/refresh_inventory_mv.sh`). UI에서 `last_refreshed_at`을 노출해 사용자에게 데이터 신선도를 알려야 한다.
- 감사 로그: `inventory.summary.viewed` 이벤트가 `{ actor_id, filters, result_count, request_id }`를 포함한다. 프런트는 필터/정렬 상태를 명시적으로 노출해 감사 이유를 이해할 수 있도록 UX를 준비한다.
## 결재 단계 & 행위
- `GET /api/v1/approval-steps``approver_id`, `approval_id`, `status_id`, `q` 필터와 `include=approval,approver,status` 확장을 지원한다. 프런트 컨트롤러가 새 파라미터를 모두 전달하는지 점검한다.
- `/approval/**` 행위가 `expected_updated_at`을 요구하고 `data.approval`을 반환하며, Prometheus 지표(`approval_flow_action_total`, `approval_flow_action_duration_seconds`)가 발행된다.

View File

@@ -0,0 +1,103 @@
# 재고관리(Inventory) 기능 단계별 개발 계획
## 개요
- 재고 변동 이력을 기준으로 최신 재고를 노출하는 API/화면을 Clean Architecture 구조에 맞춰 단계적으로 구축한다.
- 공식 명세는 `stock_approval_system_spec_v4.md` §§3.24~3.25(재고 이벤트/집계 뷰)와 `stock_approval_system_api_v4.md` §4.8~4.9(Inventory Summary API)에 정의되어 있으며, 모든 구현·테스트는 해당 계약을 1차 근거로 삼는다.
- 읽기 전용 권한 스코프 `inventory.view`가 추가되었고, `/api/v1/inventory/**` 경로는 해당 스코프와 메뉴 권한(`menu_code=inventory`, `route_path=/inventory/summary`)을 모두 충족해야 접근할 수 있다.
## 최근 진행 현황 (2025-11-08)
- Inventory Summary UI를 정식 릴리스하며 자동 새로고침, 창고 필터, 상세 시트(그래프/타임라인)를 모두 연결 완료했다.
- Golden 테스트(`inventory_summary_page_golden_test.dart`)와 위젯 테스트를 확장해 정렬/필터/빈 상태/권한 오류까지 회귀 시나리오를 커버한다.
- 릴리스 노트(`CHANGELOG.md`)와 PR 템플릿(`.github/PULL_REQUEST_TEMPLATE.md`)에 사용자 영향 및 필수 검증 커맨드를 명시했다.
- 상세 다이얼로그 통합 계획서에 재고 현황 사례와 골든 스냅샷 경로를 기록해 문서 일관성을 확보했다.
## 전체 타임라인 개요
1. **요구 정합성 확보 (Backend & Product)** — 데이터 출처, 뷰 리프레시 주기, RBAC 확정.
2. **백엔드 개발** — 마테뷰·API·권한 시드·테스트·문서 동기화.
3. **프론트엔드 개발** — Flutter 사이드 메뉴/리스트/상세/상태 관리 구현 및 테스트.
4. **통합 검증** — 계약 검증, 샘플 데이터, E2E 시나리오, 배포 산출 정리.
---
## 백엔드 단계별 Tasks (선행) — ✅ 완료
> 기준: `../superport_api_v2`
1. ### 요구/계약 정리
- [x] `doc/frontend_backend_alignment_report.md` 업데이트로 이벤트 뷰→스냅샷 흐름 문서화 (2025-10-24)
- [x] 정렬/페이징/오류 코드 스펙 동기화 (`stock_approval_system_api_v4.md` §§4.8~4.9)
- [x] 감사 로그/Slack 알림 범위 정의 (`doc/inventory_summary_audit_plan.md`)
2. ### 데이터 모델링 & 마이그레이션
- [x] `migration/110_inventory_balance_events_view.sql`
- [x] `migration/115_inventory_balance_snapshots_mv.sql` + 인덱스/리프레시 정책
- [x] `script/refresh_inventory_mv.sh`, `doc/qa/inventory_data_replay.md`
3. ### API/서비스 설계
- [x] `backend/src/domain/inventory.rs` (쿼리/DTO 규칙)
- [x] `backend/src/api/v1/inventory.rs` (`/inventory/summary`, `/inventory/summary/{product_id}`)
- [x] `stock_approval_system_api_v4.md`, `doc/API_CLIENT_SPEC.md`에 직렬화/빈 결과 규칙 명시
4. ### 권한·인증·감사
- [x] `migration/105_add_inventory_scope.sql``inventory.view` 스코프 시드
- [x] `backend/src/api/security.rs``InventoryAuthContext` 및 스코프 검사 추가
- [x] 감사 이벤트 `inventory.summary.viewed` (`backend/src/app/services/inventory.rs`)
5. ### 구현
- [x] `backend/src/adapters/repositories/inventory.rs` (뷰 조회)
- [x] `backend/src/app/services/inventory.rs` 2초 TTL 캐시
- [x] 오류 코드 통일 (`INVENTORY_SNAPSHOT_NOT_READY`, `INVENTORY_SCOPE_REQUIRED`)
6. ### 샘플/테스트 데이터
- [x] `migration/120_seed_inventory_summary.sql`
- [x] `doc/qa/inventory_data_replay.md`
7. ### 검증 & 문서
- [x] `backend/tests/inventory_summary.rs`
- [x] `script/run_backend_checks.sh` (`fmt`/`check`/`clippy`/`tests::inventory_summary`)
- [x] Postman/Thunder & `doc/API_CLIENT_SPEC.md` 업데이트 (백엔드 기준)
---
## 프론트엔드 단계별 Tasks (백엔드 완료 후 착수)
1. ### 계약 동기화 & 환경 준비
- [x] `superport_v2`에서 API DTO/JSON 직렬화를 `build_runner`로 재생성(`InventorySummaryResponse`, `InventoryDetailResponse`).
- [x] `ApiClient``/api/v1/inventory/summary` 경로를 추가하고, 서비스 등록은 기존 의존성 주입 컨테이너(`injection_container.dart`)에 `InventoryRepository`/`InventoryService`로 분리.
- [x] QA 계정은 로그인 응답(`permissions`, `permission_codes`)에 `scope:inventory.view`가 포함되도록 백엔드와 권한 시드를 맞추고, README에서 해당 흐름을 안내.
2. ### 내비게이션 & 라우팅
- [x] Flutter 사이드 메뉴에 `재고현황`을 대시보드와 입출고 사이에 배치하고, 앱 라우터(GoRouter/AutoRoute)에서 `/inventory/summary` 라우트를 추가.
- [x] 라우트 가드: 세션의 `permissions` 배열에 `scope:inventory.view`가 없으면 메뉴 자체를 숨기고, 직접 URL 접근 시 권한 부족 안내/감사 로그 전송.
3. ### 상태 관리 & 데이터 요청
- [x] 기존 상태관리 패턴(예: Riverpod `AsyncNotifier` / Bloc)을 따른 `InventorySummaryController`를 작성하고 페이징, 정렬, 필터 상태를 보존.
- [x] 상세 패널은 `InventoryDetailController`를 분리해 제품 ID별 캐시, 동일 요청 중복 방지, `event_limit` 조절(기본 20) 로직을 포함.
- [x] HTTP 오류(403, 404, 409)와 빈 데이터 응답 시 UI 상태를 명시적으로 노출하고, 최근 이벤트가 비어있을 때 대체 메시지를 제공.
4. ### UI 컴포넌트
- [x] Flutter `PaginatedDataTable` 또는 기존 공용 테이블 위젯으로 리스트를 구성: 컬럼 = 순번, 제품명/코드, 벤더, 총계, 창고별 요약, 최근 변동(타입/수량/시간/거래처).
- [x] 상세 뷰(모달 또는 `DraggableScrollableSheet`)에 `warehouse_balances` 그래프/Tag, `recent_events` 타임라인(입·출고/대여/반납 구분 아이콘) 표현.
- [x] View-only 배지, Skeleton/empty/error state 컴포넌트를 기존 디자인 시스템(`superport_v2/lib/widgets/state/`)과 재사용.
5. ### UX 보완
- [x] 최신 변동 기준 정렬 라벨(`최근 이벤트: 2025-10-24 12:12`)과 자동 새로고침 토글(뷰 리프레시 시각 기준)을 안내.
- [x] 창고별 잔량은 Tag/Pill 또는 미니 차트로 시각화하고, 총 보유 수량을 강조(Warning 색상은 음수 재고만).
- [x] 키보드 포커스 이동, 스크린리더 라벨(`recent_event.event_label`) 등 접근성 체크.
6. ### 테스트 & 품질
- [x] Widget 테스트: 리스트 렌더링, 최근 이벤트 표시, 권한 없는 경우 Alert 노출.
- [x] 통합/Golden 테스트: 정렬/필터 조합, 빈 데이터 상태, 상세 패널 타임라인.
- [x] `flutter analyze`, `flutter test --coverage`, 필요 시 `flutter test --coverage --machine` 결과를 CI에 업로드.
7. ### 문서 & 배포 준비
- [x] `doc/detail_dialog_unification_plan.md` 또는 신규 문서에 UI 플로우와 스냅샷을 추가.
- [x] 릴리스 노트/PR 템플릿에 사용자 영향(읽기 전용 화면 추가)과 검증 커맨드(`cargo test -- tests::inventory_summary`, `flutter test --coverage`)를 명시.
- [x] QA와 함께 재고/입출고/대여 연계 시나리오를 포함한 E2E 테스트 목록을 `doc/qa/inventory/` 하위에 정리.
---
## 통합 체크리스트
- [x] `inventory.view` 스코프 및 메뉴 권한 시드 적용 (백엔드 105번 마이그레이션)
- [x] `inventory_balance_events_view` / `inventory_balance_snapshots` 리프레시 + 모니터링 스크립트
- [x] `/api/v1/inventory/summary`/`{product_id}` 스펙 검증 & `cargo test -- tests::inventory_summary` 통과
- [x] 감사 로그(`inventory.summary.viewed`) 경보 플로우 스테이징 검증
- [x] 문서/QA/배포 안내 최신화 (`doc/API_CLIENT_SPEC.md`, `doc/qa/inventory_data_replay.md`, `script/DEPLOY_REMOTE.md`)

View File

@@ -0,0 +1,37 @@
# 재고 요약 감사 로그 · Slack 알림 계획
> 원본 정의: `../superport_api_v2/doc/inventory_summary_audit_plan.md` 프런트에서도 동일 정책을 참조하도록 요약본을 유지한다.
## 이벤트 개요
- 엔드포인트: `GET /api/v1/inventory/summary`, `GET /api/v1/inventory/summary/{product_id}`
- 이벤트 코드: `inventory.summary.viewed` (version `1.0`)
- 발행 채널: Kafka(선택), WebSocket(`AuditEventStream`)
### Payload
| 필드 | 설명 |
| --- | --- |
| `actor_id` | 요청자 ID |
| `filters` | `page`, `page_size`, `warehouse_id`, `include_empty`, `sort`, `order`, `updated_since`, `event_limit`, `product_id` |
| `result_count` | 목록: `items.len()`, 단건: `recent_events.len()` |
| `request_id` | 서버가 채번한 마이크로초 기반 상관관계 키 |
| `emitted_at` | UTC 기준 발행 시각 |
프런트는 필터 UI 상태를 서버와 동일하게 유지해 감사 로그와 UX 간 불일치가 없도록 한다.
## Slack / PagerDuty 라우팅
| 시나리오 | 채널 | 레벨 |
| --- | --- | --- |
| 정상 조회 | `#inventory-monitoring` (옵션) | INFO |
| 권한 부족 (`INVENTORY_SCOPE_REQUIRED`) | `#inventory-alerts` | WARN / 5분 내 5회 → PagerDuty Low |
| 스냅샷 지연 (`INVENTORY_SNAPSHOT_NOT_READY`) | `#inventory-alerts` | INFO / 10분 지속 → PagerDuty Medium |
| 정합성 실패·데이터 불일치 | `#inventory-critical` | ERROR / 즉시 PagerDuty High |
프런트 액션:
- 권한 부족 시 AlertDialg + Slack 알림에 포함될 수 있는 컨텍스트(`filters`,`actor_id`)를 명시.
- 스냅샷 지연 오류에서는 사용자에게 뷰 리프레시 절차 안내 메시지를 출력.
## 운영 체크리스트
1. 배포 직후 `script/refresh_inventory_mv.sh --database-url "$DATABASE_URL"` 실행 기록을 Ops에 공유.
2. Slack 로그 샘플을 QA와 함께 캡처해 PR에 첨부.
3. 감사 이벤트 미수신 시 `AuditEventStream` 구독자 상태(프론트 Admin 콘솔) 확인.
4. 월 1회 Ops와 정책 재검토, 필요 시 PagerDuty 라우팅/임계값 갱신.

View File

@@ -0,0 +1,40 @@
# 재고 현황 E2E 체크리스트
## 개요
- `/inventory/summary` 플로우가 백엔드 계약(`stock_approval_system_api_v4.md` §4.8~4.9)과 UI 명세(`doc/inventory_management_feature_plan.md`)을 모두 만족하는지 QA 단계에서 검증한다.
- Chrome CanvasKit 렌더러 기준으로 테스트하며, 로그인 응답의 `permissions` 또는 `permission_codes``scope:inventory.view`가 포함된 계정을 사용한다.
## 사전 조건
1. `.env.development` 또는 `.env.production`에서 `Environment.initialize()`가 성공적으로 수행되어야 한다.
2. `inventory_balance_snapshots` 마테뷰 리프레시 스크립트가 직전 5분 내 실행되어 `last_refreshed_at`이 현재 시각과 5분 이상 차이나지 않아야 한다.
3. QA 계정은 `scope:inventory.view``menu_code=inventory` `can_read=true` 권한을 모두 보유해야 한다.
## 시나리오
1. **기본 목록 로딩**
- 조건: 초기 페이지 진입
- 기대 결과: View Only 배지, 총 제품 수, 최근 이벤트 기준 카드 노출, 테이블 기본 정렬은 `last_event_at DESC`.
2. **자동 새로고침 토글**
- 조건: 자동 새로고침 스위치를 활성화한 상태에서 30초 이상 대기
- 기대 결과: 추가 조작 없이 목록이 다시 로딩되고 `마지막 리프레시` 라벨이 최신 값으로 갱신된다.
3. **자동 새로고침 비활성화**
- 조건: 스위치를 끄고 45초 이상 대기
- 기대 결과: 추가 로딩이 발생하지 않으며, 다시 스위치를 켜면 즉시 주기 타이머가 재시작된다.
4. **필터 적용/리셋**
- 조건: 검색어 + 벤더 + 창고 선택 후 적용 → 리셋
- 기대 결과: 적용 시 해당 파라미터가 API 호출에 포함되고, 리셋 시 모든 입력/토글이 초기 상태로 돌아간다.
5. **상세 시트 그래프 & 접근성**
- 조건: 행 클릭 → 상세 시트 오픈
- 기대 결과: 창고 잔량 미니 차트가 창고명/수량과 함께 노출되고, 스크린리더에서 `창고명 잔량 N개`로 읽힌다.
6. **최근 이벤트 타임라인**
- 조건: 상세 시트에서 최근 이벤트가 존재하는 제품 선택
- 기대 결과: 이벤트 라벨, 수량 증감 색상, 거래처 정보 표기가 존재하며, 스크린리더는 `최근 이벤트 <라벨>`과 변화량/발생 시각을 낭독한다.
7. **빈 상태 / 오류 배너**
- 조건: 존재하지 않는 제품명으로 필터 → 빈 상태 확인, 이후 프록시로 500 오류를 강제
- 기대 결과: 빈 상태 문구와 오류 배너가 각각 노출되고 닫기 버튼 작동.
8. **권한 미보유 접근**
- 조건: `scope:inventory.view`가 없는 계정으로 직접 URL 접근
- 기대 결과: 라우터가 가드를 통해 접근을 차단하고, 감사 로그에는 권한 부족 사유가 남는다.
## 추가 메모
- E2E 수행 후 `doc/qa/inventory_data_replay.md`에 사용한 시드 데이터와 `last_refreshed_at` 값을 기록한다.
- 자동 새로고침으로 발생하는 API 호출 횟수는 30초 간격 기준 초당 0.033회이므로, 부하 테스트 시 50 동시 사용자까지 문제 없는지 모니터링한다.

View File

@@ -0,0 +1,45 @@
# 재고 요약 데이터 재현 가이드 (프런트 참조)
> 백엔드 원본: `../superport_api_v2/doc/qa/inventory_data_replay.md` QA/프런트 협업용 요약.
## 목적
- 스테이징/로컬에서 `/api/v1/inventory/summary` 계약을 검증할 때 동일한 데이터 세트를 확보한다.
## 순서 요약
1. **마이그레이션 실행**
```bash
for file in migration/0*_*.sql migration/1*_*.sql; do
psql "$DATABASE_URL" --set ON_ERROR_STOP=1 -f "$file"
done
```
2. **QA 시드 재적재**
```bash
psql "$DATABASE_URL" --set ON_ERROR_STOP=1 -f migration/120_seed_inventory_summary.sql
```
3. **마테뷰 리프레시**
```bash
../superport_api_v2/script/refresh_inventory_mv.sh --database-url "$DATABASE_URL"
```
4. **정합성 SQL**
```sql
SELECT product_id, total_quantity,
SUM((wb->>'quantity')::numeric) AS warehouse_sum
FROM inventory_balance_snapshots
CROSS JOIN LATERAL jsonb_array_elements(warehouse_balances) AS wb
GROUP BY product_id, total_quantity
HAVING SUM((wb->>'quantity')::numeric) <> total_quantity;
```
5. **API 스팟 체크**
```bash
curl -H "Authorization: Bearer <token>" \
"$API_BASE/api/v1/inventory/summary?page=1&page_size=50"
```
## 롤백
1. `DROP MATERIALIZED VIEW IF EXISTS inventory_balance_snapshots;`
2. `DROP MATERIALIZED VIEW IF EXISTS inventory_balance_events_view;`
3. 110/115 마이그레이션 재실행 → 리프레시 → 120 시드 재적재.
## 프런트 활용 팁
- QA가 위 절차로 데이터를 복원했다는 확인을 받은 뒤 UI/Golden 테스트를 실행한다.
- `last_refreshed_at` 값(응답 필드)을 QA 케이스에 기록해 자동 새로고침 UX 기준으로 활용한다.

File diff suppressed because it is too large Load Diff

View File

@@ -132,7 +132,7 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> API 기본 응답(`GET /approval/templates`, `GET /approval/templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
---
@@ -562,6 +562,7 @@ zipcodes ||--o{ customers : addressed
| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
| approval_action_id | 결재행위ID | bigint | - | - | Y | | | approval_actions.id |
| action_code | 행위코드 | varchar | 30 | - | Y | | | - |
| from_status_id | 변경전상태ID | bigint | - | - | N | | | approval_statuses.id |
| to_status_id | 변경후상태ID | bigint | - | - | Y | | | approval_statuses.id |
| action_at | 작업일시 | timestamp | - | now() | Y | | | - |
@@ -573,6 +574,8 @@ zipcodes ||--o{ customers : addressed
---
- `action_code``submit`, `approve`, `reject`, `comment`, `recall`, `resubmit` 등 표준 문자열을 저장해 참조 행위 레코드가 없어도 이력 복원이 가능하도록 한다.
### 3.22 `approval_templates` (결재_템플릿)
| 영문테이블명 | 한글테이블명 |
|---|---|
@@ -614,6 +617,61 @@ zipcodes ||--o{ customers : addressed
---
### 3.24 `inventory_balance_events_view` (재고_이벤트_뷰)
| 영문테이블명 | 한글테이블명 |
|---|---|
| inventory_balance_events_view | 재고_이벤트_뷰 |
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| event_id | 이벤트ID | bigint | - | - | Y | Y | Y | transaction_lines.id |
| transaction_id | 트랜잭션ID | bigint | - | - | Y | | | stock_transactions.id |
| transaction_line_id | 트랜잭션라인ID | bigint | - | - | Y | | | transaction_lines.id |
| transaction_no | 전표번호 | varchar | 40 | - | Y | | | - |
| product_id | 제품ID | bigint | - | - | Y | | | products.id |
| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id |
| transaction_type_id | 트랜잭션타입ID | bigint | - | - | Y | | | transaction_types.id |
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id |
| delta_quantity | 증감수량 | numeric | 20,6 | 0 | Y | | | - |
| event_kind | 이벤트종류 | varchar | 30 | - | Y | | | - |
| counterparty_name | 거래처요약 | varchar | 150 | - | N | | | - |
| event_occurred_at | 이벤트일시 | timestamp | - | - | Y | | | - |
| captured_at | 집계일시 | timestamp | - | now() | Y | | | - |
> 입고/출고/대여 라인을 `transaction_lines`에서 펼친 뷰다. `delta_quantity`는 입고/반납=양수, 출고/대여=음수 규칙을 따른다. `event_kind`는 `receipt`, `issue`, `rental_out`, `rental_return` 등 표준 문자열로 저장한다. 최신 변동 정렬을 위해 `event_occurred_at DESC`, `event_id DESC` 복합 인덱스를 생성하며, 뷰는 마테리얼라이즈드 형태로 5분마다 리프레시된다.
---
### 3.25 `inventory_balance_snapshots` (재고_집계_뷰)
| 영문테이블명 | 한글테이블명 |
|---|---|
| inventory_balance_snapshots | 재고_집계_뷰 |
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| product_id | 제품ID | bigint | - | - | Y | Y | Y | products.id |
| product_code | 제품코드 | varchar | 30 | - | Y | | | - |
| product_name | 제품명 | varchar | 100 | - | Y | | | - |
| vendor_id | 벤더ID | bigint | - | - | Y | | | vendors.id |
| vendor_name | 벤더명 | varchar | 100 | - | Y | | | - |
| total_quantity | 총재고수량 | numeric | 20,6 | 0 | Y | | | - |
| warehouse_balances | 창고별요약 | jsonb | - | '[]'::jsonb | Y | | | - |
| recent_event_id | 최근이벤트ID | bigint | - | - | N | | | inventory_balance_events_view.event_id |
| recent_event_kind | 최근이벤트종류 | varchar | 30 | - | N | | | - |
| recent_event_delta | 최근이벤트증감 | numeric | 20,6 | 0 | N | | | - |
| recent_event_counterparty | 최근거래처 | varchar | 150 | - | N | | | - |
| recent_event_warehouse_id | 최근창고ID | bigint | - | - | N | | | warehouses.id |
| recent_event_warehouse_name | 최근창고명 | varchar | 100 | - | N | | | - |
| recent_event_transaction_id | 최근전표ID | bigint | - | - | N | | | stock_transactions.id |
| recent_event_transaction_no | 최근전표번호 | varchar | 40 | - | N | | | - |
| recent_event_at | 최근이벤트일시 | timestamp | - | - | N | | | - |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | - |
| refreshed_at | 리프레시일시 | timestamp | - | now() | Y | | | - |
> `inventory_balance_events_view`를 제품 단위로 집계한 마테뷰. `warehouse_balances`는 `[ { "warehouse_id": 1, "warehouse_code": "WH-001", "warehouse_name": "1센터", "quantity": 80 } ]` 형태의 배열 JSON을 저장한다. `recent_*` 필드는 최신 이벤트 스냅샷을 캐싱해 `/api/v1/inventory/summary` 응답과 동일 구조를 제공하며, `refreshed_at`은 뷰가 새로 고쳐진 시각(UTC)을 그대로 보존한다. `updated_at` 컬럼으로 증분 조회가 가능하도록 `updated_at DESC` 인덱스를 생성한다.
---
## 4) FK 관계 (source → target)
- `menus.parent_menu_id``menus.id`
- `users.group_id``groups.id`
@@ -657,6 +715,7 @@ zipcodes ||--o{ customers : addressed
- 수량/단가 음수 금지(CHECK).
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
- 사용자의 소속 그룹(`users.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
- 재고 현황 조회 API는 읽기 전용 권한 스코프 `inventory.view`를 요구하며, 스코프가 없는 사용자는 `/api/v1/inventory/**` 경로에서 403(`INVENTORY_SCOPE_REQUIRED`)을 받는다.
- `users.employee_id`는 앞뒤 공백을 제거한 뒤 대소문자 구분 없이 중복 검증하며, 저장 시 대문자로 정규화한다.
- 자기 정보 수정(`PATCH /users/me`)에서는 `phone`, `email`, `password`만 변경할 수 있고, `password` 변경 시 기존 비밀번호 검증이 필수다.
- 비밀번호 재설정(관리자)은 8자 영문 대소문자+숫자 조합을 생성하고 이메일 발송 큐에 푸시한 뒤 `force_password_change=true`, `password_updated_at=now()`로 기록한다.
@@ -672,6 +731,7 @@ zipcodes ||--o{ customers : addressed
- `transaction_lines(transaction_id, line_no, is_deleted)`
- `transaction_customers(transaction_id, customer_id, is_deleted)`
- FK 및 조회 인덱스: 모든 `*_id`, `updated_at`, `is_deleted`, `is_active`.
- 재고 집계 마테뷰 인덱스: `inventory_balance_events_view(event_occurred_at DESC, event_id DESC)`, `inventory_balance_snapshots(updated_at DESC)` 및 필요 시 `inventory_balance_snapshots``warehouse_balances` JSONB GIN 인덱스.
---
@@ -714,4 +774,5 @@ zipcodes ||--o{ customers : addressed
- `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장.
- 낙관적 잠금(선택): `version`(int) + ETag.
- 병렬 결재 확장(선택): `approval_steps``group_no`, `approval_mode(all|any)` 도입.
- 감사 로그(`approval_audits`) 적재 시 `approval.audit.recorded` 이벤트를 Kafka(토픽 예: `approval_audit_events`)와 WebSocket 브로드캐스트로 발행한다. 이벤트 구성은 `{ event, version, emitted_at, request_id, audit_id, summary }` JSON으로 정의하며, 운영 환경별 엔드포인트는 `event_bus.kafka.*`, `event_bus.websocket.*` 설정으로 분리한다.
- `/health` 응답의 `build_version``config/default.toml``[app].build_version`을 사용하며, `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.