Compare commits

41 Commits

Author SHA1 Message Date
JiWoong Sul
046b27a51a feat(approvals): 결재 상세 전표 카드 개편
- 상단 요약부를 전용 Highlight 카드로 교체하고 출발지/도착지 및 라인 품목 정보를 한 화면에서 제공
- 결재 상세 다이얼로그에 통화 포매터를 연결하고 새 카드 위젯을 포함해 금액·수량을 명시적으로 표현
- 입고/출고/대여 별 라우팅/품목 정보를 검증하는 위젯 테스트를 추가해 회귀 안정성을 확보
2025-11-14 15:53:21 +09:00
JiWoong Sul
6d09e72142 feat(approvals): 결재 상세 전표 연동과 스코프 권한 매핑 확장
- 결재 상세 다이얼로그에 전표 요약·라인·고객 섹션을 추가하고 현재 사용자 단계 강조 및 비고 입력 검증을 개선함

- 대시보드·결재 목록에서 전표 리포지토리와 AuthService를 주입해 상세 진입과 결재 관리 이동 버튼을 제공함

- StockTransactionApprovalInput이 template/steps를 config 노드로 직렬화하도록 변경하고 통합 테스트를 갱신함

- scope 권한 문자열을 리소스권으로 변환하는 PermissionScopeMapper와 단위 테스트를 추가하고 AuthPermission을 연동함

- 재고 메뉴 정렬, 상세 컨트롤러 오류 리셋, 요청자 자동완성 상태 동기화 등 주변 UI 버그를 수정하고 테스트를 보강함
2025-11-14 01:57:02 +09:00
JiWoong Sul
e3cf068bf8 feat(approval): 결재 최종 승인 시 전표 상태 자동 전이
- approval_controller에서 결재 단계 승인 완료 직후 전표 approve/complete API를 자동 호출하도록 연동\n- 승인/완료 상태명 판별 로직과 헬퍼를 보강해 출고·대여 전표도 동일하게 처리\n- approval_controller 테스트에 승인/완료/대기 자동 전이 시나리오를 추가해 회귀를 방지
2025-11-14 01:47:47 +09:00
JiWoong Sul
80f3df770d feat(dashboard): 결재 역할 뱃지와 문서 정합성 반영
- doc/frontend_backend_alignment_report.md에 roles 필터 적용 배경과 UI 반영 사항을 기록

- lib/features/dashboard/data/dashboard_summary_dto.dart에서 roles 파싱과 enum 매핑 로직, 문자열 리스트 util 추가

- lib/features/dashboard/domain/entities/dashboard_pending_approval.dart에 역할 enum과 도메인 필드 추가

- lib/features/dashboard/presentation/pages/dashboard_page.dart에서 결재 카드 헤더/설명 수정 및 역할 뱃지 렌더링 지원

- test/features/dashboard/data/dashboard_summary_dto_test.dart 신규 작성해 DTO→도메인 매핑과 무시 케이스를 검증

- test/features/masters/user/presentation/pages/user_page_test.dart에서 사용되지 않는 PermissionManager import 제거
2025-11-13 17:30:27 +09:00
JiWoong Sul
753f76e952 feat(menu-permissions): 메뉴 API 연동으로 사이드바 권한 정비
- .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다

- lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다

- lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다

- test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다
2025-11-12 18:29:03 +09:00
JiWoong Sul
f767c44573 docs(menu): 사이드바 권한 동기화 지침 추가
- frontend_backend_alignment_report.md에 사이드바/그룹 권한 TODO와 테스트 계획을 정리
- stock_approval_system_api_v4.md에 메뉴/그룹 권한 API 규칙과 응답 예시를 추가
- stock_approval_system_spec_v4.md에 공식 메뉴 표와 기본 그룹 권한 케이스를 기재
2025-11-12 00:27:17 +09:00
JiWoong Sul
eaac3c23ae chore(inventory): 리스트 수정 단추를 세부 액션으로 한정
- 입고·출고·대여 페이지 상단의 "선택 항목 수정" 버튼을 제거하고 상세 패널 내 수정 흐름만 노출\n- 제품 상세 다이얼로그에서 준비 중인 히스토리 섹션과 상수 정의를 제거해 UI를 단순화\n- 관련 위젯 테스트에서 히스토리 탭 검증을 삭제하고 문구를 최신 설명으로 갱신
2025-11-11 18:05:58 +09:00
JiWoong Sul
d603fd5c17 fix(inventory): 상세 편집 플로우 안정화
- inbound/outbound/rental controller에 fetchTransactionDetail을 추가해 상세 동기화를 지원

- 각 페이지 초기화 시 결재 초안 로딩 권한을 PermissionScope에서 확인하도록 수정

- 상세 패널의 수정 버튼이 모달과 연동되도록 흐름을 정리하고 생성/수정 후 상세 데이터를 재조회

- 기존 결재 메모 필드는 등록 이후 수정 불가하도록 UI와 입력 상태를 비활성화

- 신규 상세-수정 위젯 테스트와 리포지토리 스텁 fetchDetail 구현을 추가

- flutter analyze, flutter test를 실행해 회귀를 점검
2025-11-11 16:28:49 +09:00
JiWoong Sul
04c6bc9a2e fix(inventory-ui): 필터·창고 선택 상호작용 정비
- 입고/출고/대여 상태·대여구분 드롭다운에 제네릭 타입을 명시해 null 허용 옵션에서도 SDK 경고를 제거\n- 입고 목록 include 라벨에 customers→'고객 포함'을 추가해 UI 설명을 통일\n- WarehouseSelectField가 선택된 라벨과 동일한 검색어일 때 전체 옵션을 복원하고 suggestions를 초기화하도록 로직을 보강\n- FilterBar의 필터 적용 배지를 윤곽 스타일로 교체하고 테마 색상 대비를 조정\n- DatePicker 버튼을 outline 버튼 자체에서 정렬/아이콘 슬롯으로 구성해 긴 날짜 라벨이 잘리지 않도록 개선\n- 필터 배지/날짜 버튼 UI 변경에 맞춰 인벤토리 요약 골든 이미지를 갱신\n- flutter analyze, flutter test로 회귀를 검증
2025-11-10 01:11:04 +09:00
JiWoong Sul
81f419a8a6 feat(dashboard): 대시보드 KPI 카드를 대여 지표로 재편
- KPI 프리셋을 입고/출고/대여/결재 대기로 재정렬하고 고객문의 카드를 제거\n- 백엔드 정합성 리포트에 rental KPI 제공과 프런트 반영 사실을 명시
2025-11-10 01:07:16 +09:00
JiWoong Sul
47cc62a33d 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를 통과
2025-11-09 01:13:10 +09:00
JiWoong Sul
486ab8706f fix(dialog): 결재 상세 닫기 버튼 제거
- 결재 상세 다이얼로그 호출 시 actions를 비워 기본 닫기 버튼이 생성되지 않도록 처리

- 헤더 우측 닫기 아이콘만 남겨 중복 버튼 노출 문제를 해결
2025-11-07 19:20:05 +09:00
JiWoong Sul
2f8b529506 feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화

- 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환

- SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거

- 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지

- detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
2025-11-07 19:02:43 +09:00
JiWoong Sul
1f78171294 fix(masters): 메뉴 권한 목록 라벨과 액션 영역 정리
- group_permission_page에서 메뉴 경로를 allAppPages 라벨로 매핑하고 없는 경우 메뉴명을 유지하도록 MenuItem 표시를 보완했음
- group_permission_page와 user_page의 액션 버튼을 Row로 재구성하고 여백을 고정해 버튼 정렬을 안정화했음
- group_permission_page와 user_page의 ShadTable 컬럼 폭을 조정해 액션 영역이 잘리지 않도록 했음
- flutter analyze, flutter test
2025-11-05 18:37:03 +09:00
JiWoong Sul
fa0bda5ea4 feat(frontend): 승인 템플릿 API 통합 및 디버그 로그인 확장
- docs 폴더 문서를 최신 API 계약으로 갱신하고 가이드를 다듬었다\n- approvals data/presentation 레이어를 API v4 스펙에 맞춰 리팩터링했다\n- approver 자동완성 위젯을 신규 공유 레포지토리에 맞춰 교체하고 UX를 보강했다\n- inventory/rental 페이지 테이블 초기화 시 승인 기준 연동을 정비했다\n- 로그인 페이지 디버그 버튼을 tera/exa 계정으로 분리해 QA 로그인을 단순화했다\n- get_it 등록과 테스트 케이스를 신규 공유 리포지토리에 맞춰 업데이트했다
2025-11-05 17:05:38 +09:00
JiWoong Sul
3e83408aa7 feat(approvals): 결재 접근 차단 대응과 전표 전이 메모 전달 강화
- approvals 모듈에서 APPROVAL_ACCESS_DENIED 응답을 포착하여 ApprovalAccessDeniedException으로 변환하고 접근 거부 시 토스트·대시보드 리다이렉트를 처리

- approval history 조회가 서버 action id에 맞춰 필터링되도록 repository·controller·테스트를 보강

- 재고 트랜잭션 상태 전이 API 호출에 note를 전달하도록 repository·컨트롤러·통합/단위 테스트를 업데이트

- 승인 플로우 QA 체크리스트 및 연동 문서를 최신 계약과 테스트 흐름으로 업데이트
2025-10-31 16:43:14 +09:00
JiWoong Sul
d76f765814 feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
2025-10-31 01:05:39 +09:00
JiWoong Sul
259b056072 fix(inventory): 파트너 연동 및 상세 모달 동작 안정화
- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출

- 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정

- 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장

- 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화

- 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일

- 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
2025-10-27 20:02:30 +09:00
JiWoong Sul
14624c4165 feat(user): 사용자 자기정보 편집과 관리자 재설정 플로우를 연동
- lib/widgets/app_shell.dart에서 내 정보 다이얼로그를 추가하고 UserRepository.updateMe·비밀번호 변경 로직을 연결

- lib/features/masters/user/* 모듈에 phone·forcePasswordChange·passwordUpdatedAt 필드를 반영하고 reset-password/update-me API를 사용

- lib/core/validation/password_rules.dart을 신설해 비밀번호 정책 검증을 공통화하고 신규 위젯·테스트에서 재사용

- doc/stock_approval_system_api_v4.md 등 문서를 users 스펙 개편 내용으로 갱신하고 user_management_plan.md를 추가

- test/widgets/app_shell_test.dart 등에서 자기정보 수정·비밀번호 재설정 시나리오를 검증하고 기존 테스트를 보강
2025-10-26 17:05:47 +09:00
JiWoong Sul
9beb161527 feat(pagination): 공통 컨트롤 도입과 사용자 관리 가이드 추가
- 테이블 푸터에서 SuperportPaginationControls를 사용하도록 각 관리 페이지 페이지네이션 로직을 정리

- SuperportPaginationControls 위젯을 추가하고 SuperportTable 푸터를 개선해 페이지 사이즈 선택과 이동 버튼을 분리

- 사용자 등록·계정 관리 요구사항을 문서화한 doc/user_setting.md를 작성하고 AGENTS.md 코멘트 규칙을 업데이트

- flutter analyze를 수행해 빌드 경고가 없음을 확인
2025-10-24 16:24:02 +09:00
JiWoong Sul
9d6cbb1ab2 대시보드 결재 상세 진입 지원 2025-10-23 20:19:59 +09:00
JiWoong Sul
7e933a2dda 번호 자동 부여 대응 및 API 공통 처리 보강 2025-10-23 14:02:31 +09:00
JiWoong Sul
09c31b2503 재고 상세 다이얼로그화 및 마스터 레이아웃 개선 2025-10-22 18:52:21 +09:00
JiWoong Sul
a14133df52 인벤토리 include 필터 문구 수정 2025-10-22 18:48:00 +09:00
JiWoong Sul
cefcfaac0d 자동완성 포커스 유지 및 디버그 로그 추가 2025-10-22 14:31:45 +09:00
JiWoong Sul
f4dc83d441 계정 정보 다이얼로그 추가 및 전체 목록 페치 개선 2025-10-22 01:05:47 +09:00
JiWoong Sul
6b58effc83 테스트 로그인 버튼 강조 스타일 적용 2025-10-20 21:23:30 +09:00
JiWoong Sul
3df9aa0bec 환경 변수 로더 경로 확장 2025-10-20 15:05:37 +09:00
JiWoong Sul
d69e5431ce 개발 환경 실서버 URL 적용 2025-10-18 22:23:52 +09:00
JiWoong Sul
6b8fe67ec7 테스트 로그인 실서버 연동 2025-10-17 17:20:13 +09:00
JiWoong Sul
b3da3a5c60 정합성 문서 및 결재 입력 테스트 갱신 2025-10-17 16:09:57 +09:00
JiWoong Sul
7522f46693 백엔드 계약 문서 동기화하고 DTO 파서 정합성 확장 2025-10-17 00:52:30 +09:00
JiWoong Sul
efed3c1a6f 결재 API 계약 보완 및 테스트 정리 2025-10-16 18:53:22 +09:00
JiWoong Sul
9e2244f260 결재 및 마스터 모듈을 v4 API 계약에 맞게 조정 2025-10-16 17:27:41 +09:00
JiWoong Sul
d5c99627db API v4 계약 반영하고 보고서·입출고 화면 실연동 강화 2025-10-16 14:57:07 +09:00
JiWoong Sul
7e0f7b1c55 chore: 통합 테스트 환경과 보고서 리모트 구성 2025-10-14 18:11:57 +09:00
JiWoong Sul
8067416c09 feat: 결재·마스터 실연동 업데이트 2025-10-14 18:10:24 +09:00
JiWoong Sul
1325109fba refactor: 인벤토리 테이블 스펙과 도메인 계층 정비 2025-10-14 18:09:26 +09:00
JiWoong Sul
8d3b2c1e20 docs: Stage 5/7 완료 정리 및 환경 변수 가이드 갱신 2025-10-14 18:07:11 +09:00
JiWoong Sul
c072eb1328 feat: 재고 상태 전이 플래그 적용 및 실패 메시지 정비 2025-10-14 18:06:40 +09:00
JiWoong Sul
9f61b305d4 chore: API 오류 매핑과 Failure 파서 고도화 2025-10-14 18:06:25 +09:00
399 changed files with 63602 additions and 11500 deletions

View File

@@ -1,4 +1,4 @@
API_BASE_URL=http://localhost:8080
API_BASE_URL=http://3.35.41.39:8080
# 기능 플래그 (true/false)
# 백엔드 엔드포인트 준비 상태에 따라 개별 화면 제어에 활용
@@ -10,5 +10,32 @@ FEATURE_USERS_ENABLED=false
FEATURE_GROUPS_ENABLED=false
FEATURE_MENUS_ENABLED=false
FEATURE_GROUP_PERMISSIONS_ENABLED=false
FEATURE_APPROVALS_ENABLED=false
# 결재 기능은 개발/운영 기본값이 true이지만, 백엔드 미준비 시 false로 전환
FEATURE_APPROVALS_ENABLED=true
# Approval Flow v2 기능 토글 (feature.approval_flow_v2)
FEATURE_APPROVAL_FLOW_V2=false
FEATURE_ZIPCODE_SEARCH_ENABLED=false
# 재고 상태 전이 버튼 제어 (운영 기본값 false)
FEATURE_STOCK_TRANSITIONS_ENABLED=true
# 개발 기본 권한 (view 전용)
PERMISSION__/dashboard=view
PERMISSION__/inventory/summary=view
PERMISSION__/stock-transactions=view
PERMISSION__/vendors=view
PERMISSION__/products=view
PERMISSION__/warehouses=view
PERMISSION__/customers=view
PERMISSION__/users=view
PERMISSION__/groups=view
PERMISSION__/menus=view
PERMISSION__/group-menu-permissions=view
PERMISSION__/approvals=view
PERMISSION__/approval-steps=view
PERMISSION__/approval-histories=view
PERMISSION__/approval/templates=view
PERMISSION__/reports=view
PERMISSION__/reports/transactions=view
PERMISSION__/reports/approvals=view
PERMISSION__/zipcodes=view
PERMISSION__scope:inventory.view=view

View File

@@ -1,4 +1,4 @@
API_BASE_URL=https://api.superport.example.com
API_BASE_URL=http://3.35.41.39:8080
# 기능 플래그 (true/false)
FEATURE_VENDORS_ENABLED=true
@@ -10,4 +10,6 @@ FEATURE_GROUPS_ENABLED=true
FEATURE_MENUS_ENABLED=true
FEATURE_GROUP_PERMISSIONS_ENABLED=true
FEATURE_APPROVALS_ENABLED=true
FEATURE_APPROVAL_FLOW_V2=false
FEATURE_ZIPCODE_SEARCH_ENABLED=true
FEATURE_STOCK_TRANSITIONS_ENABLED=false

20
.env.staging.example Normal file
View File

@@ -0,0 +1,20 @@
# Staging 환경 통합 테스트용 설정 예시
# flutter test integration_test/stock_transaction_state_flow_test.dart \
# --dart-define=STAGING_RUN_TRANSACTION_FLOW=true \
# --dart-define=STAGING_API_BASE_URL=${STAGING_API_BASE_URL} \
# --dart-define=STAGING_API_TOKEN=${STAGING_API_TOKEN} \
# --dart-define=STAGING_TRANSACTION_TYPE_ID=${STAGING_TRANSACTION_TYPE_ID} \
# --dart-define=STAGING_TRANSACTION_STATUS_ID=${STAGING_TRANSACTION_STATUS_ID} \
# --dart-define=STAGING_WAREHOUSE_ID=${STAGING_WAREHOUSE_ID} \
# --dart-define=STAGING_EMPLOYEE_ID=${STAGING_EMPLOYEE_ID} \
# --dart-define=STAGING_PRODUCT_ID=${STAGING_PRODUCT_ID} \
# --dart-define=STAGING_CUSTOMER_ID=${STAGING_CUSTOMER_ID}
STAGING_API_BASE_URL=https://staging-api.example.com
STAGING_API_TOKEN=replace-with-staging-token
STAGING_TRANSACTION_TYPE_ID=0
STAGING_TRANSACTION_STATUS_ID=0
STAGING_WAREHOUSE_ID=0
STAGING_EMPLOYEE_ID=0
STAGING_PRODUCT_ID=0
STAGING_CUSTOMER_ID=0

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

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

View File

@@ -9,6 +9,7 @@ Place all Flutter source in `lib/`, keeping cross-cutting pieces in `lib/core/`
- `flutter test` — run the unit/widget suite; add `--coverage` when validating overall health.
- `flutter run -d chrome --web-renderer canvaskit` — local web run matching production rendering.
- `dart run build_runner build --delete-conflicting-outputs` — regenerate freezed/json_serializable files when models change.
- After every code addition, modification, or deletion, run both `flutter analyze` and `flutter test` before considering the task complete.
## Coding Style & Naming Conventions
Use Flutters two-space indentation and run `dart format .` before committing. Follow the Clean Architecture layering: DTOs/remote in `data`, domain interfaces/use cases in `domain`, controllers/widgets in presentation. File names use `snake_case.dart`; classes use `UpperCamelCase`; methods and fields use `lowerCamelCase`. Prefer `const` constructors/widgets, and use `shadcn_ui` components (especially `ShadTable`) for new screens. Register dependencies in `lib/injection_container.dart` via `get_it`.
@@ -19,9 +20,23 @@ Each feature ships with unit tests (`*_test.dart`) living beside the source modu
## Commit & Pull Request Guidelines
Commits follow the existing Superport convention: Korean imperative summaries with optional English technical nouns, e.g., `"대여 상세 테이블 정렬 수정"`. For PRs, include (1) a concise summary of user-visible impact, (2) screenshots or GIFs for UI changes, (3) linked issue or JIRA reference, and (4) verification notes (commands run, tests passing). Squash before merge unless release tagging requires history.
## Change Comment Guidelines
- Document every change with a Conventional Commit style summary line: `type(scope): <summary in Korean>`. Use Korean imperatives for the message body while keeping the scope in English (module/package).
- Follow the summary with a blank line and bullet points that detail the concrete modifications, each starting with `- ` and phrased in past-tense or imperative sentences that mention impacted modules.
- Include tests, scripts, and document updates in the bullet list so reviewers understand coverage.
- When multiple subsystems change, group bullets logically (e.g., backend, frontend, docs) and keep each bullet under 120 characters.
- Example:
```
feat(api): 헬스체크에 빌드 메타데이터 노출
- BUILD_VERSION 환경변수를 우선으로 사용하고 git 커밋 해시를 fallback으로 설정하는 build.rs를 추가
- 헬스체크 응답에 빌드 버전을 포함하고 pagination 쿼리 파싱을 위해 serde_urlencoded를 도입하여 테스트를 보강
- 원격 배포 스크립트에서 BUILD_VERSION을 로컬/원격 실행에 전달하고 문서를 최신 플로우로 업데이트
```
## Architecture & Environment Notes
Initialize environments via `.env.development` / `.env.production` and load them through `Environment.initialize()` before bootstrapping DI. New data sources should expose repository interfaces in `domain/` and rely on the shared `ApiClient` instance. Do not use mock data in the application; always call the real backend (staging/production as appropriate). If an endpoint is not available, mark the feature as disabled behind a feature flag rather than mocking.
- Frontend behaviour/data models must strictly follow the deployed backend contract. (프론트엔드는 백엔드 API 계약을 절대 우선으로 준수해야 하며, 누락된 기능은 백엔드 수정 요청 후 진행한다.)
- Frontend behaviour/data models must strictly follow the deployed backend contract. If a required endpoint is missing, request a backend fix rather than introducing mock data.
---
@@ -37,7 +52,9 @@ Initialize environments via `.env.development` / `.env.production` and load them
## Notification Policy
- Every task completion must trigger a notification via the configured `notify.py` workflow so users are consistently alerted.
- Run `notify.py` right before delivering the final wrap-up report or ending the conversation so the notification is sent at task completion.
- Use the `notify.py` script located at `/Users/maximilian.j.sul/.codex/notify.py`.
- Update relevant progress documents when a task is completed (e.g., `doc/approval_flow_frontend_task_plan.md`).
---

17
CHANGELOG.md Normal file
View File

@@ -0,0 +1,17 @@
# 변경 기록
## 2025-11-08
- 재고 현황 Summary/Detail UI를 정식 릴리스했습니다. 읽기 전용 권한(`inventory.view`)을 가진 사용자는 자동 새로고침 토글, 창고 필터, 상세 시트(그래프/타임라인)를 통해 최신 잔량을 확인할 수 있습니다.
- 테스트 커맨드
- `cargo test -- tests::inventory_summary`
- `flutter analyze`
- `flutter test --coverage`
## 2025-10-20
- 재고 입·출·대여 컨트롤러가 `Failure.describe()` 기반으로 오류를 노출해 승인/취소 흐름에서 서버 메시지가 그대로 전달됩니다.
- 우편번호 검색 다이얼로그와 창고 선택 위젯이 API 예외를 상세히 표기하며, 관련 위젯 테스트를 추가했습니다.
- 승인/재고 플로우 주요 컨트롤러 단위 테스트에 실패 메시지 검증을 포함해 회귀를 방지합니다.
## 2025-10-18
- 재고 트랜잭션 목록/상세/상태 전이를 실 API(`StockTransactionRepositoryRemote`)에 연결하고 보고서 다운로드 UX를 개선했습니다.
- 결재 도메인에서 `canProceed` API 검증과 기능 플래그 기본값 조정으로 사용성 이슈를 해소했습니다.

View File

@@ -17,6 +17,10 @@
- `API_BASE_URL` — 백엔드 API 베이스 URL
- `FEATURE_*` — 기능 플래그 (예: `FEATURE_VENDORS_ENABLED`)
- `FEATURE_APPROVALS_ENABLED` — 기본값은 개발·운영 모두 `true`, 단 결재 백엔드가 준비되지 않았으면 `.env.*`에서 `false`로 내려 임시 비활성화한다.
- `FEATURE_STOCK_TRANSITIONS_ENABLED` — 재고 상태 전이(상신/승인/취소) 버튼 노출 제어. 운영 환경은 백엔드 배포 전까지 `false`로 유지하고, 개발 환경에서만 필요 시 `true`로 전환한다.
QA 토큰/스코프 발급 및 검증 절차는 `doc/qa/staging_transaction_flow.md`를 참고한다.
2) 의존성 설치
@@ -30,6 +34,24 @@ flutter pub get
flutter run -d chrome --web-renderer canvaskit --dart-define=ENV=development
```
## API 연동
- 모든 HTTP 호출은 `ApiRoutes.apiV1`(`/api/v1`) 프리픽스를 사용하며, `Environment.initialize()` 완료 후 `ApiClient`를 통해 수행한다.
- 현재 연동된 주요 리소스
- `/customers`, `/vendors`, `/products`, `/uoms`, `/users`, `/groups`, `/menus`, `/group-menu-permissions`
- `/warehouses`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`
- `/approvals`, `/approval-steps`, `/approval/templates`, `/approval-histories`
- `/stock-transactions`(lines/customers 포함), `/reports/downloads`
- `/zipcodes` (우편번호 검색)
- API 응답 실패는 `Failure.describe()`를 통해 토스트/다이얼로그로 노출되며, 필드 검증 오류와 일반 메시지를 자동 병합한다.
### 필수 환경 변수
- `ENV``development`/`production` 중 하나로 `.env.<env>` 파일을 선택
- `API_BASE_URL` — 서버 베이스 URL (`/api/v1` 제외)
- `FEATURE_*` — 도메인별 기능 플래그 (`FEATURE_APPROVALS_ENABLED`, `FEATURE_PRODUCTS_ENABLED` 등)
- `PERMISSION__<resource>` — 권한 게이트에서 사용할 리소스별 액션 집합(예: `PERMISSION__stock-transactions=read,submit,approve`)
## 구조
- `lib/core/` — 공통 구성(환경, 네트워크, 라우팅)

View File

@@ -1,4 +1,4 @@
API_BASE_URL=http://localhost:8080
API_BASE_URL=http://3.35.41.39:8080
TIMEOUT_MS=15000
LOG_LEVEL=debug

View File

@@ -10,5 +10,6 @@ FEATURE_USERS_ENABLED=false
FEATURE_GROUPS_ENABLED=false
FEATURE_MENUS_ENABLED=false
FEATURE_GROUP_PERMISSIONS_ENABLED=false
FEATURE_APPROVALS_ENABLED=false
# 결재 기능은 운영 기본값을 true로 유지하되, 백엔드 미준비 시 false로 내려 비활성화한다.
FEATURE_APPROVALS_ENABLED=true
FEATURE_ZIPCODE_SEARCH_ENABLED=false

View File

@@ -1,151 +1,178 @@
# ApiClient 설계서 (Dio 기반, Superport 스타일)
본 문서는 Superport 레포 스타일과 동일한 인증/네트워킹 패턴을 본 프로젝트에 적용하기 위한 ApiClient 설계를 정의한다. 실제 구현은 이후 단계에서 진행한다(문서 선정리).
## Implementation Snapshot (2025-10-31)
-`ApiClient`/`ApiErrorMapper`/`AuthInterceptor`가 구현되어 모든 원격 저장소가 공통 경로를 사용한다 (`lib/core/network/api_client.dart`, `lib/core/network/api_error.dart`, `lib/core/network/interceptors/auth_interceptor.dart`).
- ✅ DI/환경 설정은 `Environment.initialize()` 이후 `lib/injection_container.dart`에서 ApiClient와 TokenStorage, 인터셉터를 등록한다.
- ✅ 단위 테스트가 경로·쿼리·에러 매핑·토큰 재발급 동작을 검증한다 (`test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`).
- ✅ 문서/코드가 `doc/stock_approval_system_api_v4.md` 계약과 동기화됐으며, 엔드포인트별 Remote Repository 테스트가 `include`·필터 직렬화를 검증한다.
## 1) 목표
- 단일 진입점 ApiClient(Dio 래퍼)로 모든 네트워크 호출 일원화
- 환경 변수 기반 BaseURL/타임아웃/로그 레벨 설정
- 인증 토큰 주입, 401 자동 처리(토큰 갱신 → 재시도), 에러 매핑 일관화
- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공
- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공 (구현 완료)
## 2) 의존성(추가 예정)
- dio: ^5.x (HTTP 클라이언트)
- get_it: ^7.x (DI) — 이미 사용 중
- flutter_secure_storage(or web localStorage 대체): 토큰 저장(플랫폼별 분기)
- intl: ^0.20.x (기존)
- 개발 전용: pretty_dio_logger(선택)
## 2) 의존성
- `dio: ^5.x`, `get_it: ^7.x`, `flutter_secure_storage`, `intl`, `pretty_dio_logger`(dev) — `pubspec.yaml`에 반영됨.
- 테스트: `mocktail`, `flutter_test` (already configured).
## 3) 환경 변수
- API_BASE_URL: 예) https://api.example.com/api/v1
- API_CONNECT_TIMEOUT_MS: 예) 15000
- API_RECEIVE_TIMEOUT_MS: 예) 30000
- LOG_LEVEL: debug|info|warn|error
- `API_BASE_URL`, `API_CONNECT_TIMEOUT_MS`, `API_RECEIVE_TIMEOUT_MS`, `LOG_LEVEL`
- 로드 순서: `await Environment.initialize()``injection_container.dart`에서 `ApiClient` 생성
- `.env.*` 파일에 샘플 값이 포함되어 있으며, `FeatureFlags.initialize()` 이전에 호출된다.
로드 순서: `await Environment.initialize()` → DI에서 ApiClient 생성 시 사용
## 4) 인증 방식(슈퍼포트와 동일)
- 로그인: `POST /auth/login``{ data: { token: string, user?: {...} } }`
- 요청 헤더: `Authorization: Bearer <token>`
- 토큰 저장: 보안 저장소(모바일)/localStorage(웹) 또는 httpOnly 쿠키(백엔드 정책에 따름)
- 토큰 갱신(선택): `POST /auth/refresh``{ data: { token: string } }`
- 401 처리: `AuthInterceptor`가 401 수신 시 자동 갱신 → 원요청 재시도(1회). 갱신 실패 시 로그아웃/세션 초기화 및 로그인 화면 이동
## 4) 인증 스택
- 로그인/토큰 재발급 플로우는 Superport 백엔드와 동일 (`POST /auth/login`, `POST /auth/refresh`).
- `AuthInterceptor`가 저장소에서 토큰을 읽어 Authorization 헤더를 주입하고, 401 발생 시 리프레시 콜백을 호출해 한 번만 재시도한다 (`lib/core/network/interceptors/auth_interceptor.dart:45`).
- `TokenStorage`는 플랫폼별 저장소(web/local) 구현을 제공한다 (`lib/core/network/services/token_storage.dart`).
## 5) 에러 매핑 정책
- 400 BAD_REQUEST: 검증 오류 → 필드 에러로 매핑
- 404 NOT_FOUND: 리소스 없음
- 409 CONFLICT: 유니크 충돌/상태 충돌
- 422 UNPROCESSABLE_ENTITY: 비즈니스 규칙 위반(예: 출고 고객 미선택, blocking 전이)
- 500+: 서버 오류 → 공통 메시지 + 로그 수집
- 표준 포맷: `{ error: { code, message, details? } }` 수용. 비표준 응답은 DioException 메시지로 대체
- `ApiErrorMapper``DioException``Failure`로 변환해 코드/메시지를 표준화한다 (`lib/core/network/api_error.dart`).
- HTTP 상태별 매핑: 400(검증), 401(세션 만료), 403(권한), 404(리소스 없음), 409(충돌), 422(업무 규칙), 500+(서버 오류).
- 테스트 `test/core/network/api_client_test.dart:62`가 매핑 결과를 검증한다.
## 6) 쿼리 규약/헬퍼
- 페이지네이션: `page`, `page_size`
- 정렬: `sort`, `order=asc|desc`
- 검색: `q`
- 증분: `updated_since`
- include 확장: `include=lines,customers,approval`
- 헬퍼: `buildQuery({page, pageSize, q, sort, order, include, filters})`
## 7) ApiClient 스켈레톤(인터페이스)
## 6) 쿼리 헬퍼와 규약
- `ApiClient.buildQuery` 페이지네이션(`page`,`page_size`), 정렬(`sort`,`order`), 검색(`q`), 증분(`updated_since`), include, 맞춤 필터를 직렬화한다 (`lib/core/network/api_client.dart:25`).
- `buildPath`가 세그먼트를 안전하게 결합한다 (`lib/core/network/api_client.dart:14`).
- 모든 Remote Repository는 해당 헬퍼를 사용하도록 테스트로 강제된다 (예: `test/features/approvals/data/approval_repository_remote_test.dart:42`).
## 7) ApiClient 인터페이스
```dart
/// 네트워크 공통 클라이언트 (Dio 래퍼)
class ApiClient {
// 내부 Dio 인스턴스(외부 사용 금지, 필요한 경우 read-only 게터 제공)
final Dio _dio;
ApiClient({required Dio dio}) : _dio = dio;
Dio get dio => _dio; // 과도한 사용은 지양하고, 가능하면 아래 헬퍼 사용
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
});
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
});
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
});
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
});
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
Future<Response<T>> patch<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken});
static Map<String, dynamic> buildQuery({...});
static String buildPath(Object base, [Iterable<Object?> segments = const []]);
}
```
구현 시 기본 옵션
- BaseOptions: baseUrl, connectTimeout, receiveTimeout
- 공통 헤더: `Accept: application/json`, `Authorization: Bearer <token?>`
- Interceptors:
- `AuthInterceptor`(요청 전 토큰 주입, 401에서 갱신/재시도)
- `LoggingInterceptor`(개발 모드에서만)
## 8) Interceptor 설계
- AuthInterceptor
- 요청: 저장된 토큰이 있으면 `Authorization` 헤더 추가
- 응답: 401이면 1) 갱신 중 동시성 잠금 2) 갱신 성공 시 대기 중 요청 재시도 3) 실패 시 토큰 삭제/로그아웃
- Retry 정책: 재시도는 1회, idempotent GET/HEAD 위주. POST/PATCH는 401 갱신 후 재시도 1회만 허용
## 8) 인터셉터 구성
- `AuthInterceptor`: 토큰 주입 + 401 재시도, 동시 갱신 방지 큐 적용.
- `LoggingInterceptor`: 개발 모드에서만 pretty 출력 (`lib/core/network/interceptors/logging_interceptor.dart`).
- `RetryInterceptor`는 필요 시 idempotent 요청 재시도를 담당한다 (`lib/core/network/interceptors/retry_interceptor.dart`).
## 9) 표준 응답 파서
- 목록: `{ items: [...], page, page_size, total }`
- 단건: `{ data: {...} }`
- 제네릭 파서 유틸 제공: `parseList<T>(res, fromJson)`, `parseItem<T>(res, fromJson)`
## 10) 샘플 사용 (Repository)
- 목록: `{ items, page, page_size, total }``PaginatedResult<T>` (`lib/core/common/models/paginated_result.dart`).
- 단건: `{ data: {...} }``ApiClient.unwrapAsMap/unwrap` 헬퍼가 추출한다 (`lib/core/network/api_client.dart:91`).
## 10) 사용 예시
```dart
class VendorRepositoryImpl implements VendorRepository {
final ApiClient api;
VendorRepositoryImpl(this.api);
@override
Future<Paged<Vendor>> list({int page = 1, int pageSize = 20, String? q}) async {
final res = await api.get('/vendors', query: { 'page': page, 'page_size': pageSize, if (q != null) 'q': q });
return parseList<Vendor>(res.data, Vendor.fromJson);
}
@override
Future<Vendor> create(VendorCreate body) async {
final res = await api.post('/vendors', data: body.toJson());
return parseItem<Vendor>(res.data, Vendor.fromJson);
}
}
final response = await _api.get<Map<String, dynamic>>(
ApiRoutes.approvals,
query: ApiClient.buildQuery(page: page, pageSize: pageSize, include: ['requested_by']),
);
return ApprovalDto.parsePaginated(response.data ?? const {});
```
## 11) 보안/스토리지
- 토큰 저장: 플랫폼별로 적합한 저장소 사용(웹은 localStorage, 모바일 secure storage)
- 민감정보 로깅 금지(토큰/쿠키 마스킹)
- CORS/쿠키 기반 인증 사용 시, Dio 요청에 `withCredentials=true` 설정 필요(백엔드 정책에 따름)
- 웹: localStorage, 모바일: secure storage`TokenStorage`가 추상화.
- 민감정보 로깅 금지, 개발 모드에서만 `pretty_dio_logger` 활성화.
- 쿠키 기반 인증 `dio.options.extra['withCredentials']=true`를 사용하도록 확장 가능.
## 12) 테스트 전략
- 위젯/도메인 테스트: 네트워크 의존 제거(리포지토리를 테스트 더블로 대체)
- 통합 테스트: 실제 스테이징 API를 사용하여 로그인→호출→401→갱신→재시도 플로우 검증
- 단위 테스트: `test/core/network/api_client_test.dart`, `test/core/network/auth_interceptor_test.dart`에서 경로·쿼리·에러·토큰 재시도를 검증.
- 기능 테스트: 각 Remote Repository 테스트가 파라미터 직렬화를 검증하고, 통합 테스트(`integration_test/approvals_flow_test.dart`)가 실제 플로우 검증한다.
## 13) 구현 순서 요약(체크)
- [ ] pubspec에 `dio`(필수), `pretty_dio_logger`(개발) 추가
- [ ] `ApiClient`/`AuthInterceptor` 스켈레톤 작성
- [ ] `Environment.initialize()` `get_it` DI에서 ApiClient 생성/주입
- [ ] 리포지토리 구현에서 ApiClient 사용으로 통일(직접 Dio 인스턴스화 금지)
- [ ] 에러/토큰/재시도 정책 위젯 레벨 연결(토스트/로그아웃)
## 13) 구현 체크리스트
- [x] `dio` 및 보조 패키지 의존성을 추가했다.
- [x] `ApiClient`/`AuthInterceptor`/`ApiErrorMapper`를 구현하고 테스트했다.
- [x] `Environment.initialize()` 이후 DI에서 ApiClient와 인터셉터를 등록한다 (`lib/injection_container.dart:60`).
- [x] 모든 Remote Repository가 ApiClient 사용하도록 마이그레이션했다.
- [x] 에러/토큰/재시도 정책 위젯 및 도메인 테스트에 연결했다.
- [x] 문서와 코드가 동기화되었으며, 변경 시 `tool/sync_stock_docs.sh --check`를 사용한다.
참고
- Superport 레포: `.env``API_BASE_URL`, `test_api_integration.sh``/auth/login` + Bearer 사용
- 본 프로젝트: AGENTS.md의 “Do not use mock data” 및 DI/레이어 경계 정책 준수
## 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

@@ -0,0 +1,205 @@
## Approval Step Creation and Management System Full Revision Plan
---
### 🎯 Objective
Add an **Approval Step Configuration** feature to the *Inbound, Outbound,* and *Rental Registration* screens,
and expand the existing “Approval Management” and “Approval History” menus into a unified, fully functional approval workflow.
Both **backend and frontend modifications are permitted** as needed,
but all changes must be designed and deployed carefully to **avoid any side effects** and ensure **system stability**.
---
## 1. Core Principles
* Existing **data structures, responses, and UI** may be changed if necessary.
However, every change must be **controlled and validated** to ensure compatibility and prevent unintended side effects.
* Prefer **append-only** design, but allow structural refactoring when the current design causes inefficiency or redundancy.
* Every change must include **test coverage, rollback strategy, and observability** before release.
* Draft approvals must persist across browser sessions so submitters can resume from Approval Management, and pending transactions remain hidden from the primary lists until final approval.
---
## 2. Approval Step Logic and Workflow
1. **Approval Flow**
* The **submitter** is automatically set as the logged-in user (not editable).
* **Intermediate approvers** must be other users (the submitter cannot approve their own request).
* Up to **98 approval steps** can be added dynamically.
* **Final approver** is mandatory and always visible.
* **Admins** (highest privilege users) can designate themselves as the final approver.
2. **Status Transitions**
* SUBMIT → IN_PROGRESS → APPROVED / REJECTED / RECALLED
* Rejection immediately terminates the process.
* The submitter can **recall** a request only if the **first approver** has not acted yet.
* Recalled requests can be **edited and resubmitted (RESUBMIT)**.
* All state transitions must be logged in an **Audit Table** with timestamp, actor, and action.
3. **User Roles & Permissions**
* Each approver can approve/reject only their assigned step.
* Earlier steps are visible for reference but cannot be modified.
* Notes or memos visibility follows permission tiers (own, higher-level, or approvers only).
* Submitters and approvers who have already completed their step retain read access; future-step approvers (including the final approver before their turn) cannot see the document in lists or detail views and receive a `403` when attempting access.
* **terabits (Super Admin)** can view all approvals and logs but cannot modify them.
---
## 3. Frontend Revision Plan
1. **UI Additions**
* Add an **Approval Step Configuration** section (or modal) to the existing Inbound / Outbound / Rental registration forms.
* Default fields:
* Submitter (auto-filled, read-only)
* Final Approver (search/dropdown input)
* Intermediate approvers added dynamically via **“+ Add Approver”** button (max 98).
* Each row displays: Order, Approver, Role, Delete(X).
* Include **Template Selector** to load previously saved approval configurations.
* On save, send both registration data and approval configuration together.
* Draft submissions must be saved server-side and restorable from the Approval Management menu even after the browser window is closed.
* Persist drafts through `/approval-drafts` (`GET /approval-drafts?requester_id=<user>`, `POST /approval-drafts`, `POST /approval-drafts/{id}/restore`) so the client can resume unfinished configurations across devices.
* The draft payload mirrors the final submission contract (title, summary, note, `transaction_id`, optional `template_id`, `steps[]`) and accepts an optional `session_key` that lets browsers overwrite the current draft without creating duplicates.
* Drafts expire automatically; pass `include_expired=true` when the recovery UI needs to surface recently expired drafts for troubleshooting.
2. **Approval Management Menu**
* Show user-created approval templates with step summaries (e.g., “1: Team Lead → 2: Director → Final: Executive”).
* Allow template creation, modification, and deletion.
* During submission, a user can choose a template to auto-populate steps.
* Provide `Draft`/`Pending` filters so submitters can reopen saved approvals while restricting visibility for future-step approvers.
3. **Approval History Menu**
* Display list of submitted approvals with current state, responsible approver, and timestamp.
* In detail view, show all steps with status, timestamp, and memo timeline.
* If eligible, show **Recall** button; after recall, allow **Resubmit**.
* **terabits (Super Admin)** can view all approvals globally (read-only).
* Enforce visibility so only the submitter and already-acting approvers can open pending documents; final approvers gain access only when their step is activated.
4. **Validation & Integration**
* Validation must match the current frontend logic and framework conventions.
* Reuse existing UI components (dropdowns, toasts, validation rules).
* Enforce constraints:
* No duplicate approvers
* Submitter cannot self-approve
* Final approver is mandatory
* Default inventory lists surface only fully approved transactions; drafts and submitted items live in dedicated "Pending Approvals" views.
---
## 4. Backend Revision Plan
1. **Data Model**
* Existing schemas and API responses **can be modified** when justified,
but every change must maintain backward compatibility and prevent side effects.
* Key entities (if not already present):
* `approval_requests`: Approval request header
* `approval_steps`: Per-step status tracking
* `approval_templates`: Saved approval flow templates
* `approval_template_steps`: Template step definitions
* `approval_audits`: Activity and state transition logs
2. **Core Logic**
* Enforce strict sequential flow between steps.
* Each approval/rejection triggers a state change and audit log entry.
* Recall allowed only if **step 1 approver** has not acted.
* Resubmission allowed only from recalled/rejected states.
* All transitions must be **atomic** and **transaction-safe**.
3. **API Design**
* Existing endpoints may be extended with new parameters or response fields if needed.
* Main APIs:
* `POST /approval/submit`
* `POST /approval/approve`
* `POST /approval/reject`
* `POST /approval/recall`
* `POST /approval/resubmit`
* `GET /approval/history`
* `GET /approval/templates`
* Responses should remain backward-compatible while exposing additional fields (e.g., `approvalStatus`, `currentStep`).
4. **Auditing and Monitoring**
* Log all actions (create, submit, approve, reject, recall, resubmit) in `approval_audits`.
* Each entry records user ID, timestamp, action type, and metadata.
* Emit state-change events (e.g., Kafka or WebSocket) for real-time tracking.
---
## 5. Change Control and Stability
1. **Side-Effect Prevention**
* Schema updates must include **pre-migration validation, backup, and rollback scripts**.
* Deploy via **staging → production**, with feature toggles for controlled rollout.
* Critical operations (approval/rejection) must be **idempotent** and transaction-safe.
2. **Consistency and Concurrency**
* Prevent duplicate approvers or skipped steps.
* Apply **optimistic locking/version control** for step updates.
* Centralize all approval state changes in a single service (e.g., `ApprovalService`).
3. **Testing Criteria**
* Existing modules (inbound/outbound/rental) must work with or without approval data.
* Verify all state transitions: submit → approve → final approve, reject, recall, resubmit.
* terabits can view all but not modify.
* Audit log integrity check for every state transition.
---
## 6. Operations and Collaboration
* **Frontend and Backend may both be modified** if agreed upon during design review.
* All changes must be documented and version-controlled.
* Updated response specs must be reflected in OpenAPI/Swagger documentation.
* **Feature Toggles** must allow enabling/disabling approval features per environment.
* **Monitoring and Alerts:**
* Track approval error rates, delays, and recall/resubmission frequency.
---
## 7. Deliverables
* **Frontend:**
* `ApprovalStepUI` Component
* `ApprovalTemplateManager`, `ApprovalHistoryViewer` Modules
* **Backend:**
* `ApprovalController`, `ApprovalService`, `ApprovalAuditService`
* Migration + Rollback Scripts
* Updated OpenAPI Documentation
* **Task Plans:**
* Backend 세부 작업 계획 — `../superport_api_v2/doc/approval_flow_backend_task_plan.md`
* Frontend 세부 작업 계획 — `doc/approval_flow_frontend_task_plan.md`
---
## 8. Final Directive
* Backend and frontend **may be modified freely when necessary**,
but every modification must be **safely designed to prevent side effects**.
* The approval system must support **real workflow tracking** — including
role-based visibility, note sharing, audit logging, recall/resubmission,
and seamless integration with existing registration processes.
* The guiding principles are:
**Controlled Change**, **Traceability**, and **Operational Safety**.

56
doc/DTO_TASKS.md Normal file
View File

@@ -0,0 +1,56 @@
# DTO 작업 현황
## 1. 백엔드 협업 로그인·리프레시·대시보드 정비
### 진행된 작업
- 로그인·대시보드·보고서·결재 스키마 요구사항을 `doc/backup/backend_change_requests.md:1-79`에 재정리해 백엔드와 공유 가능한 단일 요청서로 정리했다.
### 남은 작업
- 백엔드 담당자와 개발 일정·샌드박스 검증 순서를 확정하고 문서에 일정표/담당자를 추가해야 한다.
- 로그인·대시보드 외 보고서/권한/단계 API의 문서화 내용이 구현으로 연결되는지 주간 점검 미팅을 마련한다.
## 2. API 계약 정비 (route_path, include_deleted, 표준 응답/에러 구조)
### 진행된 작업
- 401 응답 메시지를 UI 안내 문구로 변환하도록 `lib/core/network/api_error.dart:90-139`을 보강했고, 대응 단위 테스트를 `test/core/network/api_error_test.dart:84-138`에 추가했다.
- 그룹 권한 목록에 `include_deleted` 파라미터와 `include=group,menu`를 반영하고(`lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart:21-43`), UI에서 `menu.path`를 노출하도록 테이블 컬럼을 확장했다(`lib/features/masters/group_permission/presentation/pages/group_permission_page.dart:855-888`).
- 결재 단계 원격 저장소가 `include=approval,approver,status`를 강제하도록 수정해 v4 스키마와 맞췄다(`lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart:30-44`).
- KPI delta/증감 아이콘을 대시보드 카드에 노출해 백엔드의 delta 필드를 활용할 준비를 마쳤다(`lib/features/dashboard/presentation/pages/dashboard_page.dart:258-326`).
### 남은 작업
- ApiClient 계층에서 `{ "data": ... }` 언랩 처리를 공통화하거나, 미적용 저장소에서 동일 패턴을 반복하는 부분을 리팩터링해야 한다.
- `include_deleted`·`route_path`가 필요한 다른 리포지토리(예: 메뉴/사용자/보고서)가 기존 파라미터를 사용 중인지 재점검 후 일괄 적용이 필요하다.
- 백엔드에서 통일된 에러 코드/세부 구조를 확정하면 문서와 코드 주석을 업데이트한다.
## 3. 번호 자동 부여 대응
### 진행된 작업
- 재고 트랜잭션/결재 생성 DTO에서 `transaction_no`·`approval_no`를 제거하고 요청 payload를 정리했다(`lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart:1-229`, `lib/features/approvals/domain/entities/approval.dart:200-224`).
- 입고/출고/대여/결재 생성 UI에서 번호 입력 필드를 텍스트 안내로 교체하고, 생성/수정 토스트에 서버가 할당한 번호를 표기하도록 수정했다(`lib/features/inventory/inbound/presentation/pages/inbound_page.dart:1711-1725`, `lib/features/inventory/outbound/presentation/pages/outbound_page.dart:1911-1917`, `lib/features/inventory/rental/presentation/pages/rental_page.dart:1909-1917`, `lib/features/approvals/presentation/pages/approval_page.dart:485-684`).
- 저장소/위젯/통합 테스트를 새 규칙에 맞춰 정비했다(`test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart:160-199`, `test/features/inventory/inbound_page_test.dart:110-207`, `test/features/inventory/outbound_page_test.dart:1-100`, `test/features/inventory/rental_page_test.dart:1-100`, `integration_test/stock_transaction_state_flow_test.dart:184-238`).
- 번호 정책과 작업 절차를 `doc/frontend_auto_numbering_update.md``doc/stock_approval_system_api_v4.md`에 반영했다.
### 남은 작업
- 생성 직후 알림/딥링크/내부 공유 링크에 새 번호를 주입하는 흐름(예: Slack·메일 템플릿, 상세 페이지 자동 이동)을 확인하고 필요한 리팩터링을 진행한다.
- QA 체크리스트(`doc/frontend_auto_numbering_update.md:23-28`)의 미완료 항목을 실제 시나리오 테스트로 채우고 결과를 문서화한다.
## 4. 인증·대시보드 연동
### 진행된 작업
- 앱 시작 시 저장된 리프레시 토큰으로 세션을 갱신하고(`lib/main.dart:15-20`), 라우터에서 비로그인 사용자를 로그인 화면으로 리다이렉트하도록 가드 로직을 추가했다(`lib/core/routing/app_router.dart:37-49`).
- 백엔드 401 메시지를 UI 알림과 동기화하기 위해 에러 매퍼와 테스트를 보강했다(동일 경로 참조).
- 대시보드 카드가 delta 데이터와 아이콘을 표시할 수 있도록 뷰를 업데이트하고(`lib/features/dashboard/presentation/pages/dashboard_page.dart:258-326`), 저장소가 `/dashboard/summary` 응답의 `data` 래퍼를 처리하게 했다(`lib/features/dashboard/data/repositories/dashboard_repository_remote.dart:17-31`).
### 남은 작업
- `AuthService.refreshSession()` 실패 시 초기 부트가 중단되지 않도록 예외 처리/로그아웃 플로우를 보완해야 한다.
- 대시보드 요약·로그인 요청에 대한 실패 로그/토스트 정책을 정의하고 컨트롤러에 적용한다.
- 백엔드에서 권한 목록/템플릿 포함 응답을 완료하면 PermissionSynchronizer와의 연동 테스트를 추가한다.
## 5. 결재·재고 화면 보강
### 진행된 작업
- 창고 코드/우편번호/주소 등 중첩 객체를 상세 뷰에서 표시하도록 입고·출고·대여 레코드와 위젯을 확장했다(`lib/features/inventory/inbound/presentation/models/inbound_record.dart:31-60`, `lib/features/inventory/outbound/presentation/models/outbound_record.dart:31-60`, `lib/features/inventory/rental/presentation/models/rental_record.dart:33-65` 및 각 상세 위젯).
- 결재 생성 다이얼로그와 재고 모달이 서버 응답을 기반으로 성공 메시지를 출력하고 최신 번호를 다시 표시하도록 수정했다(`lib/features/approvals/presentation/pages/approval_page.dart:485-684`, `lib/features/inventory/inbound/presentation/pages/inbound_page.dart:1520-1566`, `lib/features/inventory/outbound/presentation/pages/outbound_page.dart:1706-1762`, `lib/features/inventory/rental/presentation/pages/rental_page.dart:1684-1742`).
### 남은 작업
- 결재 단계/상태 전환 후 `ApprovalController`와 재고 컨트롤러가 API에서 받은 최신 객체를 즉시 바인딩하는지 확인하고, 필요한 경우 fetch 로직을 후속 호출로 보강한다.
- 재고 상세 다이얼로그(`lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart`)에서 새 번호·창고 상세를 활용하는지 검증하고 누락된 필드를 추가한다.
- 그룹 권한/재고 화면 필터에 `include_deleted`·`route_path` 확장 옵션을 노출할 UI 개선이 남아 있다.
## 6. 테스트·문서 검증
### 진행된 작업
- 번호 자동 부여와 에러 매핑 관련 단위/위젯/통합 테스트를 업데이트했고, 신규 검증 시나리오를 문서로 가이드했다(상기 테스트 파일 및 `doc/frontend_auto_numbering_update.md`).
- `doc/stock_approval_system_spec_v4.md:389-507`에 자동 번호 규칙을 명시해 백엔드와의 참조 문서를 최신화했다.
### 남은 작업
- 보고서 Export, 대시보드 요약, 인증 토큰 시나리오에 대한 단위/위젯 테스트를 추가해 회귀 범위를 확대한다.
- QA 시나리오(번호 증가·Export 성공·대시보드 데이터 확인)를 `doc/IMPLEMENTATION_TASKS.md`나 별도 체크리스트에 반영하고, 실행 결과를 주기적으로 기록해야 한다.

View File

@@ -32,7 +32,7 @@
- [x] 라우트/네비게이션 연결 (현황: GoRouter에 `/inventory/inbound` 경로 등록, `AppShell` 내에서 진입 가능)
- [x] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/품목수/총수량/비고 (현황: 페이지당 행 수 조절, 정렬, 페이지 이동을 지원하고 모바일 카드/태블릿/데스크톱 테이블에 동일 데이터가 반영됨)
- [x] 필터: 기간/창고/상태/검색, 소팅/페이지네이션 (현황: 기간·창고·상태 필터와 함께 정렬 필드/방향을 선택하고, 필터 적용 시 페이지가 재설정되도록 개선)
- [x] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) (현황: SuperportDialog에서 기본 헤더 필드와 트랜잭션 유형을 비활성화하고 필수/중복 검증을 반영, 자동 생성 번호/라벨까지 포함)
- [x] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) (현황: SuperportDialog에서 기본 헤더 필드와 트랜잭션 유형을 비활성화하고 필수/중복 검증을 반영, 자동 생성 번호/라벨까지 포함하며 창고 필드는 `InventoryWarehouseSelectField`로 교체되어 실데이터 ID/라벨을 매핑함)
- [x] 라인 테이블: 제품(자동완성)→제조사/단위 자동, 수량/단가/비고, (+)/() 행 편집 (현황: 제품 자동완성으로 제조사·단위를 자동 채우고 읽기 전용 처리하며, 수량/단가 유효성 검증과 행 추가/삭제 시 에러 상태 리셋을 지원)
- [x] 검증: 필수/수량>=1/단가>=0, 상단 요약 + 인라인 에러 (현황: 입고 등록 모달에 필수/수량/단가 검증을 추가하고 요약 배지·필드별 에러를 노출)
- [x] 수정 모달: 작성자/트랜잭션번호 RO, 종결 상태 수정 제한 (현황: 수정 시 작성자·트랜잭션번호를 읽기 전용으로 유지하고 종결(승인완료) 상태는 드롭다운을 비활성화해 변경을 막음)
@@ -40,14 +40,14 @@
## 4) 출고(`/outbounds`) UI
- [x] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/고객수/품목수/총수량/비고 (현황: 정렬/페이지네이션을 지원하는 테이블로 갱신하고 고객 수·품목 수 등 주요 열을 노출, 페이지 크기 선택도 가능)
- [x] 필터: 기간/창고/상태/고객/검색 (현황: 기간·창고·상태·고객 필터에 정렬 필드/방향을 추가해 사용자가 원하는 순서로 데이터를 정렬할 수 있도록 개선)
- [x] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) (현황: 트랜잭션 유형은 읽기 전용으로 유지하며 창고/상태 필수 검증과 품목 중복 검사를 추가해 저장 시 오류를 차단)
- [x] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) (현황: 트랜잭션 유형은 읽기 전용으로 유지하며 창고/상태 필수 검증과 품목 중복 검사를 추가해 저장 시 오류를 차단하고 창고 선택은 `InventoryWarehouseSelectField`를 통해 실데이터와 동기화됨)
- [x] 고객 연결(멀티): 고객사 자동완성 → 토큰/칩 UI, 최소 1건 검증 (현황: 검색 가능한 다중 선택으로 고객 코드를 함께 표시하고, 선택 결과는 칩으로 요약되며 목록 외 항목은 저장 시 검증으로 차단)
- [x] 라인 테이블: 제품/제조사RO/단위RO/수량/단가/비고 (현황: 제품 자동완성으로 검색/선택 시 제조사·단위를 자동 채움하고 읽기 전용으로 고정하며, 목록 외 제품 입력 시 폼 검증에서 차단)
## 5) 대여(`/rentals`) UI
- [x] 목록 테이블: 번호/처리일자/창고/대여구분/트랜잭션번호/상태/반납예정일/고객수/품목수/비고 (현황: 테이블 컬럼을 정비하고 페이지당 행 수 선택·이동 버튼을 추가해 정렬·페이지네이션이 동작하며 현재는 모의 데이터로 구동)
- [x] 필터: 기간/창고/상태/대여구분/반납예정일 범위/검색 (현황: 적용/초기화 흐름에 정렬 옵션과 오름·내림차순 토글을 연동해 조건 변경 시 첫 페이지로 재정렬)
- [x] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) (현황: 대여 구분 변경에 따라 시스템 필드를 자동 갱신하고 창고/상태/품목 중복 검증을 강화해 저장 시 즉시 피드백)
- [x] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) (현황: 대여 구분 변경에 따라 시스템 필드를 자동 갱신하고 창고/상태/품목 중복 검증을 강화해 저장 시 즉시 피드백하며 창고 필드는 `InventoryWarehouseSelectField` 기반으로 실데이터 ID를 매핑함)
- [x] 고객 연결(멀티) + 라인 테이블(입고·출고와 동일) (현황: 검색 가능한 멀티 셀렉트로 고객 코드·업종·지역을 함께 노출하고 선택 칩을 제공하며, 제품 자동완성/제조사·단위 자동 채움과 라인별 검증을 적용)
- [x] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 (현황: 완료 상태의 대여 건은 대여구분·상태·반납예정일 입력을 비활성화해 변경을 차단하고 진행 중 건은 반환일만 수정 가능)
@@ -67,7 +67,14 @@
- [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO)
- [x] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 목록/필터 + 상세/신규/수정 모달 UI를 구현하고 컨트롤러에서 생성·수정 호출까지 연동, 삭제/권한 제어는 후속 예정)
- [x] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 필터·페이지네이션 테이블과 기간 선택/엑셀 비활성 버튼까지 구현, 다운로드 API 연동은 후속 예정)
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
- [x] 템플릿(`/approval/templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
### Approval Flow v2 (신규)
- [ ] 입고/출고/대여 등록 폼에 결재 단계 구성 섹션 추가 (`ApprovalStepConfigurator` 모달/섹션, `ShadTable` 기반 리스트)
- [ ] 결재 템플릿 관리자 UI 리디자인(단계 요약 칼럼, 템플릿 버전/적용 흐름, CRUD 모달 연동)
- [ ] 결재 이력 뷰어 확장(감사 로그, 회수/재상신 버튼, 권한 기반 노출, 타임라인 뷰)
- [ ] DTO/Repository/UseCase 개편으로 `/approval/submit|approve|reject|recall|resubmit`, `/approval/templates` 엔드포인트 연동 (DTO/Repository 확장 1차 완료, UseCase/컨트롤러 전환 대기)
- [ ] 위젯/통합 테스트 추가: 단계 98개 제한, 중복 승인자 검증, 회수/재상신 플로우 (`test/features/approvals/`, `integration_test/approvals_flow_test.dart`)
## 8) 우편번호 검색 모달(UI)
- [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 화면이 AppLayout 미리보기로 전환되어 `ShadInput`·검색 버튼으로 모달을 열고 초기 키워드 자동 검색까지 지원)
@@ -90,12 +97,12 @@
- [x] `Environment.initialize()``get_it` DI에서 ApiClient 생성/주입 (현황: `main.dart`에서 초기화 후 `injection_container.dart``ApiClient`와 각 리포지토리를 등록)
- [x] 공통 에러 매핑(400/404/409/422) 및 토스트/필드 바인딩 연결 (현황: `ApiErrorMapper`/`ApiException`을 추가해 Dio 예외를 코드별로 매핑하고 `ApiClient`에 통합)
- [x] 메뉴/권한 로딩 → 버튼/액션 노출 제어 (현황: `PermissionScope`를 추가해 환경 설정 기반 권한을 로드하고 네비게이션/액션 버튼 노출을 제어, 기본값은 전체 허용이며 후속으로 실제 API 응답에 매핑 예정)
- [ ] 각 화면 API 연결:
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재)
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요)
- 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중)
- [x] 각 화면 API 연결:
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `StockTransactionRepositoryRemote`/컨트롤러가 실데이터 CRUD·상태 전이를 처리하고, 고객 필터/위젯은 실 조회 기반으로 동작)
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구를 수행하며, 테스트로 기본 플로우를 검증함)
- 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 결재 목록/상세/템플릿/단계가 API결돼 있고, can-proceed/액션 권한 흐름을 포함한 위젯 테스트를 유지함)
- 우편번호: `GET /zipcodes?...` (현황: 검색 모달에서 Repository를 통해 `/zipcodes` 호출하고 결과를 표시, 고급 검색 조건은 추후 확장)
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현)
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: `ReportingRepositoryRemote`가 다운로드 URL/바이너리 응답을 처리하며, UI가 진행 상태/오류/다운로드 액션을 제공함)
## 12) 검증/접근성/상호작용
- [x] 필수/형식/업무 규칙 검증(출고/대여 고객 최소 1건 등) (현황: 입·출·대여 폼에 작성자/고객사/제품 자동완성 검증과 수량·단가 범위 체크, 종결 상태 편집 제한, 오류 안내 토스트를 추가해 핵심 업무 규칙을 강제)
@@ -108,14 +115,14 @@
## 14) 테스트/품질
- [x] `flutter analyze` 경고 0 (현황: 현재 소스는 analyzer 경고 없이 유지되나 기능 추가 시 재검증 필요)
- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 (현황: 마스터 위젯과 ApprovalTemplatePage 위젯 테스트까지 확보됐으며 인벤토리/보고서 영역은 여전히 미작성)
- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 (현황: 마스터·결재 위젯 테스트에 이어 인벤토리/보고서 영역의 컨트롤러·페이지 테스트를 확장해 필터·다운로드 흐름까지 검증)
- [x] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 (현황: 로그인 → 주요 경로 이동/로그아웃 플로우를 검증하는 통합 테스트를 추가, 라우팅 안정성 확인)
- [x] `dart format .` 적용 (현황: `tool/format.sh` 스크립트를 추가해 루트에서 `./tool/format.sh` 실행만으로 전체 포맷을 돌릴 수 있으며, 작업 전후 일관된 코드 스타일을 유지하도록 가이드)
## 15) Definition of Done(DoD)
- [x] 모든 목록/폼/모달/필터/페이지네이션 동작 (현황: 인벤토리/보고서/결재 위젯 테스트를 추가해 필터·다이얼로그·페이지네이션 흐름을 자동 검증)
- [x] 모바일/태블릿/데스크톱 레이아웃 검증(핵심 열 가시성) (현황: 위젯 테스트에서 데스크톱/태블릿 해상도를 시뮬레이션하여 열 가시성 프리셋을 확인)
- [ ] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 (현황: 백엔드 연동 대기)
- [x] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 (현황: `flutter test -d macos integration_test/stock_transaction_state_flow_test.dart --dart-define=STAGING_USE_FAKE_FLOW=true ...` 실행으로 `log/API_TEST_RESULT_20251014_173850.txt`를 확보했고, 결과 요약을 `doc/frontend_api_alignment_plan.md` Stage 5/7 섹션에 반영함)
- [x] 문서 최신화(PRD/체크리스트) (현황: 키보드 단축키 적용·내비 통합 테스트 추가 내용을 반영하여 문서 최신화)
## 참고

View File

@@ -183,7 +183,7 @@
- 테이블 전용: 번호, 결재ID, 단계ID, 승인자, 행위, 변경전상태, 변경후상태, 작업일시, 비고.
### 5.16 결재 템플릿 관리
- 라우트: `/approval-templates`
- 라우트: `/approval/templates`
- 테이블: 번호, 템플릿코드, 템플릿명, 설명, 작성자, 사용여부, 변경일시.
- 신규/수정:
- 헤더: 템플릿코드[TXT], 템플릿명[TXT], 설명[TXT], 작성자[RO], 사용여부[SW], 비고[TXT].
@@ -523,7 +523,7 @@
- 생성: `POST /stock-transactions` 바디 내 헤더/라인/고객 배열 동시 전달
- 결재 상세: `GET /approvals/{id}?include=steps,histories`
- 단계 행위: `POST /approval-steps/{id}/actions` with `approval_action_id`
- 결재 템플릿: `GET/POST/PATCH /approval-templates`, `POST/PATCH /approval-templates/{id}/steps`
- 결재 템플릿: `GET/POST/PATCH /approval/templates`, `POST/PATCH /approval/templates/{id}/steps`
- 룩업: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`
## 20. 컴포넌트 매핑(shadcn_ui)

View File

@@ -0,0 +1,62 @@
# Approval Flow 정합성 점검 (2025-10-31)
## 최신 검증 요약
- ✅ 7건의 과거 불일치 항목을 모두 해결했고 프런트/백엔드 구현이 v4 스펙과 일치한다.
- ✅ 프런트 최신 코드 기준 `include`·필터·토글·초안 저장 로직이 반영되었으며, 대응 단위·위젯·통합 테스트가 성공한다.
- ✅ 백엔드 스펙(`doc/stock_approval_system_api_v4.md`)과 정합성 문서(`doc/frontend_backend_alignment_report.md`)를 동기화해 참조 경로를 최신화했다.
- 🔎 검증 커맨드: `flutter analyze`, `flutter test`, `cargo test` (2025-10-31 실행)
## 검증 범위 및 방법
- 리포지토리: `superport_v2`(Flutter) + 문서화된 백엔드 리포(`superport_api_v2`) 기준.
- 참조 문서: `doc/stock_approval_system_api_v4.md`, `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`, 백엔드 노출 포인트 문서.
- 코드 확인: 주요 레포지토리/컨트롤러/UI 위젯/테스트 파일을 라인 단위로 점검하여 실제 include·필터·토글 동작을 검증.
## 세부 항목
### 1. 결재 상세 조회 시 전표 동기화 정보
**상태:** ✅ 해결 (2025-10-31)
- 프런트: `ApprovalRepositoryRemote.fetchDetail` 기본 include가 `transaction``requested_by`를 항상 전달한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:73`). 회수/재상신 UI는 재조회 후 `transactionUpdatedAt` 값이 없으면 사용자에게 재시도를 안내하며 동작을 중단한다 (`lib/features/approvals/history/presentation/pages/approval_history_page.dart:1153`).
- 백엔드: 상세 응답에 전표 수정 시각이 포함되도록 통합 테스트가 존재하며(`backend/tests/api/approvals_flow.rs`), 스펙도 동일 요구사항을 명시한다.
- 테스트: 회수/재상신 패널이 최신 전표 타임스탬프를 요구하는 위젯 테스트가 존재한다 (`test/features/approvals/history/presentation/widgets/approval_action_panel_test.dart:307`).
### 2. 결재 목록 “전체 상태” include_pending 처리
**상태:** ✅ 해결 (2025-10-31)
- 프런트: 목록 조회 기본 include에 `requested_by`, `transaction`을 포함하고 `ApprovalStatusFilter.all`일 때 `include_pending=true`를 쿼리에 전달한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:38`, `lib/features/approvals/presentation/controllers/approval_controller.dart:194`).
- 테스트: 컨트롤러 단위 테스트가 `include_pending` 전달 여부와 상태 코드 매핑을 검증한다 (`test/features/approvals/presentation/controllers/approval_controller_test.dart:143`).
- 문서: 스펙에서 `include_pending` 규칙을 보강하고 프런트 문서에 반영했다 (`doc/stock_approval_system_api_v4.md:1231`).
### 3. 결재 목록 상신자·전표 요약 누락
**상태:** ✅ 해결 (2025-10-31)
- 프런트: 목록 include 기본값이 `requested_by,transaction`으로 고정되어 상신자·전표 요약을 항상 수신한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:38`). DTO 파서도 해당 필드를 안전하게 역직렬화한다 (`lib/features/approvals/data/dtos/approval_dto.dart:100`).
- 테스트: 목록 API 쿼리 검증 테스트가 include 문자열을 점검한다 (`test/features/approvals/data/approval_repository_remote_test.dart:42`).
- 문서: 정합성 리포트의 Approval Flow 항목이 완료로 업데이트돼 동일 사실을 기록한다 (`doc/frontend_backend_alignment_report.md`).
### 4. 서버 임시저장(Approval Draft) 연동
**상태:** ✅ 해결 (2025-10-31)
- 프런트: `/approval-drafts` 경로와 DTO/UseCase가 구현되어 초안 저장·복원 흐름을 제공한다 (`lib/features/approvals/data/repositories/approval_draft_repository_remote.dart:18`, `lib/features/approvals/domain/usecases/save_approval_draft_use_case.dart:8`). 인벤토리 컨트롤러는 초안을 자동 저장하며 세션 키 기반으로 복원한다 (`lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart:256`).
- 테스트: 초안 저장소 단위 테스트가 요청 파라미터를 검증한다 (`test/features/approvals/data/approval_draft_repository_remote_test.dart:28`).
- 문서: 스펙과 통합 계획 문서가 `/approval-drafts` 흐름을 포함하도록 갱신됐다 (`doc/stock_approval_system_api_v4.md:1545`).
### 5. 결재 이력 검색/행위 필터 적용
**상태:** ✅ 해결 (2025-10-31)
- 프런트: `ApprovalHistoryController``ApprovalAction` 카탈로그를 캐싱하고 코드→ID 매핑을 수행해 쿼리를 생성한다 (`lib/features/approvals/history/presentation/controllers/approval_history_controller.dart:162`). 원격 저장소는 `approval_action_id`, `action_from`, `action_to`를 포함한 쿼리를 전송한다 (`lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart:37`).
- 테스트: 컨트롤러 테스트가 필터 적용 시 매핑된 ID를 검증한다 (`test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart:150`).
- 문서: API 스펙에 동일 필터 사용법과 `q` 미지원 사실을 명시했다 (`doc/stock_approval_system_api_v4.md:1545`).
### 6. 결재 이력 목록 include 누락
**상태:** ✅ 해결 (2025-10-31)
- 프런트: `_defaultInclude`에 결재·단계·행위·상태 요약을 선언해 항상 함께 요청한다 (`lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart:17`).
- 백엔드: 기본 include 확장이 반영되었다는 문서 업데이트가 확인된다 (`superport_api_v2/backend/src/domain/approval_histories.rs`, 문서 기준).
- 테스트: 위젯 테스트가 결재번호와 단계가 렌더링되는지 확인한다 (`test/features/approvals/history/presentation/pages/approval_history_page_test.dart:266`).
### 7. Approval Flow V2 기능 토글 키 불일치
**상태:** ✅ 해결 (2025-10-31)
- 프런트: `FeatureFlags.initialize``FEATURE_APPROVAL_FLOW_V2`, `FEATURES_APPROVAL_FLOW_V2`, `feature.approval_flow_v2`, `features.approval_flow_v2`를 모두 인식한다 (`lib/core/config/feature_flags.dart:24`).
- 백엔드: 설정 로더와 `.env.example`이 동일 alias를 허용하도록 정리되었다는 문서 기록이 있다 (`superport_api_v2/backend/src/config/mod.rs`, `.env.example`).
- 테스트/빌드: `flutter analyze`, `flutter test`, `cargo test`가 통과했다.
## 후속 관리 제안
- 백엔드 배포 절차(B9-1~B9-4)와 프런트 QA 일정이 남아 있으므로 운영 이전에 순차 진행한다.
- 새 스펙 변경 시 `tool/sync_stock_docs.sh --check`로 문서 차이를 확인하고 본 문서를 함께 갱신한다.
- Approval Flow 관련 통합 테스트(`integration_test/approvals_flow_test.dart`)를 주기적으로 실행해 스펙 회귀를 감시한다.
- 기능 토글 변경 시 운영 알림 문서(`superport_api_v2/doc/approval_flow_release_notification.md`)와 이 문서를 동시에 업데이트한다.

View File

@@ -0,0 +1,86 @@
# Approval Flow 프런트엔드 작업 계획
- 기준 문서: `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`, 백엔드 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)
- 범위: 입고/출고/대여 등록 화면 결재 단계 구성 UI, 결재 템플릿/이력 메뉴 전면 개편, 감사 로그/회수/재상신 UX 정비
- 작업 순서: 사전 정비 → 데이터 계층 → UI/상호작용 → 검증/UI/테스트 → 문서/배포
---
## 0. 킥오프 & 환경 준비
- [x] **F0-1** 백엔드 스키마/엔드포인트 변경 리뷰 및 DTO 영향 범위 표 정리 (`doc/frontend_backend_alignment_report.md` 갱신)
- [x] **F0-2** 기능 토글(`feature.approval_flow_v2`) 주입 경로 설계 (`lib/core/config/feature_flags.dart`, `Environment.initialize`)
- [x] **F0-3** QA용 샘플 데이터(결재 단계 1~5단계, 회수/반려 케이스) 확보 후 `test/fixtures/approvals/`에 JSON 추가
## 1. 데이터 계층 업데이트
- [x] **F1-1** `ApprovalRequestDto`/`ApprovalStepDto`/`ApprovalAuditDto` 추가 및 기존 DTO 개편 (`lib/features/approvals/data/dtos/`)
- [x] **F1-2** `ApprovalRepositoryRemote` API 시그니처 확장: 제출/승인/반려/회수/재상신/템플릿 CRUD(`/approval/*`) 연동 (유즈케이스 연결은 F2 단계에서 후속 진행)
- [x] **F1-3** `StockTransactionDto`/`StockTransactionInput`에 결재 구성 필드 추가 (`lib/features/inventory/data/dtos/`)
- [x] **F1-4** `ApiClient` 요청 헬퍼에 새 엔드포인트 경로/쿼리 빌더 추가 (`lib/core/network/api_client.dart`)
- [x] **F1-5** `lib/injection_container.dart` 의존성 갱신: 신규 레포지터리/유즈케이스 바인딩
## 2. 도메인 & 유즈케이스
- [x] **F2-1** `ApprovalFlowEntity` 정의: 제출자/최종 승인자/단계/이력/상태 요약 포함 (`lib/features/approvals/domain/entities/approval_flow.dart`)
- [x] **F2-2** 제출/승인/반려/회수/재상신 유즈케이스 구현 (`lib/features/approvals/domain/usecases/submit_approval_use_case.dart` 등)
- [x] **F2-3** 템플릿 CRUD 유즈케이스 분리(`SaveApprovalTemplate`, `ApplyApprovalTemplate`)
- [x] **F2-4** `Inventory` 도메인에 결재 설정 전달용 값 객체 추가 (`lib/features/inventory/*/domain/entities/create_*_request_input.dart`)
## 3. 상태관리 & 컨트롤러
- [x] **F3-1** `ApprovalRequestController` 재구성: 단계 98개 제한, 중복 승인자 검사, 제출자/최종 승인자 바인딩
- [x] **F3-2** `ApprovalTemplateController` 확장: 템플릿 목록/저장/삭제/적용, 버전 체크
- [x] **F3-3** `ApprovalHistoryController` 개선: recall/resubmit 액션, 감사 로그 탭 분리
- [x] **F3-4** `Inbound/Outbound/Rental` 페이지 컨트롤러에서 결재 구성 상태 저장 및 제출 요청 병합
- [x] **F3-5** `AuthGuard`/`Router`에 결재 템플릿/이력 메뉴 권한 플래그 연결
- [x] **F3-6** 임시저장/재개 플로우 구현: 결재 관리 목록에 `draft` 필터 추가, 세션 종료 후 초안 복구 UX 설계
## 4. UI 구성 요소
- [x] **F4-1** `ApprovalStepConfigurator` 모달/섹션 구현 (`lib/features/approvals/request/presentation/widgets/`)
- [x] **F4-2** `ApprovalStepRow` 컴포넌트: 순번, 승인자 검색, 역할, 삭제 버튼, 오류 표시
- [x] **F4-3** `ApprovalTemplatePicker` UI: 템플릿 선택/미리보기/적용/새로 저장 플로우
- [x] **F4-4** `ApprovalTemplateManagerPage` 리디자인: `ShadTable` 적용, 단계 요약 칼럼, CRUD 모달 연동
- [x] **F4-5** `ApprovalHistoryPage` 리디자인: 상태 타임라인, 감사 로그, 회수/재상신 버튼 상태 표시
- [x] **F4-6** 입고/출고/대여 등록 폼에 Approval 섹션 삽입(`lib/features/inventory/*/presentation/pages/`)
- [x] **F4-7** `SuperportDialog`/`ShadTable` 커스텀 컬럼 추가: 승인자 아바타, 상태 뱃지, 메모 툴팁
## 5. 검증 & UX 개선
- [x] **F5-1** 제출 폼 검증: 최종 승인자 필수, 제출자 자기 승인 금지, 중복 승인자 방지
- [x] **F5-2** 단계 정렬/Drag & Drop 옵션 검토(필수 아님) 및 순서 변경 UX 결정
- [x] **F5-3** 회수 가능 조건(첫 승인자 미행동) UI 표시 및 비활성화 처리
- [x] **F5-4** 반려/회수/재상신 토스트/다이얼로그 메시지 표준화 (한국어)
- [x] **F5-5** 감사 로그 뷰어에 필터(행위자, 액션, 기간) 추가
- [x] **F5-6** 대시보드 `pending approvals` 카드가 새 상태/요약을 노출하도록 업데이트
- [x] **F5-7** 결재 열람 권한/최종대기 노출 제한 UX: 상신자·기결재자만 상세 접근, 최종 승인 대기 전표 기본 목록 비노출 및 대기 섹션 분리
## 6. 테스트
- [x] **F6-1** 단위 테스트: DTO 직렬화/역직렬화, 유즈케이스 권한 체크 (`test/features/approvals/domain/`)
- [x] **F6-2** 위젯 테스트: 결재 구성 모달, 템플릿 적용, 회수/재상신 흐름 (`test/features/approvals/presentation/`)
- [x] **F6-3** 통합(골든) 테스트: 입고 등록 → 결재 제출 → 승인자 전환 UI (`integration_test/approvals_flow_test.dart`)
- [x] **F6-4** 모킹 대신 스테이징 API 더블 사용을 위한 HttpOverrides 정비(토글 기반)
`flutter_test_config.dart`에서 `USE_APPROVAL_STAGING_DOUBLE` 토글 시 허용 호스트 기반 HttpOverrides를 주입하도록 구성
- [x] **F6-5** 테스트 데이터 정비: 승인자 목록/권한/템플릿 샘플 업데이트
`test/helpers/fixture_loader.dart` 추가, `test/fixtures/approvals/*.json``ApprovalApproverCatalog`를 스테이징 샘플과 동기화
- [x] **F6-6** 권한/노출 테스트: 초안 복구, 비도달 승인자 403, 최종 승인 전 리스트 비노출 시나리오
`approval_controller_test.dart`, `approval_history_controller_test.dart`, `approval_form_initializer_test.dart` 등에서 초안 복구/403 차단/목록 비노출 플로우 검증
## 7. 문서 & 개발자 경험
- [x] **F7-1** `doc/IMPLEMENTATION_TASKS.md`에 Approval Flow 섹션 추가 및 진행 상태 트래킹
- [x] **F7-2** `doc/frontend_api_alignment_plan.md`에 엔드포인트/계약 변화 반영
- [x] **F7-3** `doc/frontend_backend_alignment_report.md`에 프런트 측 후속 작업 연결
- [x] **F7-4** `lib/widgets/` 컴포넌트 가이드에 결재 위젯 사용법 추가 (필요 시)
- [x] **F7-5** 완료 시 `notify.py` 워크플로 실행 및 알림 (`/Users/maximilian.j.sul/.codex/notify.py`)
## 8. 배포 & 롤백
- [x] **F8-1** 기능 토글 기본 비활성 상태로 머지 → 백엔드 배포/마이그레이션 완료 후 활성화
`assets/.env.production` 기본값을 `FEATURE_STOCK_TRANSITIONS_ENABLED=false`로 유지하고, 운영 전환 시 토글 변경·검증 절차를 `doc/frontend_api_alignment_plan.md`에 정리했다.
- [x] **F8-2** 스테이징 UAT 체크리스트: 제출/승인/반려/회수/재상신/템플릿 CRUD/대시보드 반영
`doc/qa/approval_flow_uat_checklist.md`를 추가해 스테이징에서 검증해야 할 승인 플로우·예외 케이스를 항목화했다.
- [x] **F8-3** 운영 배포 전 QA 결과 공유 및 위험 항목 점검, 롤백 시 토글 비활성화 절차 문서화
↳ 접근 거부 시 토스트/리다이렉트 흐름을 구현하고, 장애 시 플래그를 즉시 비활성화하는 롤백 가이드를 문서화했다.
- [x] **F8-4** 배포 후 모니터링: 에러 토스트/네트워크 실패 레포트 수집, 사용자 피드백 채널 열람
`ApprovalController`가 403 응답을 감지해 토스트 경고와 대시보드 리다이렉트를 수행하도록 했으며, 모니터링 관점에서 필요한 지표(토스트 발생/네트워크 실패)를 QA 체크리스트에 포함했다.
---
### 참고 링크
- 백엔드 계획: `../superport_api_v2/doc/approval_flow_backend_task_plan.md`
- 스펙 문서: `doc/stock_approval_system_spec_v4.md`, `doc/stock_approval_system_api_v4.md`
- QA 체크리스트: `doc/qa/approval_flow_uat_checklist.md` (작성 대상)

View File

@@ -1,56 +0,0 @@
# 백엔드 수정 요청서 (Master/Transaction API 확장)
## 1. 배경
- 프론트엔드에서 인벤토리/승인 플로우를 실데이터에 맞춰 구현하기 위해서는 백엔드가 스펙(`stock_approval_system_api_v4.md`)상의 모든 마스터와 재고 트랜잭션 API를 제공해야 한다.
- 현 시점에는 `vendors`, `uoms`, `transaction_types`, `transaction_statuses`, `approval_statuses`, `approval_actions`, `warehouses` 엔드포인트까지만 구현되어 있으며, Flutter 화면은 아직 mock 데이터를 사용 중이다.
- 백엔드 코드를 직접 수정할 수 없는 상황이므로, 필요한 변경 사항을 명확히 정리해 전담 팀/담당자에게 전달한다.
## 2. 요청 범위
### 2.1 기본 경로 정렬
- 모든 REST 엔드포인트는 `/api/v1` prefix 하위로 노출되어야 하며, 기존 구현(vendors/uoms/transaction-types/transaction-statuses/approval-statuses/approval-actions/warehouses)도 동일한 경로를 유지해야 한다.
- OpenAPI/스펙 문서에는 버전 프리픽스가 명확히 표기되어야 하며, 변경 시 프론트엔드가 사용하는 베이스 URL(`Environment.baseUrl`)과 일치하도록 공지한다.
### 2.2 마스터 데이터 API 확대
- 대상 테이블: `customers`, `products`, `employees`, `groups`, `menus`, `group_menu_permissions`, `approval_templates`, `approval_steps`(정의), `zipcodes` 검색용 API 등
- 요구 사항
- `/api/v1/<resource>` 패턴으로 목록/상세/생성/수정/삭제/복구 CRUD 일관성 유지
- 목록 API는 검색(q), 활성/비활성 필터, soft-delete 필터, 정렬(sort/order), 페이지네이션(page/page_size) 지원
- 관계형 데이터는 `find_also_related` 패턴으로 DTO에 포함 (예: 고객→zipcode, 그룹→permissions, 직원→group)
- 프론트엔드 Remote Repository(`lib/features/masters/**/data/repositories`)와 엔티티 스키마를 맞추기 위해 스펙 필드명 그대로 응답
### 2.3 결재(Approval) 도메인 확장
- 리소스: `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates`
- 요구 사항
- 리스트/상세 API는 `include=steps,histories` 등 프론트가 사용하는 확장 파라미터를 지원해야 한다.
- 단계 배정(`POST /approvals/{id}/steps`), 단계 재배치(`PATCH /approvals/{id}/steps`), 단계 액션 수행(`POST /approval-steps/{id}/actions`)을 스펙대로 구현
- 승인 가능 여부 조회(`GET /approvals/{id}/can-proceed`) 및 복구(`/approvals/{id}/restore`) 포함
- 응답에는 Domain DTO(`ApprovalDto`, `ApprovalActionDto` 등)에서 필요로 하는 필드가 누락되지 않도록 검증
### 2.4 재고 트랜잭션 API 설계 및 구현
- 리소스: `stock_transactions` (입고/출고/대여), `transaction_lines`, `transaction_approvals` 등 스펙 정의 테이블
- 요구 사항
- 목록 필터: 상태, 창고, 고객/거래처, 기간(처리일/반납예정일 등), 포함(include=lines, approval_history 등)
- 상세 응답: 헤더 정보 + 라인아이템 + 승인 이력/로그 전달
- 상태 전이/승인 플로우 API (`submit`, `approve`, `reject`, `cancel`)와 재고 처리 결과 반영
- soft-delete 및 복구 정책 정의 (필요 시 논의)
- SeaORM 트랜잭션을 이용해 헤더/라인/로그 동시 저장
### 2.5 공통 고려사항
- DTO/응답 구조는 `stock_approval_system_api_v4.md`와 동기화하고, 변경 시 문서도 업데이트
- `script/run_api_tests.sh`에 각 리소스의 CRUD 및 상태 전이 스텝을 추가해 회귀 테스트 가능하도록 보완
- 샘플 데이터(`migration/002_sample_data.sql`)는 필수 참조 데이터만 유지하고, 대량 더미는 옵션 플래그로 분리
## 3. 선행 작업 및 의존성
- 데이터 모델 검증: 스펙과 현재 DB 스키마 일치 여부 확인, 필요한 경우 추가 마이그레이션 작성
- 인증/권한: 그룹-메뉴 권한 매핑을 API 보호 미들웨어에 적용 (추후 프론트엔드 권한 제어와 연동)
- 로깅/관측성: 주요 재고 트랜잭션 이벤트를 tracing 로그로 남겨 운영 대응
## 4. 수용 기준 (Acceptance Criteria)
- 모든 신규/확장된 엔드포인트에 대해 Actix 라우트, 도메인 DTO, SeaORM 리포지토리, 에러 매핑이 완비되어야 한다.
- `cargo check`, `cargo test`, `script/run_api_tests.sh`가 통과해야 하며, 샘플 DB로 기본 CRUD 시나리오가 동작할 것.
- README `Next Steps` 섹션 업데이트와 변경된 API 스펙 커밋이 포함되어야 한다.
## 5. 후속 조치
- 본 문서 확인 후 백엔드 담당자가 작업 범위/일정을 산출
- 작업 완료 시 프론트엔드 팀에 API mock 제거 및 실연동 착수 일정 공유
- 필요 시 추가 논의 사항을 본 문서 하단에 코멘트 형태로 기록

View File

@@ -0,0 +1,79 @@
# 백엔드 수정 요청서 (2025-10-16 갱신)
## 1. 배경
- Flutter 프런트엔드(`superport_v2`)와 최신 백엔드(`superport_api_v2`) 사이 계약을 점검한 결과, 다수의 엔드포인트가 미구현이거나 응답 스키마가 상이해 실사용 플로우를 마무리할 수 없다.
- 프런트는 Clean Architecture 구조 및 DTO를 백엔드 스펙(v4)에 맞춰 구현한 상태이며, 실연동 전까지 계약 정합성을 확보해야 한다.
- 본 문서는 백엔드 측 추가 개발/수정을 요청하기 위한 정리 문서이다.
## 2. 주요 이슈 요약
- 로그인 및 대시보드 핵심 엔드포인트(`/api/v1/auth/**`, `/api/v1/dashboard/summary`)가 존재하지 않아 애플리케이션 초기 진입이 불가능하다.
- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 미구현 상태다.
- 결재·재고 API 응답 키가 프런트 DTO와 불일치하여 승인 상태, 요청자, 제품/벤더 정보 등이 전부 기본값으로 표시되며, 단계/상태 전환 이후 최신 데이터를 확보할 수 없다.
- 결재 단계(`approval-steps`) API가 단계 CRUD/액션 수행 후 적절한 본문을 반환하지 않고, 목록 필터(승인자·상태·검색)도 지원하지 않는다.
- 그룹-메뉴 권한 API가 라우팅 정보를 제공하지 않고, 삭제 항목 조회 파라미터가 프런트와 불일치해 권한 동기화가 깨진다.
## 3. 상세 요청
### 3.1 로그인/세션 및 대시보드 API 구현
- 엔드포인트
- `POST /api/v1/auth/login`: `identifier`, `password`, `remember_me`(bool) 입력을 받아 `{ "data": { "access_token", "refresh_token", "expires_at", "user", "permissions" } }` 구조를 반환해야 한다. `user` 객체는 `{ id, name, employee_no, email, primary_group { id, name } }` 필드를 포함하고, `permissions``resource``actions[]`(소문자 문자열)로 구성된다.
- `POST /api/v1/auth/refresh`: `refresh_token`으로 세션을 갱신하며 응답 스키마는 로그인과 동일하다.
- `GET /api/v1/dashboard/summary`: `{ "data": { "generated_at", "kpis": [], "recent_transactions": [], "pending_approvals": [] } }` 형태로 내려 KPI 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 하며 각 대기 결재는 상세 조회용 `approval_id`를 함께 반환한다.
- 요구 사항
- `kpis[]` 항목은 `{ key, label, value, trend_label, delta }` 필드를 제공해 프런트 차트 증감률을 계산할 수 있도록 한다.
- `recent_transactions[]``{ transaction_no, transaction_date, transaction_type, status_name, created_by }` 문자열 필드로 구성한다.
- `pending_approvals[]``{ approval_id, approval_no, title, step_summary, requested_at }`을 포함하며 `requested_at`은 ISO8601 UTC 문자열로 반환한다.
- 로그인 실패 시 `invalid credentials`, 비활성 계정 접근 시 `account is inactive`, 갱신 토큰 만료는 `token expired`, 재사용·서명 오류는 `invalid token` 메시지를 반환해 프런트 알림 문구와 동일하게 맞춘다.
- 인증 실패(401), 세션 만료·권한 거부(403) 시 `{ "error": { "code": <http-status>, "message": "...", "details": [...] } }` 규격을 사용하고, 만료/재사용 토큰별 메시지를 문서화한다.
### 3.2 보고서 Export API 구현
- 엔드포인트
- `GET /api/v1/reports/transactions/export`
- `GET /api/v1/reports/approvals/export`
- 요구 사항
- 공통 쿼리: `from`, `to`, `format(xlsx|pdf)`, `transaction_status_id`, `approval_status_id`, `requested_by_id`.
- 트랜잭션 보고서 `from`·`to` 값은 `yyyy-MM-dd` 형식, 결재 보고서는 ISO8601 UTC 타임스탬프를 지원한다.
- 응답은 기본적으로 파일 스트림(`Content-Type`은 선택한 포맷의 MIME, `Content-Disposition: attachment; filename="<name>"`)이며, 스토리지 연계 시 `{ "data": { "download_url", "filename", "mime_type", "expires_at" } }` 메타데이터로 대체할 수 있다.
- `format=pdf` 요청도 정상 처리하고, 지원 불가 시 명확한 4xx 코드·메시지를 반환하도록 문서화한다.
- 모든 다운로드 요청에 대해 접근 권한·감사 로그 정책을 명시한다.
### 3.3 결재/재고 응답 스키마 정합성
- 결재 목록·단건 응답은 프런트 도메인 모델과 동일한 키를 사용한다.
- 단건 응답은 `{ "data": { ... } }` 혹은 `{ "data": { "approval": { ... } } }` 구조를 유지하고, `approval_no`, `transaction_no`, `status { id, name, color }`, `requester { id, employee_no, name }`, `current_step { id, step_order, status { id, name, is_blocking_next, is_terminal }, approver { id, employee_no, name }, assigned_at, decided_at, note }`, `steps[]`, `histories[]`, `created_at`, `updated_at`을 포함한다.
- 모든 단계·이력 항목은 `status` 키로 정규화하고(`step_status` 금지), `histories[]`에는 `action { id, name }`, `from_status`, `to_status`, `approver`, `action_at`, `note`를 내려준다.
- `approval` 객체에는 필요 시 `transaction { id, transaction_no }`, `template_name` 등을 함께 포함해 단계 목록에서도 동일 데이터를 재사용할 수 있도록 한다.
- 재고 트랜잭션 응답은 중첩 객체 구조를 보장한다.
- 헤더: `transaction_type { id, name }`, `transaction_status { id, name }`, `warehouse { id, warehouse_code, warehouse_name, zipcode { ... } }`, `created_by { id, employee_no, employee_name }`, `expected_return_date`.
- 라인: `lines[].product { id, product_code, product_name, vendor { id, vendor_name }, uom { id, uom_name } }`, `quantity`, `unit_price`, `note`.
- 고객: `customers[].customer { id, customer_code, customer_name }`, `note`.
- 결재 요약: `approval { id, approval_no, status { id, name, is_blocking_next } }`.
- `quantity`, `unit_price`는 BigDecimal 직렬화 그대로 전달하되 `null`은 키를 생략하지 말 것(프런트 DTO가 숫자·문자열 모두를 파싱한다).
- `warehouse.zipcode`는 최소 `zipcode`, `road_name`을 포함하고 추가 주소 필드가 있으면 그대로 노출한다.
- 상태 전환(Submit/Approve/Reject/Cancel/Complete) 응답은 최신 `data.transaction` 전체 또는 최소한 `data.transaction_status`, `data.updated_at`, `data.approval`을 포함해 UI가 즉시 갱신되도록 한다.
### 3.4 결재 단계/행위 API 정합성
- `GET /api/v1/approval-steps``approver_id`, `approval_id`, `status_id`(또는 `step_status_id`), `q`(결재번호·승인자 키워드)를 지원하고, 항상 `include=approval,approver,status` 형태의 확장을 처리한다. 응답 항목에는 `approval { id, approval_no, transaction_no, template_name }`이 포함되어야 한다.
- 단계 생성·수정·복구 응답은 `{ "data": { ... } }` 형태로 단계 요약을 반환하고, 단계 행위·일괄 배정 응답은 최신 결재 데이터를 `data.approval` 또는 `data.approval.steps`/`data.histories`에 담아 돌려준다.
- 모든 단계·행위 응답에서 단계 상태 키는 `status`로 통일하고, `step_status_id`는 요청/응답에서 보조 필드로만 유지한다.
### 3.5 그룹-메뉴 권한 응답 확장
- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`(가능하면 `path` 보조 필드 포함)를 항상 채운다.
- `deleted=true`(또는 `include_deleted=true`) 파라미터를 허용해 소프트 삭제 항목을 조회할 수 있게 하고, 응답 항목에 `is_deleted`를 노출한다.
- `include=group,menu` 확장을 공식화해 그룹/메뉴 요약을 한 번에 받을 수 있도록 한다.
### 3.6 결재 생성/수정 응답 보강
- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답은 `{ "data": { "approval": { ... } } }` 형태로 최신 결재 요약과 `steps[]`, 필요 시 `histories[]`를 포함해야 한다.
- `approval_status_id`가 생략되면 자동으로 기본 대기 상태를 설정하는 규칙을 명시하고, `approval_no`는 서버가 자동 발급(포맷 `APP-YYYYMMDDNNNN`)함을 문서화한다.
### 3.7 응답/에러 문서화 및 테스트
- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 모두 반영하고, 인증/대시보드/결재 단계/보고서 섹션을 최신 상태로 유지한다.
- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가한다.
## 4. 수용 기준
- 상기 엔드포인트 및 스키마 변경이 구현되고, 요청/응답이 문서와 일치해야 한다.
- 기존 204 응답은 JSON 응답으로 교체되고, 키(`data.approval`, `data.transaction` 등)가 프런트 기대와 동일해야 한다.
- `cargo fmt`, `cargo check`, `cargo test` 및 CI 파이프라인이 통과한다.
## 5. 후속 조치
- 백엔드 담당자가 개발 일정·우선순위를 산출해 프런트 팀과 공유.
- 구현 완료 후 샌드박스 환경에서 계약 검증 → 프런트엔드 실연동 검증 착수.

View File

@@ -0,0 +1,126 @@
# 상세 팝업 정보 영역 통합 계획
## 1. 배경
- 현재 모든 상세 팝업(마스터·결재·재고 등)은 `SuperportDetailDialog` 상단 summary/metadata(정보1)와 탭 섹션(정보2)에서 동일한 엔티티 데이터를 중복 노출한다.
- 사용자는 정보가 하나의 블록에서 완결되길 기대하지만, 구현상 개요(summary)와 `_OverviewSection` 사이에 필드가 분산되어 정보1/정보2가 따로 보이게 된다.
- 같은 API 데이터를 두 영역에 반복 표기하면서도 정보 간 일관성이 떨어져 UX 혼란이 발생한다.
## 2. 대상 팝업
| 분류 | 파일 |
| --- | --- |
| 결재 템플릿 | `lib/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart` |
| 결재 단계 | `lib/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart` |
| 벤더 | `lib/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart` |
| 그룹 | `lib/features/masters/group/presentation/dialogs/group_detail_dialog.dart` |
| 그룹 메뉴 권한 | `lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart` |
| 고객사 | `lib/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart` |
| 메뉴 | `lib/features/masters/menu/presentation/dialogs/menu_detail_dialog.dart` |
| 제품 | `lib/features/masters/product/presentation/dialogs/product_detail_dialog.dart` |
| 사용자 | `lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart` |
| 창고 | `lib/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart` |
> ※ `SuperportDetailDialog` 자체(`lib/widgets/components/superport_detail_dialog.dart`)를 공통으로 수정해야 하므로 목록에 포함.
## 3. 목표 UX
1. 정보1(상단 summary+metadata)에서 모든 읽기 전용 필드를 완결성 있게 노출한다.
2. 정보2(탭 영역)는 액션 중심(수정/삭제/히스토리 등)으로 단순화한다.
3. 동일 필드는 단 한 곳(정보1)에만 표시되도록 하여 중복을 제거한다.
## 4. 단계별 플랜
### 4.1 구조 파악 & 정리
- [x] 대상 다이얼로그마다 현재 summary/metadata/overview에 배치된 필드를 표로 정리한다.
- [x] 중복 필드, 정보1에 반드시 남겨야 할 핵심 필드(이름, 코드, 상태, 타임스탬프 등)를 분류한다.
- [x] 추가 데이터 소스 유무 확인(현행은 단일 엔티티 전달 → 별도 API 불필요).
#### 4.1.1 필드 배치 매핑
| 팝업 | Summary 필드 | Metadata 필드 | Overview/기타 섹션 필드 | 중복 & 정보1 핵심 정리 |
| --- | --- | --- | --- | --- |
| 결재 템플릿 | 템플릿명<br>설명 | ID<br>코드<br>생성일시<br>변경일시<br>비고 | 템플릿명<br>코드<br>설명<br>사용 여부<br>생성일시<br>변경일시<br>비고 | 중복: 이름/코드/설명/타임스탬프/비고. 핵심: summary=템플릿명·설명 유지, metadata=ID/코드/생성·변경/비고, 사용 여부는 summaryBadges에 집중. |
| 결재 단계 | 단계 순번<br>승인자 이름+사번<br>비고 | 결재 ID<br>트랜잭션번호<br>템플릿명<br>승인자 사번<br>배정일시<br>결정일시 | 단계 순서<br>승인자 이름<br>승인자 사번<br>상태<br>배정/결정일시<br>비고 | 중복: 승인자 사번·배정/결정 시각·비고. 핵심: summary=단계 제목+승인자, metadata=결재/템플릿 식별자+승인자 사번+상태+배정/결정/비고로 통합. |
| 벤더 | 벤더명<br>벤더코드 | ID<br>생성일시<br>변경일시<br>비고 | 벤더코드<br>벤더명<br>사용 여부<br>삭제 여부<br>비고<br>생성/변경일시 | 중복: 코드·이름·비고·타임스탬프. 핵심: summary=벤더명·코드, metadata=ID/코드/생성·변경/비고와 사용·삭제 상태(배지 또는 metadata)만 유지. |
| 그룹 | 그룹명<br>설명 | ID<br>생성일시<br>변경일시<br>비고 | 그룹명<br>설명<br>기본 여부<br>사용 여부<br>삭제 여부<br>비고<br>생성/변경일시 | 중복: 그룹명·설명·비고·타임스탬프. 핵심: summary=그룹명·설명, metadata=ID/상태/타임스탬프/비고, 기본 여부는 metadata로 이동. |
| 그룹 권한 | 그룹명 → 메뉴명 | ID<br>메뉴 경로<br>비고<br>생성일시<br>변경일시 | 그룹<br>메뉴<br>경로<br>CRUD 권한 4종<br>사용 여부<br>삭제 여부<br>비고<br>생성/변경일시 | 중복: 경로·비고·타임스탬프. 핵심: summary=그룹/메뉴 페어, metadata=ID/경로/CRUD 권한/상태/비고/타임스탬프, 삭제 시 배지. |
| 고객사 | 고객사명<br>고객코드 | ID<br>담당자<br>연락처<br>이메일<br>생성일시<br>수정일시 | 고객코드<br>유형(파트너/일반)<br>사용/삭제 여부<br>담당자<br>연락처<br>이메일<br>우편번호<br>주소<br>비고<br>생성/수정일시 | 중복: 담당자·연락처·이메일·타임스탬프. 핵심: summary=이름·코드, metadata=ID/유형/담당·연락·이메일/주소/상태/타임스탬프/비고. |
| 메뉴 | 메뉴명<br>경로 | ID<br>메뉴코드<br>표시순서<br>비고<br>생성일시<br>변경일시 | 메뉴코드<br>메뉴명<br>상위메뉴<br>경로<br>표시순서<br>사용 여부<br>삭제 여부<br>비고<br>생성/변경일시 | 중복: 코드·경로·표시순서·비고·타임스탬프. 핵심: summary=메뉴명+경로, metadata=ID/코드/상위/표시순서/상태/타임스탬프/비고. |
| 제품 | 제품명<br>제품코드 | ID<br>제조사<br>단위<br>생성일시<br>변경일시<br>비고 | 기본 정보: 제품코드·제품명·사용/삭제 여부·비고·생성/변경일시<br>관계 섹션: 제조사·단위<br>히스토리 섹션: 안내 문구 | 중복: 제조사/단위, 비고, 타임스탬프. 핵심: summary=이름·코드, metadata=ID/제조사/단위/상태/타임스탬프/비고, 관계 섹션은 액션/링크 중심으로 축소. |
| 사용자 | 직원명<br>사번 | ID<br>그룹<br>이메일<br>연락처<br>비고<br>비밀번호 변경일시<br>생성/수정일시 | 개요: 사번·이메일·연락처·그룹·사용/삭제 여부·비고·생성/수정일시<br>보안 섹션: 비밀번호 변경일시·강제 변경 여부 | 중복: 이메일·연락처·그룹·비고·타임스탬프·비밀번호 변경일시. 핵심: summary=직원명·사번, metadata=ID/그룹/연락처/이메일/비고/비밀번호 정보/상태/타임스탬프. |
| 창고 | 창고명<br>창고코드 | ID<br>우편번호<br>기본주소<br>상세주소<br>생성일시<br>변경일시<br>비고 | 기본 정보: 창고코드·창고명·사용/삭제 여부·비고·생성/변경일시<br>주소 섹션: 우편번호·주소·상세주소 | 중복: 주소 3종·타임스탬프·비고. 핵심: summary=이름·코드, metadata=ID/주소/상태/타임스탬프/비고, 별도 주소 섹션은 지도/편집 가이드를 위한 설명만 남김. |
- 모든 상세 다이얼로그가 단일 엔티티를 인자로 전달받고 있으며, 별도의 추가 API 호출이나 비동기 데이터 로딩 없이 summary/metadata/overview를 구성한다. 따라서 정보1에 필드를 통합할 때도 데이터 소스 확장이 필요 없다.
- 공통 컴포넌트 `SuperportDetailDialog`는 summary·metadata·섹션을 세로로 쌓는 단일 레이아웃으로, 현재 1열 `_MetadataTable`만 지원한다. 고객/창고처럼 필드가 10개 이상인 경우 스크롤이 길어지므로 2열화 또는 그룹 헤더 도입 필요성이 확인됐다.
### 4.2 공통 레이아웃 개편안 확정
- [x] `SuperportDetailDialog`에서 summary/metadata 블록이 “정보1”로 인지되도록 레이아웃/간격/구분선을 다듬는다.
- [x] metadata를 2열 테이블 또는 Grid로 확장해 더 많은 필드를 수용할 수 있도록 옵션 검토.
- [x] 탭 섹션에는 개요를 제외하고, 폼·위험 액션·히스토리 등 행위 중심 요소만 남기는 가이드 작성.
#### 4.2.1 닫기 인터랙션 정리
- 상세보기 계열 팝업은 기본적으로 세 가지 닫기 방식을 제공한다.
1. 팝업 우상단의 X 버튼
2. 팝업 바깥 영역 클릭(barrier dismissible)
3. 팝업 우하단 액션 영역의 “닫기” 버튼
- 3번 “닫기” 버튼은 X 버튼과 동일한 동작을 반복해서 정보 과밀감을 유발하므로 UI에서 제거한다. 필요 시 개별 화면에서만 액션 버튼을 추가하도록 하고, 공통 다이얼로그에서는 닫기 버튼을 렌더링하지 않는다.
#### 4.2.2 정보1 레이아웃 스펙
- `SuperportDetailDialog` 상단 영역을 `infoPanel`(summary + metadata)로 묶어 `ShadCard` 스타일로 렌더링한다. 좌우/상하 여백을 통일해 summary/metadata가 하나의 덩어리(정보1)로 인지되도록 했다.
- summary 영역은 기존 `summary` 슬롯을 그대로 사용하며, 대표 타이틀/보조 텍스트 → 배지 순의 수직 스택을 유지한다. summary/metadata 사이 여백은 카드 내부에서만 관리해 섹션과 시각적으로 분리된다.
- metadata는 `_MetadataGrid`로 교체해 2열 레이아웃을 기본값(`metadataColumns = 2`)으로 제공한다. 가로폭이 520px 미만이거나 열 수가 항목 수보다 많으면 자동으로 1열로 접어 UX를 보장한다.
- metadata 항목은 라벨/아이콘/값이 세로 정렬된 카드 타일로 렌더링되며, 값 영역은 `Widget` 그대로 머지되어 텍스트·뱃지·아이콘 등 자유로운 구성을 유지한다.
- `infoPanelPadding` 파라미터를 추가해 summary+metadata 블록과 탭 섹션 사이 간격을 한 번에 조절할 수 있도록 했고, `metadataColumns` 파라미터로 팝업 별 열 수 튜닝도 가능하다.
#### 4.2.3 탭 구성 가이드
- 탭 영역은 “행위 중심 정보2”로 재정의한다. 읽기 전용 필드는 summary/metadata에 모두 흡수하고, 탭에는 폼, 수정/위험 액션, 히스토리, 관계 관리 등 상호작용성 있는 콘텐츠만 남긴다.
- `SuperportDetailDialogSection`에 overview 전용 빌더를 남겨두지 않고, 각 다이얼로그는 `_OverviewSection` 파일을 제거하면서 해당 필드를 `metadata` 리스트로 옮긴다.
- 탭이 한 개만 남는 경우 자동으로 단일 패널 모드로 전환되므로, 최소 “폼/위험/히스토리” 중 필요한 섹션만 남겨도 된다. 이때 `initialSectionId`는 남은 섹션 ID 중 하나로 업데이트한다.
### 4.3 다이얼로그별 코드 수정
- [ ] 각 다이얼로그에서 `_OverviewSection`(읽기 전용)을 제거하고 해당 필드를 metadata 배열로 이동.
- [ ] summary에는 대표 타이틀/보조 텍스트만 남기고 상태 배지는 `summaryBadges`로 유지.
- [ ] 탭 목록에서 제거된 섹션을 반영하여 ID 상수·초기 선택 로직을 정리.
- [ ] 필요 시 metadata 행 구성을 위한 헬퍼(예: `_KeyValueRow`)를 공통 유틸로 승격하거나 삭제.
#### 4.3 진행 현황
- [x] 결재 템플릿 팝업: `_TemplateOverviewSection` 제거, metadata에 상태/코드/타임스탬프/비고를 통합하고 초기 탭을 `steps`로 변경했다. `_KeyValueRow` 유틸은 더 이상 사용하지 않아 삭제 완료.
- [x] 결재 단계 팝업: `_ApprovalStepOverviewSection` 제거, 단계 순서·승인자·상태·배정/결정일시·비고를 metadata로 이동하고 탭은 수정/삭제(복구)만 남겼다. 권한에 따라 섹션이 없을 수 있으므로 info panel만으로도 정보가 완결된다.
- [x] 벤더 팝업: `_VendorOverviewSection``_KeyValueRow` 삭제, ID/코드/상태/삭제 상태/타임스탬프/비고를 metadata로 통합하고 summary에는 이름만 남겼다. 초기 탭은 생성/수정으로 변경되어 정보1+액션 분리가 완료됐다.
- [x] 그룹 팝업: `_GroupOverviewSection` 제거, 기본 여부/사용·삭제 여부/타임스탬프/비고를 metadata로 이동했으며 summary는 이름·설명만 유지한다. 탭은 등록/수정과 삭제/복구만 남겨 행위 중심 구조를 맞췄다.
- [x] 그룹 권한 팝업: `_GroupPermissionOverviewSection`을 삭제하고 그룹/메뉴/경로/CRUD 권한/상태/삭제 여부/노트/타임스탬프를 metadata에 넣었다. summary에는 `그룹 → 메뉴`만 남기고 탭은 수정, 삭제/복구(또는 등록)만 유지한다.
- [x] 고객사 팝업: `_CustomerOverviewSection` 제거, 고객 코드/유형/연락처/주소/상태/삭제 여부/타임스탬프/비고를 metadata로 통합하고 summary는 이름만 남겼다. 탭은 등록/수정+삭제/복구만 유지해 정보1과 액션을 분리했다.
- [x] 제품 팝업: `_ProductOverviewSection` 제거, 제품 코드/상태/삭제 여부/제조사/단위/비고/타임스탬프를 metadata로 이동하고 summary에는 제품명만 남겼다. 탭은 연결 관계·히스토리(행위 가이드)와 등록/수정·삭제/복구만 남도록 조정했다.
- [x] 창고 팝업: `_WarehouseOverviewSection`/`_WarehouseAddressSection` 삭제, ID/창고코드/상태/삭제 여부/주소/비고/타임스탬프를 metadata로 결합했고 탭은 등록·수정과 삭제/복구만 남겼다. 상세 모드 초기 탭을 `edit`으로 지정해 정보1과 행위 영역이 명확히 구분된다.
- [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에 필드가 모두 나타나는지, 탭이 최소 한 개만 남았을 때 동작하는지 확인.
- [ ] `flutter analyze`, `flutter test` 전 프로젝트 실행.
- [ ] 스타일 변경 시 주요 화면 캡처를 남겨 리뷰 참고자료로 사용.
## 5. 예상 산출물
- 수정된 상세 다이얼로그 10여 개 및 공통 컴포넌트 1개.
- 테스트 업데이트(다이얼로그 위젯 테스트 최소 1개 이상).
- 필요 시 설계 문서(본 문서 갱신 포함) 및 회고 노트.
## 6. 리스크 및 대응
| 리스크 | 대응 |
| --- | --- |
| metadata 영역에 필드가 많아져 가독성 저하 | 2열 레이아웃, 섹션 헤더, collapse 등 UI 개선 옵션 검토 |
| 탭 제거로 인한 라우팅/초기 섹션 ID 로직 오류 | `initialSectionId` 사용 여부 재확인 및 단위 테스트 작성 |
| 테스트 스냅샷/Golden 미존재 시 회귀 미포착 | 주요 다이얼로그에 대한 Widget 테스트 보강 |
## 7. 이후 액션
1. 4.1의 필드 매핑 표를 작성해 본 문서를 업데이트.
2. 공통 레이아웃 확정 후 시놉시스/디자인 확인.
3. 다이얼로그-by-다이얼로그로 리팩토링 진행, 각 단계 완료 시 테스트/리포트 공유.

View File

@@ -0,0 +1,27 @@
# 오류 메시지 가이드
## 목적
- API에서 전달되는 오류 메시지와 필드 상세 정보를 사용자에게 명확하게 전달하여 재시도 가이드를 제공한다.
- 화면별 토스트 · 다이얼로그 문구를 통일해 SRP 원칙을 유지하고, 번역/UX 협업 비용을 줄인다.
## 작성 원칙
- **API 우선**: `Failure.describe()`가 반환한 메시지를 그대로 노출한다. 자체 문구는 메시지가 비어 있을 때에만 사용한다.
- **콘텍스트 보강**: 필드 오류가 존재하면 `필드명: 메시지` 형식으로 추가 설명을 붙이고, 중복 문구는 제거한다.
- **톤 & 매너**: 짧고 직설적인 한국어 문장을 사용한다. 사용자 액션(재시도·확인 요청 등)은 문장 말미에 배치한다.
- **민감 정보 보호**: 서버에서 전달된 토큰/쿼리/내부 식별자는 노출하지 않는다.
## UI 표현 규칙
- **토스트(ScaffoldMessenger)**: 치명도가 낮은 검증·권한 오류는 토스트로 표시한다. 제목 없이 본문 한 줄을 유지한다.
- **다이얼로그**: 저장/제출 계열 실패로 추가 입력이 필요한 경우 다이얼로그를 사용하고, 본문 첫 줄에 `Failure.describe()` 결과를 배치한다.
- **배지/배너**: 기능 플래그로 비활성화된 경우 상세 카드 상단에 `ShadBadge.outline` 으로 안내한다.
- **모바일 카드**: 상태 전이 버튼이 비활성화되면 동일 메시지를 본문 하단에 `Text` 로 노출한다.
## 테스트 체크리스트
- `test/features/inventory/inbound_page_test.dart` — 상신 실패 시 서버 메시지가 토스트로 노출되는지 검증.
- `test/features/reporting/reporting_page_test.dart` — 창고 목록 실패 후 재시도 동작 및 메시지 유지 확인.
- 필요 시 추가 화면 테스트에서 `Failure.describe()` 값을 매칭하도록 `InventoryTestStubConfig`를 활용한다.
## 협업 프로세스
1. 신규 또는 수정 메시지는 UX 리뷰어와 공유하여 표현 톤을 확정한다.
2. 합의된 문구를 본 가이드에 추가하고 PR 설명에 링크한다.
3. 번역이 필요한 경우 `doc/localization_queue.md`에 티켓을 생성한다.

View File

@@ -1,40 +1,156 @@
# Frontend API Alignment Plan
# Frontend API Integration Task Plan
## 1. 현황 요약
- Environment `API_BASE_URL` 기본값은 `http://localhost:8080`이며 버전 prefix(`/api/v1`)는 포함되어 있지 않다.
- Master/Approval/Inventory 모듈 대부분이 `_basePath = '/<resource>'`로 정의되어 있어 실제 백엔드(`/api/v1/...`)와 경로가 불일치한다.
- 입고/출고/대여 화면은 `_mockRecords`, `Inventory*Catalog` 등 정적 데이터를 사용하고 있으며, 서버 응답 모델과 연결되어 있지 않다.
- 승인 도메인(approvals/steps/histories/templates)은 Repository/DTO가 준비되어 있으나 백엔드 미구현 상태라 기능 플래그로 비활성화되어 있다.
## 진행 현황 스냅샷 (2025-10-31 기준)
- 단계 1~2: 공통 네트워크 인프라와 마스터 도메인 원격 저장소/테스트가 모두 구축돼 실 API 계약 기준 코드가 자리잡았다.
- 단계 3: 결재 레이어는 저장소·컨트롤러·위젯 테스트까지 마쳤으며 `canProceed` API, `include_pending` 필터, 전표 타임스탬프 동기화 및 `FEATURE_APPROVAL_FLOW_V2` 토글 alias 대응이 반영됐다.
- 단계 4: 재고 트랜잭션 컨트롤러가 submit/approve/reject/cancel/complete 전 구간을 API 호출로 전환했고, Approval Draft 서버 저장/복원 사용자 흐름을 재고/결재 화면에 통합했다. 보고서 기능은 `ReportingRepositoryRemote`가 바이너리/링크 응답두 처리한다.
- 단계 5: 공통 테이블 사양과 Failure 매퍼 보강을 완료했고, 남은 작업은 배포 체크리스트(F8)와 스펙 회귀 테스트 확장이다.
- (2025-10-29) Approval Flow v2 대응을 위해 `ApprovalSubmissionInput` 등 도메인 입력 모델과 `/approval/submit|approve|reject|recall|resubmit|history` 호출을 Data 레이어에 도입했다. 기존 `create/update` 경로는 레거시 화면이 교체될 때까지 병행 유지한다.
- (2025-11-01) `ApprovalHistoryController`가 감사 로그·카탈로그 기반 코드→ID 캐싱(`_auditActions``_actionIdsByCode`)과 `ApiClient.buildQuery` `filters` 매개변수를 적용해 `approval_action_id`/`action_from`/`action_to`를 전송하며, 위젯 테스트(`approval_history_page_test.dart`)로 회귀를 감시한다.
## 2. 즉시 처리 가능한 정렬 작업
1. **공통 경로 상수화**
- `lib/core/network/api_client.dart` 또는 별도 헬퍼에 `const apiV1 = '/api/v1';` 정의.
- 모든 Remote Repository의 `_basePath` 앞에 `${ApiRoutes.apiV1}` prefix 적용.
- `WarehouseRepositoryRemote`, `VendorRepositoryRemote`, `UomRepositoryRemote`, `TransactionTypeRepositoryRemote`, `TransactionStatusRepositoryRemote`, `ApprovalStatus/Action` 등 전체 점검.
2. **DI/환경 값 점검**
- `.env.*` 예시 파일에 `API_BASE_URL` 주석으로 "버전 prefix 없음" 명시.
- 필요 시 `Environment.baseUrl``/api/v1`를 포함한 값을 직접 지정해도 되지만, 기존 백엔드 관례에 맞춰 prefix 상수 사용 권장.
3. **기 구현 백엔드 연동**
- 벤더/단위/거래유형/거래상태/결재상태/결재행위/창고 화면에서 `Feature_*` 플래그를 통해 API 호출 활성화.
- 응답 파싱 결과가 UI에 반영되는지 확인하고, 로딩/에러 핸들러 추가.
4. **테스트/검증**
- `flutter analyze`, `flutter test` 실행.
- 가짜 데이터 비활성화 후 빈 응답 대비 UI(Empty state) 정상 동작 확인.
## 문서 동기화 규칙
1. `superport_api_v2` 리포지터리의 `stock_approval_system_*.md` 문서를 단일 소스로 간주하고, 수정은 반드시 백엔드 리포지터리에서 먼저 수행한다.
2. 백엔드 문서 변경 후 프론트 리포지터리 루트에서 `tool/sync_stock_docs.sh`를 실행해 `doc/` 경로를 갱신한다. CI 또는 로컬 검증 시에는 `tool/sync_stock_docs.sh --check`로 차이를 확인한다.
3. 문서 차이가 감지되면 동기화 커밋을 생성하고 PR 본문에 백엔드 커밋 링크를 포함해 리뷰어가 출처를 추적할 수 있도록 한다.
## 3. 백엔드 작업 의존 영역
1. **고객/제품/직원/권한 등 마스터 확장**
- 백엔드 `/api/v1/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions` 구현 후 Remote Repository 활성화.
- DTO 매핑 검증 및 리스트/폼 화면에 실데이터 연동.
2. **승인(Approvals) 플로우**
- `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates` 엔드포인트 제공 시 기능 플래그 해제.
- Step assign/action API 응답 구조가 `ApprovalDto` 기대 필드(steps, actions, histories)를 포함하는지 확인.
3. **재고 트랜잭션 (입고/출고/대여)**
- `/stock-transactions` 및 관련 라인/승인 API가 준비되면 `_mockRecords`, `Inventory*Catalog` 제거.
- 목록 필터/페이지네이션을 API Query와 동기화하고, 상세 모달 입력/수정 흐름을 서버 모델로 전환.
4. **우편번호 검색**
- `/zipcodes`(검색) API 구현 시 `PostalSearchRepositoryRemote` 경로와 파라미터를 확인하고 UI를 연동.
## Approval Flow v2 연동 계획 (현황)
- [x] 백엔드 세부 작업 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)과 프런트 작업 계획(`doc/approval_flow_frontend_task_plan.md`)을 동기화했다.
- [x] 입고/출고/대여 등록 화면에 결재 단계 구성 섹션을 추가하고 제출 요청에 Approval payload를 병합했다 (`lib/features/inventory/*/presentation/pages/*_page.dart`).
- [x] 결재 템플릿/이력 메뉴를 `ShadTable` 기반으로 재구성하고 recall/resubmit, 감사 로그 UI를 확장했다 (`lib/features/approvals/request/presentation/widgets/`, `lib/features/approvals/history/presentation/pages/approval_history_page.dart`).
- [x] Approval 관련 DTO/레포지토리/유즈케이스를 전면 재정비하여 신규 엔드포인트(`/approval/submit|approve|reject|recall|resubmit`, `/approval/templates`)와 계약을 맞췄다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart`, `lib/features/approvals/domain/usecases/*`).
- [x] 테스트 체계에 결재 단계 추가/삭제/회수/재상신 위젯·통합 시나리오를 추가했고 `integration_test/approvals_flow_test.dart`로 회귀를 검증한다.
## 4. 릴리즈 절차
- 기능별 Feature Flag를 단계적으로 해제하면서 QA 진행.
- 각 단계에서 `script/run_api_tests.sh`로 백엔드 검증 → 프론트 `flutter test` → 수동 시연 순으로 검증 체계 유지.
- API 변경 사항은 `CHANGELOG``doc/backend_change_requests.md`와 동기화해 추후 회고/인수에 활용.
## 0. 사전 준비 및 브랜치 전략
1. 스테이징/운영 환경 여부와 무관하게 모든 기능은 실제 API 계약(`stock_approval_system_api_v4.md`)을 단일 소스로 삼고, 로컬 개발에서도 동일 계약을 기준으로 구현한다.
2. 프론트엔드 작업용 브랜치를 `feature/api-integration` 형태로 생성하고, 단계별 작업이 끝난 뒤 스쿼시 머지한다.
3. `.env.development`/`.env.production``API_BASE_URL`을 최신 서버 URL(가용 시)을 기입하고, 베이스 URL에는 버전 prefix(`/api/v1`)가 포함되지 않는다고 주석으로 명시한다.
## 1. 공통 네트워크 인프라 정비
1. [완료] `lib/core/network/api_routes.dart`(또는 동일 역할 파일)에 `static const apiV1 = '/api/v1';`를 추가하고, `ApiClient` 계층이 prefix를 중복으로 붙이지 않도록 확인한다.
2. [완료] 모든 Remote Repository의 `_basePath``${ApiRoutes.apiV1}/<resource>` 형태로 교정한다(범위: `lib/features/*/data/repositories/**/*.dart`).
3. [완료] `Environment.initialize()` 이후 `lib/injection_container.dart`에서 Customers, Products, Employees, Groups, Menus, GroupMenuPermissions, ApprovalTemplates, Approvals, StockTransactions 등 신규 리포지토리를 전부 등록한다.
4. [완료] 네트워크 예외 처리(`ApiClient``Failure`)와 401 재인증 흐름을 단위 테스트로 검증해 실제 API 연결 시 동작 보장을 확보한다.
## 2. 마스터 도메인 API 연동 준비
1. [완료] Customers
- DTO를 백엔드 응답 스키마(`customer_code`, `zipcode` 객체 등)에 맞춰 업데이트한다.
- `CustomerRepositoryRemote`에 목록/상세/생성/수정/삭제/복구 메서드를 구현하고, 컨트롤러·페이지에서 검색/활성 상태/페이지네이션 파라미터를 전달한다.
- 테스트: Repository 인터페이스와 위젯 테스트로 필터·복구 시나리오를 검증한다.
2. [완료] Products / Vendors / UOMs
- `_basePath``/api/v1/...`로 통일하고 `include=vendor,uom` 파라미터를 지원한다.
- 빈 응답 시 UI 안내가 노출되는지 위젯 테스트를 추가한다.
3. [완료] Employees / Groups / Menus / Group-Menu Permissions
- 각 Remote Repository를 구현하고 권한 편집 화면을 서버 스키마에 맞춘다.
- `PermissionManager`가 서버 권한 데이터를 사용하도록 업데이트하고 단위 테스트로 검증한다.
4. [완료] Warehouses / Transaction Types / Transaction Statuses / Approval Statuses / Approval Actions
- `InventoryLookupRepositoryRemote` 단위 테스트를 통해 상태/타입/결재 상태 API를 통합했고, 입출고/대여 필터는 모두 실데이터 기반 위젯으로 교체했다. 정적 고객/제품 카탈로그를 제거해 컨트롤러와 페이지가 동일한 실데이터 경로만 사용하며, 승인·반려·취소 버튼은 컨트롤러/상세 카드/모바일 리스트에서 API 기반 상태 전이를 수행한다.
5. [완료] Zipcodes
- `/api/v1/zipcodes` 규격(`q`, `page`, `page_size`)에 맞춰 `PostalSearchRepositoryRemote`를 조정하고 자동완성 위젯 테스트를 강화한다.
## 3. 결재(Approvals) 도메인 실연동 준비
1. [완료] Feature Flag를 `true` 기본값으로 전환하되, 서버가 준비되기 전에는 UI에서 불필요한 호출이 반복되지 않도록 로딩/에러 처리를 정교화한다 — 개발/운영 환경 모두 `FEATURE_APPROVALS_ENABLED=true`를 기본으로 두고, 운영 배포 전이라도 백엔드 미준비 시에는 `.env.*`에서 수동으로 비활성화하도록 가이드를 명시했다.
2. [진행중] `ApprovalRepositoryRemote` 확장
- (완료) 목록/상세 `include=steps,histories,template` 옵션과 생성/수정/삭제/복구/`canProceed` 호출을 구현했다 — `can-proceed` 엔드포인트까지 연동해 컨트롤러에서 액션 실행 전 검증하도록 구성했다.
- (2025-10-29) `submit`/`approve`/`reject`/`recall`/`resubmit`/`listHistory` 메서드와 대응 DTO(`ApprovalSubmitRequestDto`, `ApprovalResubmitRequestDto`, `ApprovalDecisionRequestDto`, `ApprovalRecallRequestDto`, `ApprovalAuditListDto`)를 추가했다. 컨트롤러·유즈케이스 연결은 F2 단계에서 마이그레이션한다.
3. [완료] `ApprovalStepController.performStepAction``/api/v1/approval-steps/{id}/actions`로 요청을 보낸 뒤 응답으로 상태를 갱신하도록 구성한다.
4. [완료] Approval Templates
- 템플릿 CRUD/restore 및 스텝 편집 API 연동을 구현하고, 템플릿 적용 시 `/approvals/{id}/steps` 호출과 연계되도록 리팩터링한다.
5. [완료] 테스트
- `ApprovalController``ApprovalPage` 권한 테스트에 `canProceed` true/false 흐름을 추가했고, 기능 플래그 on/off 시나리오를 커버하는 위젯 테스트를 유지하고 있다.
6. [ ] 결재 열람 제한 연동
- 상신자/기결재자만 목록·상세 API를 조회할 수 있도록 `ApprovalRepositoryRemote`에 403 (`APPROVAL_ACCESS_DENIED`) 처리 분기를 추가하고, UI에서 권한 토스트/리다이렉트를 구현한다.
## 4. 재고 트랜잭션 (입고/출고/대여) 실데이터 전환 준비
1. [완료] Repository 작성
- `StockTransactionRepositoryRemote`, `TransactionLineRepositoryRemote`, `TransactionCustomerRepositoryRemote``/api/v1/stock-transactions` 계열 엔드포인트에 맞춰 구현한다.
- `include=lines,customers,approval` 파라미터를 지원해 상세 응답을 완성한다.
- ApiClient 모킹 기반 단위 테스트로 쿼리/경로/페이로드 구성을 검증한다.
- 신규 `status`/`include_pending` 파라미터를 지원해 초안·상신 전표는 기본 목록에서 제외하고, 대기 전용 화면에서만 렌더링한다.
2. [진행중] Controller 연동
- (완료) `InboundPage`, `OutboundPage`, `RentalPage`에서 `_mockRecords`를 제거하고 `StockTransactionRepository` 기반 실데이터를 로드하도록 전환했다.
- (완료) 데이터 페칭 로직을 전용 컨트롤러로 분리하고 페이지가 컨트롤러 상태를 구독하도록 리팩터링했다.
- (진행) 상태 전이 액션(Submit/Approve/Reject/Cancel/Complete)을 `doc/stock_approval_system_api_v4.md` 4.7절 규격에 맞춰 API 호출 기반으로 정비한다 — submit/approve/reject/cancel/complete 모두 컨트롤러·위젯에 연결되도록 리팩터링하고, 생성·수정 다이얼로그의 `StockTransaction*Input` 매핑과 공통 위젯 교체를 마무리한다.
3. 상세 모달 UI
- 서버 응답 스키마에 맞춘 DTO→Domain 변환기를 작성하고, 편집/삭제 후 상태 동기화를 서버 응답으로 수행한다.
4. 테스트
- 각 컨트롤러 단위 테스트에서 상태 전이 및 라인/고객 관리 로직을 검증하고, 위젯 테스트로 목록 로딩/빈 상태/승인 버튼 시나리오를 확인한다.
5. 레거시 제거
- `Inventory*Catalog`, `_mockRecords`, `Fake*Repository` 파일을 삭제하고, `pubspec.yaml`에서 불필요한 참조를 정리한다.
## 5. 권한/실패 처리 고도화
### 5-1. 권한 경로 정규화
- [x] `PermissionResources._aliases`를 서버 표준 경로(`/stock-transactions`, `/approvals/...`)와 비교해 누락/중복을 정리한다. (2025-10-19) 절대 URL·쿼리 문자열 정리를 포함한 `_sanitize` 보강과 `/reports/*` 별칭 추가를 완료했다.
- [x] `PermissionManager`가 반환하는 권한 키와 페이지 컨트롤러에서 사용하는 의존성을 점검해 동일한 정규화 로직을 적용한다. (2025-10-19) `PermissionManager` 테스트에 경로 별칭 시나리오를 추가해 정규화 동작을 검증했다.
- [x] `test/core/permissions/permission_resources_test.dart`를 추가해 경로 정규화 케이스(슬래시, 대소문자, 하위 경로)를 검증한다.
### 5-2. Failure 파서 고도화
- [x] Stage 7 실패 로그(`log/API_TEST_RESULT_20251014_155128.txt`)와 `ApiErrorMapper`를 비교해 상태 전이/권한 오류 메시지 누락을 보완한다. (2025-10-19) 403/409/422 응답이 `details/context/reasons`를 그대로 보존하도록 매퍼 분기를 확장했다.
- [x] 409/422 응답의 `details`를 도메인 계층에서 소비할 수 있도록 DTO 매핑 헬퍼를 확장한다. (2025-10-19) `FailureParser``context` 노드를 흡수하고 `Failure.describe()`가 필드 오류를 병합하도록 보강했다.
- [x] 신규 분기별 단위 테스트를 추가해 UI 레이어가 일관된 메시지를 받을 수 있도록 한다. (`test/core/network/api_error_test.dart`, `test/core/network/failure_parser_test.dart`)
### 5-3. 실패 메시지 통합
- [x] 입·출·대여/결재/보고서 페이지의 토스트/다이얼로그 오류 메시지를 `ApiException` 기반으로 통합한다.
- [x] UX 팀과 합의된 메시지 톤을 정리하고 `doc/error_message_guide.md`에 반영한다. (2025-10-19) 오류 메시지 톤/노출 위치/테스트 체크리스트를 정리했다.
- [x] 주요 위젯 테스트를 업데이트해 메시지 노출 시나리오를 자동 검증한다. (2025-10-19) `reporting_page_test.dart` 재시도 흐름과 컨트롤러·파서 단위 테스트로 메시지 일관성을 검증한다.
### 5-4. 실 API 플로우 검증
- [x] `.env.staging.example`에 스테이징 변수 템플릿을 추가하고 `integration_test/stock_transaction_state_flow_test.dart`가 환경 변수 부족 시 필요한 항목을 안내하도록 보강했다. 실제 값은 배포 계정 확보 후 `.env.staging`에 기록한다.
- [x] `flutter test -d macos integration_test/stock_transaction_state_flow_test.dart` 실행 결과를 `log/API_TEST_RESULT_20251014_173850.txt`로 보관한다.
- [x] 실행 로그와 발견 이슈를 본 문서와 `doc/IMPLEMENTATION_TASKS.md` DoD 섹션에 반영한다.
## 6. 문서 및 운영 가이드 정리
1. 본 문서를 체크리스트로 활용하며 진행 상황을 주기적으로 업데이트한다.
- (2025-10-14) 입·출·대여 승인/반려/취소 액션을 컨트롤러와 UI에서 API 호출로 연결하고 진행률을 90%로 상향했다.
- (2025-10-15) 입·출·대여 생성/수정 모달을 `StockTransaction*Input` 기반으로 실 API와 연계하고 작성자·고객 선택 위젯을 실데이터 기준으로 교체했다.
- (2025-10-16) 라인/고객 편집 동기화 서비스와 컨트롤러/위젯 테스트를 추가해 단계 4 진행률을 100%로 마무리했다.
- (2025-10-18) 보고서 내보내기 화면을 실 API와 연결하고 다운로드/에러 UX를 정비했으며, 스테이징 환경 검증용 통합 테스트 스켈레톤과 QA 시나리오 문서를 추가했다.
2. [완료] `README.md` "API 연동" 섹션에 신규 자원 목록과 필수 환경 변수 설명을 추가한다.
- (2025-10-20) `/api/v1` 경로와 연동된 리소스 목록, 필수 환경 변수(`ENV`, `API_BASE_URL`, `FEATURE_*`, `PERMISSION__*`) 안내를 README에 정리했다.
3. [완료] `CHANGELOG.md`에 사용자 영향도가 높은 변경사항(승인/재고 실시간 연동)을 기록한다.
- (2025-10-20) Failure 기반 오류 메시지 통합과 재고/결재 API 연동 내역을 담은 변경 기록을 추가했다.
4. [완료] QA 시나리오 문서(`doc/qa/...`)가 있다면 실제 API 기준으로 갱신하고, 테스트 데이터 구성 방식을 명시한다.
- (2025-10-20) 스테이징 QA 문서에 `/api/v1` 마스터 조회 절차와 데이터 생성/정리 플로우를 명시해 테스트 데이터 재현 방법을 고정했다.
## 7. 실제 서버 연동 및 배포 절차 (최종 단계)
1. [완료] 백엔드 서버 기동 및 점검
- Homebrew로 PostgreSQL 16을 설치해 로컬 DB를 준비한 뒤, `migration/001_initial_schema.sql``script/load_sample_data.sh --with-demo-data`로 스키마·샘플 데이터를 적재했다.
- `cargo run -p backend``nohup`으로 기동하고 `curl http://127.0.0.1:8080/health`로 헬스 체크를 확인했다.
- `script/run_api_tests.sh --base-url http://127.0.0.1:8080``stock_approval_system_api_v4.md` (2025-09-18) 스펙 반영 버전으로 재실행해 상태 전이·권한 복구 시나리오가 모두 통과하는지 확인하고, 결과 로그(log/API_TEST_RESULT_YYYYMMDD_HHMMSS.txt)를 갱신한다.
- (2025-10-19) 프론트 통합 테스트(`integration_test/stock_transaction_state_flow_test.dart`)의 환경 변수 안내 및 `.env.staging.example` 템플릿을 추가했다. 백엔드에서 재고 상태 전이/권한 복구 엔드포인트를 배포하면 스테이징 토큰·ID를 확보한 뒤 재실행한다.
2. [완료] 정적 분석 및 테스트
- `flutter analyze` → No issues found.
- `flutter test --coverage` → 모든 230개 테스트 통과, `coverage/lcov.info` 생성.
- `flutter build web --release``build/web` 산출(Wasmtime dry-run 경고는 `flutter_secure_storage_web` 의존으로 인한 JS interop 제한).
3. [완료] 수동 점검
- 로컬 백엔드 + 스텁 데이터를 이용해 위젯/통합 테스트로 승인·입출고·대여 핵심 플로우를 재검증했다(`group_permission_page_test`, `inbound_page_test` 등). 추가 수동 검증은 스테이징 환경 재가동 시 실행 예정이나, 필수 경로는 자동화 테스트/스크립트로 커버했다.
4. [완료] 배포 준비
- `assets/.env.production` 기준 값과 README의 환경 변수 설명을 재확인했으며, `flutter build web --release` 산출물을 통해 배포 아티팩트 생성을 검증했다.
- 최종 머지 전 `notify.py` 호출 및 릴리스 노트/환경 파일 확정 프로세스는 배포 승인 시점에 수행하도록 안내를 남긴다.
- 백엔드 v4 스펙 반영 체크리스트
- [x] 재고 상태 전이 API 회귀 테스트를 `doc/stock_approval_system_api_v4.md` 4.7절 기준으로 재작성하고 submit/approve/reject/cancel/complete 호출 성공 여부를 통합 테스트에 반영한다.
- (2025-11-05) `integration_test/stock_transaction_state_flow_test.dart`에서 가상/실제 환경 모두 `submit → approve → complete`, `submit → cancel`, `submit → reject` 흐름을 검증하도록 리팩터링했다.
- 상태 전이 요청 본문에 `note` 필드를 전달하도록 `StockTransactionRepository` 인터페이스와 원격 구현·단위 테스트를 갱신했다.
- [x] 그룹-메뉴 권한 복구 API(`POST /group-menu-permissions/{id}/restore`) 시나리오를 복구해 삭제/복구 UI가 `include_deleted=true` 응답을 사용하는지 검증한다.
- 삭제 포함 토글이 활성화된 상태에서 복구 후 재조회 시 `include_deleted=true`가 유지되는지를 컨트롤러 단위 테스트로 심사하고, 복구 직후 목록이 최신 상태로 동기화되도록 확인했다.
- [x] 백엔드 배포 확인 후 `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그 해제 시나리오와 운영 전환 체크리스트를 정리한다.
- 운영 배포 전 점검: (1) 스테이징에서 통합 테스트 승인을 완료하고, (2) 백엔드 릴리스 노트의 마이그레이션 완료 여부를 확인한다.
- 배포 직후 절차: `assets/.env.production``FEATURE_STOCK_TRANSITIONS_ENABLED` 값을 `true`로 전환하고 운영 배포 파이프라인에서 해당 파일을 사용해 웹 번들을 재생성한다. 배포 이후 재고 화면의 상신/승인 버튼 노출과 토스트 메시지를 QA 체크리스트에 따라 검증한다.
- 롤백 가이드: 장애 발생 시 동일 순서로 토글을 `false`로 되돌리고, 통합 테스트의 `STAGING_RUN_TRANSACTION_FLOW``false`로 설정해 회귀 시나리오를 비활성화한다.
## 8. 재고 생성 결재 정보 수집 계획 (2024-08-XX 업데이트)
> 현황: `StockTransactionApprovalInput`과 인벤토리 컨트롤러 초안 저장 로직이 반영되어 UI/테스트 레벨에서 요구사항을 충족한다 (`lib/features/inventory/*/presentation/controllers/*_controller.dart`, `test/features/inventory/*/presentation/controllers/*_controller_test.dart`).
1. **신규 입력 필드 구성**
- 입고/출고/대여 등록 모달에 “결재 정보” 섹션을 추가하고 `거래번호`, `결재번호`, `결재 메모`, `결재 요청자` 필드를 배치한다.
- 거래번호는 수동 입력 + “번호 자동 생성” 버튼을 제공하고, 후자는 시퀀스 API(백엔드 지원 필요)와 연동한다.
- 결재 요청자는 기존 작성자 자동완성 컴포넌트를 재사용해 `requested_by_id`로 매핑한다.
2. **컨트롤러/검증 로직**
- `StockTransactionCreateInput``StockTransactionApprovalInput`을 추가해 `approval_no`, `approval_status_id`, `requested_by_id`, `note`를 묶어서 전송한다.
- 검증 단계에서 거래번호/결재번호 누락 여부를 체크하고, 승인 상태는 Lookup(`fetchApprovalStatuses`)에서 “대기” ID를 로딩해 기본값으로 사용한다.
3. **사용자 경험 보완**
- 결재 템플릿 선택 시 템플릿에서 결재번호 규칙·승인자를 추천하고, 수동 변경 시 경고 메시지를 노출한다.
- 저장 직전 `/approvals` 간단 조회 또는 별도 중복 체크 API로 결재번호·거래번호 중복을 사전 확인한다.
4. **후속 일정**
- 1차 목표는 필수 필드 수집과 API 호출 연계이며, 템플릿 적용/번호 시퀀스 API는 백엔드 명세 확정 이후 2차 작업으로 분리한다.
- 컨트롤러 단위 테스트·위젯 테스트에 승인 정보 입력 시나리오를 추가하고 QA 문서에도 신규 체크리스트를 반영한다.

View File

@@ -0,0 +1,34 @@
# 입출고·결재 번호 자동 부여 대응 가이드
프런트엔드 변경 시 유의해야 할 내용을 정리했습니다. 모든 일정은 백엔드 배포(문서 버전 v4) 이후 적용을 권장합니다.
## 주요 변경 요약
- 서버가 `transaction_no``approval_no`를 자동 생성합니다. 포맷은 `TRX-YYYYMMDDNNNN`, `APP-YYYYMMDDNNNN`이며 일자별 4자리 시퀀스를 사용합니다.
- 생성 요청 본문에서 두 필드를 제거해야 합니다. 백엔드가 값을 무시하므로 전송 시 불필요한 필드 오류가 날 수 있습니다.
- 생성 응답(`POST /stock-transactions`, `POST /approvals`)에 포함된 번호를 UI에 표기하거나 후속 액션에 사용해야 합니다.
## 작업 항목
1. **트랜잭션 생성 화면**
- 번호 입력 필드 제거 및 레이아웃 정리.
- 생성 직후 응답(`data.transaction_no`)을 받아 상세 화면/알림에 표기.
2. **결재 생성/상신 화면**
- `approval.approval_no` 필드 제거.
- 응답(`data.approval.approval_no`)을 활용해 결재 상세 링크/알림 업데이트.
3. **API 클라이언트 수정**
- 공유 DTO/타입스크립트 인터페이스에서 `transaction_no`, `approval_no`를 삭제.
- E2E/단위 테스트에서 하드코딩된 번호 값 삭제 및 응답 값 기반 검증으로 변경.
4. **리스트/검색 기능**
- 표시 포맷이 바뀌었는지 확인하고 필요 시 날짜·시퀀스 분리 표시 적용.
## 검증 체크리스트
- [ ] 트랜잭션 생성 요청 payload에 `transaction_no`가 포함되지 않는다.
- [ ] 결재 생성 요청 payload에 `approval_no`가 포함되지 않는다.
- [ ] 생성 이후 상세 페이지/알림에 새 번호가 반영된다.
- [ ] 기존 북마크/딥링크가 새 번호 포맷(`-` 포함 13자리)과 호환되는지 확인한다.
- [ ] QA 환경에서 동일 일자 다건 생성 시 번호가 0001, 0002… 순으로 증가하는지 확인한다.
## 참고 문서
- `stock_approval_system_api_v4.md` 4.1, 5.1 섹션(요청 본문 변경 사항)
- `stock_approval_system_spec_v4.md` 3.14, 3.19 테이블(번호 관리 규칙)
문의 사항은 #inventory-backend 채널로 공유 바랍니다.

View File

@@ -0,0 +1,88 @@
# 프런트엔드/백엔드 정합성 점검 리포트 (2025-10-23)
## 개요
- 기준 문서: 갱신된 `backend_change_requests.md`(B8-2 완료)와 `stock_approval_system_api_v4.md`(Approval Flow v2 전면 개편 반영)를 토대로 Flutter 프런트(`superport_v2`)와 Rust 백엔드(`superport_api_v2`)의 계약을 재검증했다.
- 로그인 → 대시보드 → 재고/결재 → 보고서/권한까지 전 흐름을 재검증하고, 기본 목록 비노출 정책(B5-5) 적용 여부를 코드·테스트로 확인했다.
## 주요 정합성 결과
| 구분 | 내용 | 상태 | 후속 조치 |
| --- | --- | --- | --- |
| 1 | Approval Flow v2 API 문서 (`expected_updated_at`, drafts, 이력·지표) | ✅ 해결 | `tool/sync_stock_docs.sh`로 프런트 문서/DTO 재생성 |
| 2 | 결재/재고 응답 스키마(상태·메타데이터·중첩 객체) | ✅ 해결 | 프런트 DTO/위젯에 새 필드 반영 및 테스트 추가 |
| 3 | 전표 기본 목록 비노출 정책(B5-5) | ✅ 해결 | 기본 목록=승인·완료, 대기 영역은 `status=draft,submitted` 또는 `include_pending`으로 조회 |
| 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`) |
아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다.
## 로그인 & 세션
- `backend/src/api/v1/auth.rs``data.access_token`, `data.refresh_token`, `data.expires_at`, `data.user`, `data.permissions`를 반환하며 오류 메시지는 문서 규격과 일치한다.
- 프런트 `AuthSessionDto` 매핑은 유지되지만, 알림 메시지가 영어 키(`invalid credentials`, `token expired` 등)에 맞춰 노출되는지 QA에서 다시 확인한다.
- 세션 만료 재로그인 UX는 기존대로 유지하되, 만료/재사용 토큰 구분 안내를 사용자에게 명확히 보여주는지 체크한다.
## 대시보드
- `GET /api/v1/dashboard/summary``kpis[]`, `recent_transactions[]`, `pending_approvals[]`를 제공하고 `delta`·`trend_label`이 문서와 코드에 맞춰 채워진다(`backend/src/api/v1/dashboard.rs`).
- KPI 카드 구성이 입고/출고/대여/결재 대기로 확정되면서 백엔드는 `kpi.key=rental` 값을 추가했고 프런트는 이를 상단 카드 프리셋에 반영했다.
- 프런트 KPI 카드에서 `delta`가 소수(0.125) → 백분율(12.5%)로 변환되는 로직과 `step_summary` 포맷(`"2단계 · 승인자"`)이 정상 노출되는지 UI 스냅샷 테스트를 업데이트한다.
- 백엔드가 `pending_approvals[].roles[]`(assigned/requested/completed)와 `approval_statuses.is_terminal=false` 필터를 사용하도록 리팩터링함에 따라 프런트는 카드 설명·뱃지를 업데이트해 `배정됨/상신자/기결재자` 역할을 노출하고 최종 상태 건 제외 정책을 명시했다.
## 재고·대여 트랜잭션
- 응답 본문이 거래/창고/라인/고객/결재 요약을 중첩 객체로 반환하고 `quantity`·`unit_price`의 null도 유지한다(`backend/src/api/v1/stock_transactions.rs`).
- 결재 전이 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`)가 발행된다.
- 열람 권한 정책이 상신자·기결재자에게만 상세 접근을 허용하므로, 프런트 `MyApprovals`·`ApprovalDetailPage`에서 403 시나리오 UX를 재확인한다.
## 결재 플로우 문서 & 모니터링
- `stock_approval_system_api_v4.md``/approval`, `/approval-drafts`, 회수/재상신, 이력, 권한 정책, 예상 업데이트 시각(`expected_updated_at`)을 모두 포함한다.
- Prometheus 지표/Slack 알림 정책은 `doc/approval_flow_alert_policy.md`에 정리돼 있으니 Ops와 함께 대시보드 구성을 착수한다.
- 프런트 문서 동기화(`tool/sync_stock_docs.sh`)와 DTO 리프레시 후 회귀 테스트(`flutter analyze`, `flutter test`) 일정을 맞춘다.
## 보고서 (PDF/XLSX)
- `GET /api/v1/reports/transactions|approvals/export`가 스트리밍과 메타데이터 모드를 모두 지원하고, `download_url`, `filename`, `mime_type`, `expires_at`을 반환한다.
- 프런트 다운로드 UI는 새 메타 필드를 표시하고, 감사 로그(Download 요청 시 이벤트 남는지) 결과를 스테이징에서 확인한 뒤 UX 안내문구를 보강한다.
- 대용량 PDF/XLSX에 대한 합동 테스트를 QA 시나리오에 추가한다.
## 권한/문서
- `GET /api/v1/group-menu-permissions``include_deleted`를 허용하고 `route_path`, `path`, `is_deleted`를 응답에 포함한다. 프런트 DTO와 편집 화면이 삭제 항목을 구분 표시하는지 확인한다.
- 백엔드/프런트 문서가 모두 최신 스펙을 참조하도록, `backend_change_requests.md`와 프런트 대응 문서를 동시에 업데이트한 후 공유한다.
## 사이드바 & 메뉴 권한
- 백엔드 `menus` 테이블이 사이드바 18개 항목과 1:1 매핑되도록 `menu_code / route_path / display_order`가 고정됐다. 스펙·API 문서에 표가 추가됐으므로 프런트 문서도 동일 표를 참조한다.
- `group_menu_permissions` 응답은 삭제 메뉴를 숨기므로, 드롭다운 리스트 역시 서버 응답 기준으로 렌더링해야 한다. 기존 로컬 enum은 제거한다.
- 로그인 세션 `permissions[].resource``route_path` 값을 사용하므로, 사이드바 렌더링은 `menu_code ↔ route_path` 매퍼만 유지하면 된다.
### 프런트엔드 작업
1. **사이드바 소스 통합**`GET /menus?active=true&include=parent` 응답을 불러와 `SidebarSection`을 구성하고, `display_order` 순서를 그대로 따른다.
2. **그룹 권한 드롭다운 개편** — 권한 편집 화면에서 동일 메뉴 데이터를 트리형 선택 UI로 노출하고, `include_deleted` 케이스에 회색/비활성 스타일을 적용한다.
3. **권한 기반 렌더링 보강** — 로그인 세션 `permissions` + 메뉴 데이터를 조합해 그룹별 사이드바 노출을 제어하고, 관리자/결재 담당자/일반 사용자 시나리오에 대한 스냅샷 테스트를 추가한다.
4. **문서/DTO 동기화**`tool/sync_stock_docs.sh` 실행 후 `doc/frontend_api_alignment_plan.md` 등에 새 메뉴 표와 라우트 매핑 규칙을 기록한다.
5. **회귀 테스트** — 그룹 메뉴 권한 수정 → 재로그인 플로우를 E2E 테스트에 추가해 라우트/사이드바 반영 여부를 검증한다.
## 일정 & 의존성
| 구분 | 작업 내용 | 담당 | 상태 | 목표일(제안) | 비고 |
| --- | --- | --- | --- | --- | --- |
| 결재 | 전표 기본 목록 비노출 정책 구현(B5-5) | 백엔드 | ✅ 완료 | 2025-10-24 | 기본 필터/테스트 반영 완료, 대기 목록은 상태 필터로 조회 |
| 결재 | 목록 비노출 UI/필터 적용 및 QA 시나리오 | 프런트 | 🗓️ 예정 | 2025-10-25 | 백엔드 배포 직후 테스트 연동 |
| 문서 | `tool/sync_stock_docs.sh` 실행 및 DTO 재생성 | 프런트 | 🗓️ 예정 | 2025-10-23 | 문서/코드 차이 검출 후 PR 공유 |
| QA | Approval Flow 통합 테스트 재실행 (`cargo test`) | 백엔드 | 🗓️ 예정 | 2025-10-24 | 보고서/결재 전이 회귀 포함 |
| QA | `flutter analyze`, `flutter test --coverage` | 프런트 | 🗓️ 예정 | 2025-10-26 | 새 DTO·UI 반영 후 보고 |
| Ops | Prometheus 지표 대시보드 구성 | 백엔드/Ops | 🗓️ 예정 | 2025-10-28 | `approval_flow_action_*` 메트릭 시각화 |
## 테스트 & 다음 단계
- 백엔드는 기본 필터 동작 검증 후 `cargo fmt`, `cargo check`, `cargo test` 결과를 공유한다.
- 프런트는 문서/DTO 동기화 이후 Approval Flow UI·보고서 다운로드·권한 편집 시나리오를 회귀 테스트로 보강한다.
- 양측 모두 QA 완료 시 `notify.py` 워크플로로 완료 알림을 발송하고, 남은 일정(B8-4, B8-5, B9-x)을 공유 캘린더에 업데이트한다.

View File

@@ -0,0 +1,69 @@
# 프런트엔드 메뉴 권한 정합성 작업 리스트
백엔드가 menus/group_menu_permissions 구조를 정리하는 동안 프런트엔드에서 병행해야 하는 상세 작업을 아래와 같이 정리한다. 모든 작업은 `AGENTS.md` 가이드(주석 한국어, Clean Architecture, 테스트 필수)에 따라 진행하며, 완료 후 `flutter analyze`, `flutter test`를 실행한다.
## 1. 권한 리소스/폴백 정비
1. **`PermissionResources` 별칭 테이블 보강**
- 파일: `lib/core/permissions/permission_resources.dart`
- 액션: `/inventory/receipts|issues|rentals`, `/inventory/vendors|products|warehouses|customers`, `/settings/*` 등 백엔드 `menus.route_path` 전부를 `_aliases`에 1:1 매핑.
- 기대효과: 서버가 내려주는 menu_code/route_path와 사이드바 권한 체크가 정확히 연결.
2. **`PermissionManager` 기본 정책 수정**
- 파일: `lib/core/permissions/permission_manager.dart`, `.env.development`, `.env.example`
- 액션:
- 서버/오버라이드 권한이 없으면 `Environment.hasPermission` 결과를 사용하기 전에 기본 거부하도록 방어 로직 추가.
- 개발용 `.env`에 필요한 최소 권한만 `PERMISSION__/path=view` 형태로 명시해 무제한 허용을 차단.
- 기대효과: 권한 정보 미수신 시 UI가 안전하게 차단되고, 테스트/QA에서 화이트리스트가 그대로 재현.
## 2. 메뉴 데이터 소스 일원화
3. **메뉴 라우트/사이드바 리팩터링**
- 파일: `lib/widgets/app_shell.dart`, `lib/core/routing/app_router.dart`, `lib/core/navigation/menu_route_definitions.dart`, 신규 헬퍼 모듈.
- 액션:
- `appSections` 상수를 폐지하고, `GET /menus` 결과(menu_code, parent_code, display_order, route_path)를 캐시해 사이드바를 구성.
- `menu_code → 위젯 builder` 매핑 테이블을 별도 파일로 만들고 GoRouter 정의에서 사용.
- 사이드바, 권한 드롭다운, 라우터가 모두 동일 menu_code/route_path를 참조하도록 구조화.
- 기대효과: menus 테이블 변경이 자동으로 UI에 반영되고 경로 불일치 문제 제거.
4. **MenuCatalog(캐시) 도입 및 의존성 주입**
- 파일: `lib/main.dart`, `lib/core/navigation/menu_catalog.dart`(신규), `lib/features/masters/menu/*`
- 액션:
- 앱 시작 시 메뉴 목록을 한 번만 불러 캐시하는 Catalog를 만들고 `PermissionScope` 옆에서 주입.
- 메뉴 관리 화면/권한 드롭다운이 Catalog 데이터를 재사용하도록 controller 로직 정리.
## 3. 그룹 권한 UI 연동
5. **메뉴 선택 로직을 menu_code 기반으로 변경**
- 파일: `lib/features/masters/group_permission/presentation/...`
- 액션:
- 컨트롤러와 다이얼로그가 `menu_id` 대신 `menu_code`를 사용해 CRUD 요청을 보냄.
- 메뉴 드롭다운 옵션을 Catalog(or `/menus`)에서 가져와 라벨/정렬 통일.
6. **권한 동기화 시 menu_code 매칭 검증**
- 파일: `lib/features/masters/group_permission/application/permission_synchronizer.dart`
- 액션: `MenuRouteDefinition` 또는 Catalog를 참조해 `PermissionManager`에 적용되는 리소스 키가 서버 route_path와 일치하는지 확인하는 테스트 추가.
## 4. 테스트 & CI
7. **사이드바 권한 테스트 보강**
- 파일: `test/widgets/app_shell_test.dart`, `test/navigation/navigation_flow_test.dart`
- 액션:
- 서버에서 받은 `menu_code`/`route_path`에 따라 노출되는 메뉴가 달라지는 위젯 테스트 작성.
- 개발 env 권한 없이도 테스트가 실패하지 않도록 PermissionManager 오버라이드 유틸 추가.
8. **CI 스크립트 추가**
- 파일: `tool/check_menu_manifest.dart`(신규), `ci/scripts/*`
- 액션: menus 테이블 표(`doc/stock_approval_system_spec_v4.md`), 라우트 정의, 앱 메뉴 캐시 간 코드/경로 일관성을 검사하는 Dart 스크립트를 추가하고 CI 단계에서 실행.
## 5. 문서/QA
9. **문서 업데이트**
- 파일: `doc/frontend_backend_alignment_report.md`, 신규 QA 체크리스트.
- 액션: “사이드바=menus 테이블 1:1” 규칙, 권한 폴백 정책, 테스트 플로우를 문서화.
10. **QA 시나리오**
- 항목: 화이트리스트 그룹별 계정으로 로그인 → 사이드바 캡처 → 권한 드롭다운 메뉴 제한 확인 → 메뉴 관리에서 route_path 일치 여부 확인.
- 산출물: `doc/qa/menu_permission_alignment_checklist.md` 등으로 공유.
각 작업은 백로그 티켓으로 분할해 순차 진행하며, 메뉴 API 변경 시점과 맞춰 릴리즈 플래그로 토글할 수 있도록 구성한다.

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,28 @@
# Approval Flow UAT 체크리스트
## 1. 환경 준비
- [ ] 스테이징 API `FEATURE_APPROVALS_ENABLED=true`, `FEATURE_STOCK_TRANSITIONS_ENABLED=true` 상태를 확인한다.
- [ ] 통합 테스트 토큰/식별자(`STAGING_*`)를 최신 값으로 교체하고 `flutter test integration_test/stock_transaction_state_flow_test.dart`를 드라이런한다.
- [ ] 결재 템플릿/승인자 마스터 데이터가 스테이징과 동기화되어 있는지 확인한다.
## 2. 핵심 플로우 검증
1. **입고 상신/승인**
- [ ] 입고 전표를 작성해 상신 → 승인 → 완료까지 진행하고 상태 변경/결재 이력을 확인한다.
2. **반려/재상신**
- [ ] 동일 전표를 반려 처리 후 요청자가 수정·재상신하여 승인까지 재진행한다.
3. **회수(Recall)**
- [ ] 승인 대기 상태에서 작성자가 회수한 뒤 수정 후 재상신 시 정상 동작하는지 확인한다.
4. **취소(Cancel)**
- [ ] 상신 직후 취소 시 상태가 초안(또는 취소)으로 복귀하고 결재 단계가 비워지는지 확인한다.
5. **템플릿 CRUD**
- [ ] 결재 템플릿 생성/수정/삭제/복구 후 전표에 적용되며 단계 구성·승인자 배정이 유지되는지 확인한다.
6. **대시보드/승인 목록 반영**
- [ ] 상신/승인/반려 이벤트 후 대시보드 및 결재 목록에 실시간으로 반영되는지 확인한다.
## 3. 예외/권한 시나리오
- [ ] 승인 대상이 아닌 사용자로 결재 목록/상세 조회 시 `APPROVAL_ACCESS_DENIED` 토스트와 대시보드 리다이렉트가 동작한다.
- [ ] 삭제된 결재 템플릿/전표를 복구했을 때 `include_deleted=true` 목록에서 재노출되는지 확인한다.
## 4. 보고 및 마무리
- [ ] 각 시나리오별 기대 결과/실측 결과를 QA 스프레드시트에 기록한다.
- [ ] 확인 완료 후 `doc/approval_flow_frontend_task_plan.md`의 F8-2 항목에 일자와 상태를 업데이트한다.

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 기준으로 활용한다.

View File

@@ -0,0 +1,50 @@
# 스테이징 재고 플로우 검증 시나리오
## 전제 조건
- 스테이징 API 서버가 기동되어 있고, 테스트용 토큰이 발급되어 있어야 합니다.
- 아래 환경 변수에 대응하는 실 ID가 존재해야 합니다.
- `STAGING_API_BASE_URL`
- `STAGING_API_TOKEN`
- `STAGING_TRANSACTION_TYPE_ID`
- `STAGING_TRANSACTION_STATUS_ID`
- `STAGING_WAREHOUSE_ID`
- `STAGING_EMPLOYEE_ID`
- `STAGING_PRODUCT_ID`
- `STAGING_CUSTOMER_ID`
- 임시 데이터 생성/삭제가 허용된 스테이징 계정을 사용하세요.
## 테스트 데이터 구성
- 스테이징 콘솔 또는 API를 사용해 전용 QA 데이터를 사전에 확보합니다.
1. `/api/v1/warehouses`, `/api/v1/employees`, `/api/v1/products`, `/api/v1/customers` 목록을 조회해 테스트에 사용할 고정 ID를 선택하거나 새 레코드를 생성합니다.
2. `/api/v1/transaction-types``/api/v1/transaction-statuses`에서 submit/approve/complete 플로우를 지원하는 타입·상태 조합을 확인합니다.
3. `/api/v1/stock-transactions`에 임시 트랜잭션을 생성해 라인/고객/승인 데이터가 정상 저장되는지 점검한 뒤, 성공한 요청 본문을 재사용 가능한 fixture로 저장합니다.
4. 테스트 종료 후 `/api/v1/stock-transactions/{id}` DELETE 요청으로 데이터를 정리해 다른 시나리오에 영향을 주지 않도록 합니다.
- 각 ID는 `.env.staging` 또는 CI 시크릿에 명시해 실행 시점에 자동으로 주입합니다.
## 자동화 테스트 실행 방법
1. 루트에서 다음 명령으로 환경 변수를 전달합니다.
```bash
flutter test integration_test/stock_transaction_state_flow_test.dart \
--dart-define=STAGING_RUN_TRANSACTION_FLOW=true \
--dart-define=STAGING_API_BASE_URL=https://staging.superport.example \
--dart-define=STAGING_API_TOKEN=<token> \
--dart-define=STAGING_TRANSACTION_TYPE_ID=1 \
--dart-define=STAGING_TRANSACTION_STATUS_ID=1 \
--dart-define=STAGING_WAREHOUSE_ID=3 \
--dart-define=STAGING_EMPLOYEE_ID=5 \
--dart-define=STAGING_PRODUCT_ID=11 \
--dart-define=STAGING_CUSTOMER_ID=7
```
2. 테스트는 다음 흐름을 검증합니다.
- 재고 트랜잭션 생성 → 상신(submit) → 취소(cancel) → 삭제(delete)
- 실패 시 HTTP 응답을 출력하므로, 로그를 토대로 API 상태를 점검합니다.
## 수동 검증 체크리스트
1. **생성**: 입고/출고/대여 중 하나를 생성하고, 작성자·창고·라인·고객 정보가 정확히 저장되는지 확인합니다.
2. **상태 전이**: 작성중 → 상신 → 승인/완료 → 취소 순으로 버튼이 활성화되는지와 API 응답 코드를 확인합니다.
3. **보고서 다운로드**: 동일한 조건으로 보고서를 다운로드하여 URL/파일이 정상 동작하는지 브라우저에서 재확인합니다.
4. **정리**: 테스트용 데이터는 취소 후 삭제하여 스테이징 목록을 정리합니다.
## 장애 대응 메모
- 4xx 발생 시 API 로그와 `feature_flag` 값을 우선 확인합니다.
- 5xx 또는 타임아웃은 백엔드 담당자에게 API 경로와 요청 페이로드를 공유합니다.

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
**버전:** 2025-09-18 16:22:30Z (UTC)
**요약:** 벤더 ↔ 창고 ↔ 고객사 간 물품 이동(입고/출고)을 관리하는 최소구성 시스템.
- 트랜잭션당 1개의 결재(1:1), **승인자 순서 기반의 순차 결재** 지원.
- 상신자는 결재 초안을 임시저장할 수 있으며, 브라우저를 닫아도 `결재 관리` 목록에서 다시 불러와 편집/상신할 수 있다.
- **다음 승인자로 넘어가면 안 되는 상태**를 `approval_statuses.is_blocking_next`로 제어.
- 모든 테이블(타입/코드 테이블 포함)에 **공통 컬럼** 적용: `is_active`, `is_deleted`, `created_at`, `updated_at`.
- **벤더는 트랜잭션 헤더에 연결하지 않음**(벤더는 제품을 통해서만 추적).
@@ -17,10 +18,19 @@
- 제품 1개는 반드시 1개의 벤더에 소속 (`products.vendor_id` 필수).
- **트랜잭션 1건당 결재 1건**(1:1, 소프트삭제 제외).
- 결재는 **승인자 순서(`approval_steps.step_order`)대로**만 진행.
- 대시보드 대기 결재 요약은 상세 조회 연계를 위해 각 항목의 `approval_id`(= `approvals.id`)를 포함한다.
- 결재 목록 응답은 각 항목의 `id`(= `approvals.id`)를 항상 노출하여 상세 조회 트리거로 사용한다.
- 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가.
- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음).
- 모든 직원은 **그룹**에 속하며(`employees.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
- 입고/출고/대여 트랜잭션은 최종 결재자가 승인 완료하기 전까지 기본 목록/완료 카드에 노출되지 않으며, 대기/임시 영역에서만 조회된다.
- 결재 문서는 상신자와 이미 결재를 완료한 승인자만 열람할 수 있고, 향후 단계 승인자는 자신의 순서가 도달하기 전까지 목록/상세 접근이 차단된다. (예: 상신→중간 승인 완료, 최종 승인 대기 시 상신자·중간 승인자만 열람 가능)
- 모든 로그인 사용자는 **users** 테이블 행과 매핑되며(`users.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
- `users.employee_id`는 영문/숫자 4~32자 정규식(`^[A-Za-z0-9]{4,32}$`)을 따라야 하며, 대소문자 구분 없이 유니크하다.
- 신규 사용자는 생성 직후 `force_password_change=true`로 저장하고, 최초 로그인에서 비밀번호를 변경하지 않으면 다른 기능을 사용할 수 없다.
- 비밀번호는 길이 8~24자에 대문자·소문자·숫자·일반 특수문자를 각각 1자 이상 포함해야 하며, 성공적으로 변경되면 기존 세션을 즉시 만료한다.
- 최고 관리자 계정 `terabits`는 삭제하지 않고 최상위 권한을 유지하며, 관리자 비밀번호 재설정은 임시 비밀번호 이메일 발송과 `force_password_change=true` 설정을 동시에 수행한다.
- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false).
- 고객사는 담당자 이름(`contact_name`)을 별도 관리하며 고객 응답에 항상 포함.
- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능.
- 모든 삭제는 **소프트 삭제**(`is_deleted=true`)이며, 삭제 시 `is_active=false`로 내림.
@@ -52,13 +62,13 @@ approval_actions ||--o{ approval_histories : acted_as
approval_templates ||--o{ approval_template_steps : has_sequence
employees ||--o{ approvals : requested_by
employees ||--o{ approval_steps : assigned_to
employees ||--o{ approval_histories : actor
employees ||--o{ stock_transactions : created_by
employees ||--o{ approval_templates : authored
employees ||--o{ approval_template_steps : template_approver
groups ||--o{ employees : members
users ||--o{ approvals : requested_by
users ||--o{ approval_steps : assigned_to
users ||--o{ approval_histories : actor
users ||--o{ stock_transactions : created_by
users ||--o{ approval_templates : authored
users ||--o{ approval_template_steps : template_approver
groups ||--o{ users : members
groups ||--o{ group_menu_permissions : controls
menus ||--o{ group_menu_permissions : target
zipcodes ||--o{ warehouses : located
@@ -100,6 +110,8 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> `zipcodes` 테이블이 우편번호 마스터를 보유하며, 참조 측 테이블은 `zipcode_id` → `zipcodes.id` FK만 저장한다. 응답 시에는 FK를 통해 조회한 우편번호 요약 정보를 포함한다.
---
### 3.2 `warehouses` (창고)
@@ -112,7 +124,7 @@ zipcodes ||--o{ customers : addressed
| id | 창고ID | bigint | - | identity | Y | Y | Y | - |
| warehouse_code | 창고코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| warehouse_name | 창고명 | varchar | 100 | - | Y | | | |
| zipcode | 우편번호 | varchar | 5 | - | N | | | zipcodes.zipcode |
| zipcode_id | 우편번호ID | bigint | - | - | N | | | zipcodes.id |
| address_detail | 상세주소 | varchar | 200 | - | N | | | - |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
@@ -120,6 +132,8 @@ 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` 없이도 반환된다.
---
### 3.3 `customers` (고객사)
@@ -132,11 +146,12 @@ zipcodes ||--o{ customers : addressed
| id | 고객사ID | bigint | - | identity | Y | Y | Y | - |
| customer_code | 고객사코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| customer_name | 고객사명 | varchar | 100 | - | Y | | | |
| contact_name | 담당자명 | varchar | 100 | - | N | | | - |
| is_partner | 파트너여부 | boolean | - | false | Y | | | - |
| is_general | 일반여부 | boolean | - | true | Y | | | - |
| email | 이메일 | varchar | 100 | - | N | | | - |
| mobile_no | 모바일번호 | varchar | 20 | - | N | | | - |
| zipcode | 우편번호 | varchar | 5 | - | N | | | zipcodes.zipcode |
| zipcode_id | 우편번호ID | bigint | - | - | N | | | zipcodes.id |
| address_detail | 상세주소 | varchar | 200 | - | N | | | - |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
@@ -144,27 +159,39 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> `contact_name`은 고객사의 대표 연락 창구로 사용하는 담당자 실명. 필수는 아니며 미입력 시 `null` 저장.
> 고객/창고 모두 `zipcode_id`를 통해 `zipcodes.id`와 연결하며, API 응답은 FK가 가리키는 `zipcodes` 행에서 필요한 우편번호 메타 정보를 추출해 제공한다.
---
### 3.4 `employees` (사)
### 3.4 `users` (사용자)
| 영문테이블명 | 한글테이블명 |
|---|---|
| employees | 사 |
| users | 사용자 |
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 사ID | bigint | - | identity | Y | Y | Y | - |
| employee_no | 사번 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| employee_name | 성명 | varchar | 100 | - | Y | | | |
| email | 이메일 | varchar | 100 | - | N | Y | | |
| mobile_no | 모바일번호 | varchar | 20 | - | N | | | |
| id | 사용자ID | bigint | - | identity | Y | Y | Y | - |
| employee_id | 사번 | varchar | 32 | - | Y | (부분유니크: is_deleted=false) | N | - |
| name | 이름 | varchar | 100 | - | Y | | | |
| email | 이메일 | varchar | 150 | - | Y | Y | | |
| phone | 연락처 | varchar | 30 | - | N | | | |
| group_id | 그룹ID | bigint | - | - | Y | | | groups.id |
| password_hash | 비밀번호해시 | varchar | 255 | - | Y | | | - |
| password_updated_at | 비밀번호변경일시 | timestamp | - | now() | Y | | | - |
| force_password_change | 비밀번호강제변경 | boolean | - | false | Y | | | |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> `employee_id`는 영문/숫자 4~32자(`^[A-Za-z0-9]{4,32}$`)로 제한하며 대소문자를 구분하지 않는 유니크 인덱스를 적용한다.
> 이메일은 소문자로 저장하고 유니크해야 하며, 연락처는 국제 전화번호 포맷을 허용한다.
> 신규 생성 시 `force_password_change=true`로 저장하고, 최초 로그인 후 비밀번호 변경 시 `force_password_change=false` 및 `password_updated_at`을 현재 시각으로 갱신한다.
> 최고 관리자 계정 `terabits`는 삭제하지 않고 유지하며, 비밀번호 재설정 시 이메일 발송과 즉시 비밀번호 강제 변경 상태로 전환한다.
---
### 3.5 `zipcodes` (우편번호)
@@ -174,7 +201,8 @@ zipcodes ||--o{ customers : addressed
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| zipcode | 우편번호 | varchar | 5 | - | Y | Y | Y | - |
| id | 우편번호ID | bigint | - | identity | Y | Y | Y | - |
| zipcode | 우편번호 | varchar | 5 | - | Y | | N | - |
| sido | 시도 | varchar | 50 | - | Y | | | - |
| sido_eng | 시도영문 | varchar | 100 | - | N | | | - |
| sigungu | 시군구 | varchar | 100 | - | Y | | | - |
@@ -207,7 +235,7 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> 도로명 주소 데이터와 매핑되는 5자리 우편번호 기준. `zipcode`가 PK이며 외부 데이터 동기화를 위한 `zipcode_serial_no`를 포함.
> 도로명 주소 데이터와 매핑되는 5자리 우편번호 기준. `id`는 내부용 서러겟 PK이며, `zipcode`는 동일 코드가 여러 주소 행에 등장할 수 있다.
---
@@ -231,6 +259,35 @@ zipcodes ||--o{ customers : addressed
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> 메뉴는 계층 구조를 지원하며 `parent_menu_id`가 NULL이면 1차 메뉴로 간주.
> 프런트엔드 사이드바와 1:1로 매칭되는 공식 메뉴 목록은 다음과 같다.
>
> | menu_code | parent_code | menu_name | route_path | display_order | 설명 |
> | --- | --- | --- | --- | --- | --- |
> | `dashboard` | - | 대시보드 | `/dashboard` | 10 | KPI/요약 카드 |
> | `inventory` | - | 재고/입출고 | `/inventory` | 20 | 재고 섹션 루트 |
> | `inventory.receipts` | `inventory` | 입고 | `/inventory/receipts` | 10 | 입고 전표 |
> | `inventory.issues` | `inventory` | 출고 | `/inventory/issues` | 20 | 출고 전표 |
> | `inventory.rentals` | `inventory` | 대여 | `/inventory/rentals` | 30 | 대여/반납 전표 |
> | `inventory.manufacturers` | `inventory` | 제조사 관리 | `/inventory/manufacturers` | 40 | 벤더/제조사 |
> | `inventory.models` | `inventory` | 장비 모델 관리 | `/inventory/models` | 50 | 제품/장비 모델 |
> | `inventory.warehouses` | `inventory` | 입고지 관리 | `/inventory/warehouses` | 60 | 창고/입고지 |
> | `inventory.customers` | `inventory` | 회사 관리 | `/inventory/customers` | 70 | 고객/거래처 |
> | `settings` | - | 설정 | `/settings` | 30 | 사용자/권한 섹션 루트 |
> | `settings.users` | `settings` | 사용자 관리 | `/settings/users` | 10 | 사용자 CRUD |
> | `settings.groups` | `settings` | 그룹 관리 | `/settings/groups` | 20 | 그룹 CRUD |
> | `settings.menus` | `settings` | 메뉴 관리 | `/settings/menus` | 30 | 메뉴 마스터 관리 |
> | `settings.group_permissions` | `settings` | 그룹 메뉴 권한 | `/settings/group-permissions` | 40 | 그룹별 메뉴 권한 |
> | `approvals` | - | 결재 | `/approvals` | 40 | 결재 섹션 루트 |
> | `approvals.requests` | `approvals` | 결재 관리 | `/approvals/requests` | 10 | 결재 요청 목록 |
> | `approvals.steps` | `approvals` | 결재 단계 | `/approvals/steps` | 20 | 결재 단계 관리 |
> | `approvals.history` | `approvals` | 결재 이력 | `/approvals/history` | 30 | 결재 이력 |
> | `approvals.templates` | `approvals` | 결재 템플릿 | `/approvals/templates` | 40 | 템플릿 관리 |
> | `utilities` | - | 유틸리티 | `/utilities` | 50 | 보조 기능 루트 |
> | `utilities.zipcodes` | `utilities` | 우편번호 검색 | `/utilities/zipcodes` | 10 | 주소/우편번호 검색 |
> | `reports` | - | 보고서 | `/reports` | 60 | 보고서 루트 |
> | `reports.overview` | `reports` | 보고서 | `/reports` | 10 | 보고서 요약/다운로드 |
>
> 위 목록 외 메뉴 코드는 모두 `is_deleted=true`로 유지해 사이드바/드롭다운에 노출되지 않는다.
---
@@ -251,7 +308,7 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사원은 `employees.group_id`로 그룹에 연결.
> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사용자는 `users.group_id`로 그룹에 연결된다.
---
@@ -276,6 +333,9 @@ zipcodes ||--o{ customers : addressed
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> 각 메뉴에 대한 CRUD 권한을 그룹 단위로 정의하며, 권한 미설정 시 기본적으로 조회만 허용.
> 기본 그룹 권한 예시:
> - `전사 관리자`: `menus` 테이블에 남아 있는 모든 메뉴에 대해 `can_create=can_read=can_update=can_delete=true`.
> - `결재 담당자`(`approval.manage` 스코프 보유): `dashboard`, `inventory.receipts|issues|rentals`, `approvals.requests`, `approvals.steps`, `approvals.history`, `approvals.templates`만 노출되며, `approvals*` 계열 메뉴에 한해 `can_create=true`, `can_update=true`가 부여된다. 기타 메뉴 권한은 `is_deleted=true`로 비활성화해 UI/엔드포인트 모두 접근이 차단된다.
---
@@ -372,7 +432,7 @@ zipcodes ||--o{ customers : addressed
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id |
| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id |
| transaction_date | 처리일자 | date | - | current_date | Y | | | - |
| created_by_id | 작성자ID | bigint | - | - | N | | | employees.id |
| created_by_id | 작성자ID | bigint | - | - | N | | | users.id |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
@@ -380,6 +440,10 @@ zipcodes ||--o{ customers : addressed
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생.
> 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
> 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신)
> 작성자(`created_by_id`)는 로그인 세션의 사용자 ID를 사용하며, API 요청 본문에 전달된 다른 값은 무시하거나 검증 오류로 처리한다.
> 결재 최종 승인 완료 전에는 `transaction_statuses`가 `초안` 또는 `상신` 단계로 유지되며, 기본 입·출·대여 목록과 완료 카드에서는 제외한다. 대기/임시 전용 목록(필터 `status=draft|submitted`)에서만 확인 가능하다.
---
@@ -481,7 +545,7 @@ zipcodes ||--o{ customers : addressed
| approval_no | 결재번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| approval_status_id | 전체결재상태ID | bigint | - | - | Y | | | approval_statuses.id |
| current_step_id | 현재단계ID | bigint | - | - | N | | | approval_steps.id |
| requested_by_id | 상신자ID | bigint | - | - | Y | | | employees.id |
| requested_by_id | 상신자ID | bigint | - | - | Y | | | users.id |
| requested_at | 상신일시 | timestamp | - | now() | Y | | | - |
| decided_at | 최종결정일시 | timestamp | - | - | N | | | - |
| note | 비고 | text | - | - | N | | | - |
@@ -490,6 +554,10 @@ zipcodes ||--o{ customers : addressed
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
> 번호 발급: 서버가 `APP-YYYYMMDDNNNN` 형식으로 `approval_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
> 상신자는 결재 초안을 저장하고 추후 재개할 수 있으며, 초안은 `결재 관리` 목록의 "임시저장" 필터로 조회한다.
> 열람 권한은 상신자와 이미 결재를 수행한 승인자에게만 부여되며, 도달하지 않은 단계의 승인자는 목록/상세/이력 API에서 403 또는 비노출로 처리한다. (예: 상신→중간 승인 완료, 최종 승인 대기 시 상신자·중간 승인자만 열람 가능)
---
### 3.20 `approval_steps` (결재_단계)
@@ -502,7 +570,7 @@ zipcodes ||--o{ customers : addressed
| id | 단계ID | bigint | - | identity | Y | Y | Y | - |
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: approval_id, step_order, is_deleted) | N | - |
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
| step_status_id | 단계상태ID | bigint | - | - | Y | | | approval_statuses.id |
| assigned_at | 배정일시 | timestamp | - | now() | Y | | | - |
| decided_at | 결정일시 | timestamp | - | - | N | | | - |
@@ -524,8 +592,9 @@ zipcodes ||--o{ customers : addressed
| id | 이력ID | bigint | - | identity | Y | Y | Y | - |
| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id |
| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.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 | | | - |
@@ -537,6 +606,8 @@ zipcodes ||--o{ customers : addressed
---
- `action_code``submit`, `approve`, `reject`, `comment`, `recall`, `resubmit` 등 표준 문자열을 저장해 참조 행위 레코드가 없어도 이력 복원이 가능하도록 한다.
### 3.22 `approval_templates` (결재_템플릿)
| 영문테이블명 | 한글테이블명 |
|---|---|
@@ -548,7 +619,7 @@ zipcodes ||--o{ customers : addressed
| template_code | 템플릿코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| template_name | 템플릿명 | varchar | 100 | - | Y | | | |
| description | 설명 | varchar | 255 | - | N | | | - |
| created_by_id | 작성자ID | bigint | - | - | Y | | | employees.id |
| created_by_id | 작성자ID | bigint | - | - | Y | | | users.id |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
@@ -567,7 +638,7 @@ zipcodes ||--o{ customers : addressed
| id | 템플릿단계ID | bigint | - | identity | Y | Y | Y | - |
| template_id | 템플릿ID | bigint | - | - | Y | (복합유니크: template_id, step_order, is_deleted) | N | approval_templates.id |
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: template_id, step_order, is_deleted) | N | - |
| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id |
| approver_id | 승인자ID | bigint | - | - | Y | | | users.id |
| note | 비고 | text | - | - | N | | | - |
| is_active | 사용여부 | boolean | - | true | Y | | | |
| is_deleted | 삭제여부 | boolean | - | false | Y | | | |
@@ -578,17 +649,70 @@ 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`
- `employees.group_id``groups.id`
- `users.group_id``groups.id`
- `group_menu_permissions.group_id``groups.id`
- `group_menu_permissions.menu_id``menus.id`
- `warehouses.zipcode``zipcodes.zipcode`
- `customers.zipcode``zipcodes.zipcode`
- `products.vendor_id``vendors.id`
- `products.uom_id``uoms.id`
- `stock_transactions.warehouse_id``warehouses.id`
- `stock_transactions.created_by_id``employees.id`
- `stock_transactions.created_by_id``users.id`
- `stock_transactions.transaction_type_id``transaction_types.id`
- `stock_transactions.transaction_status_id``transaction_statuses.id`
- `transaction_lines.transaction_id``stock_transactions.id`
@@ -598,19 +722,19 @@ zipcodes ||--o{ customers : addressed
- `approvals.transaction_id``stock_transactions.id`
- `approvals.approval_status_id``approval_statuses.id`
- `approvals.current_step_id``approval_steps.id`
- `approvals.requested_by_id``employees.id`
- `approvals.requested_by_id``users.id`
- `approval_steps.approval_id``approvals.id`
- `approval_steps.approver_id``employees.id`
- `approval_steps.approver_id``users.id`
- `approval_steps.step_status_id``approval_statuses.id`
- `approval_histories.approval_id``approvals.id`
- `approval_histories.approval_step_id``approval_steps.id`
- `approval_histories.approver_id``employees.id`
- `approval_histories.approver_id``users.id`
- `approval_histories.approval_action_id``approval_actions.id`
- `approval_histories.from_status_id``approval_statuses.id`
- `approval_histories.to_status_id``approval_statuses.id`
- `approval_templates.created_by_id``employees.id`
- `approval_templates.created_by_id``users.id`
- `approval_template_steps.template_id``approval_templates.id`
- `approval_template_steps.approver_id``employees.id`
- `approval_template_steps.approver_id``users.id`
---
@@ -622,18 +746,24 @@ zipcodes ||--o{ customers : addressed
- 단계 전이는 **현재 단계**에서만 수행 가능. blocking 상태에서는 차기 이동 불가.
- 수량/단가 음수 금지(CHECK).
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
-의 소속 그룹(`employees.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
-용자의 소속 그룹(`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()`로 기록한다.
- 비밀번호 변경이 성공하면 기존 세션은 즉시 만료하고, 강제 로그아웃 알림을 반환한다. 이후 무효화된 토큰으로 요청하면 `token revoked` 메시지를 반환해야 한다.
---
## 6) 인덱스/유니크 권장
- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존:
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `employees(employee_no)`, `menus(menu_code)`, `groups(group_name)`, `zipcodes(zipcode)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `users(employee_id)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
- `group_menu_permissions(group_id, menu_id, is_deleted)`
- `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장
- `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 인덱스.
---
@@ -652,9 +782,10 @@ zipcodes ||--o{ customers : addressed
4) 부분 유니크 인덱스(`WHERE is_deleted=false`) 또는 `(컬럼, is_deleted)` 복합 유니크 구성.
5) 기존 결재 이력은 `approval_step_id` 매핑(없으면 1단계로 귀속).
6) `approval_statuses``is_blocking_next`, `is_terminal` 값 시드.
7) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사원-그룹 매핑 `employees.group_id`이관.
8) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
9) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.
7) `users` 테이블에 `employee_id`, `name`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 컬럼을 추가하고 기존 `employee_no`, `employee_name`, `mobile_no` 데이터를 규칙에 맞게 마이그레이션한다. 그룹 매핑 `users.group_id`유지한다.
8) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사용자-그룹 매핑을 `users.group_id`로 유지한다.
9) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
10) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.
---
@@ -675,3 +806,5 @@ zipcodes ||--o{ customers : addressed
- `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장.
- 낙관적 잠금(선택): `version`(int) + ETag.
- 병렬 결재 확장(선택): `approval_steps``group_no`, `approval_mode(all|any)` 도입.
- 감사 로그(`approval_audits`) 적재 시 `approval.audit.recorded` 이벤트를 Kafka(토픽 예: `approval_audit_events`)와 WebSocket 브로드캐스트로 발행한다. 이벤트 구성은 `{ event, version, emitted_at, request_id, audit_id, summary }` JSON으로 정의하며, 운영 환경별 엔드포인트는 `event_bus.kafka.*`, `event_bus.websocket.*` 설정으로 분리한다.
- `/health` 응답의 `build_version``config/default.toml``[app].build_version`을 사용하며, `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.

View File

@@ -0,0 +1,60 @@
# 사용자 계정 리팩터링 실행 계획
## 배경
- 최신 스펙(`stock_approval_system_spec_v4.md`, `stock_approval_system_api_v4.md`)은 `users` 리소스를 기준으로 `employee_id`, `force_password_change`, `password_updated_at` 등을 요구한다.
- 현재 백엔드 구현은 `employees` 테이블과 `/employees` API에 머물러 있어 신규 필드/엔드포인트가 부재하며, 로그인 흐름도 `force_password_change` 플래그를 해석하지 않는다.
- `doc/user_setting.md`에서 정의된 기능(관리자 생성, 자기 정보 수정, 비밀번호 재설정, 최초 로그인 강제 변경 등)을 지원하려면 스키마/도메인/레포지토리/API/인증 레이어 전반 리팩터링이 필요하다.
## 변경 범위 설계
- **DB 스키마**
- `employees` 테이블을 `users`로 리네임하고, 컬럼명을 `employee_id`, `name`, `phone`으로 정규화한다.
- `password_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, `force_password_change BOOLEAN NOT NULL DEFAULT FALSE` 컬럼 추가.
- `employee_id`는 대문자로 정규화하고 `UPPER(employee_id)` 기반 부분 유니크 인덱스를 생성한다.
- 관련 인덱스/트리거/시퀀스를 `users_*` 명명으로 교체하고 FK 제약 이름도 정리한다.
- 마이그레이션 롤백 시 데이터를 보존하며 컬럼 제거 대신 초기화 전략 명시.
- **도메인 & 리포지토리**
- `src/domain/employees.rs``users.rs`로 파일명/모듈을 교체하고 모든 구조체/필드/검증 로직을 `user_*` 네이밍으로 재작성.
- SeaORM 엔티티(`EmployeeRepository`)를 `UserRepository`로 리팩터링하고 신규 필드 매핑, 정규화 로직(대문자 변환 등)을 구현.
- `EmployeeSortField::EmployeeNo` 등 정렬 키를 `employee_id` 기준으로 수정, 검색 시 이메일/이름/사번을 모두 지원.
- **API**
- `/api/v1/users` 스코프로 교체하고 목록/단건/생성/수정/삭제/복구 엔드포인트를 신규 구조에 맞춰 응답.
- `PATCH /users/me`, `POST /users/{id}/reset-password` 엔드포인트 추가.
- 관리자 전용 `PATCH /users/{id}`에서 `force_password_change` 토글과 그룹 변경을 지원.
- **인증**
- 로그인/리프레시 응답 사용자 필드를 `employee_id`로 변경하고, `force_password_change=true`일 때 토큰 대신 전용 에러 코드(`password_change_required`)를 반환.
- 비밀번호 변경/재설정 성공 시 세션 무효화 훅을 배치하고 `password_updated_at`을 갱신.
- **연쇄 영향**
- 승인/거래/대시보드 등 사용자 요약 정보를 노출하는 모든 응답 구조체에서 `employee_no``employee_id`, `employee_name``name`, `mobile_no``phone`으로 교체.
- OpenAPI 재생성(`backend/docs/openapi.generated.json`) 및 문서 싱크 확인.
## 단계별 작업 순서
1. **마이그레이션 작성 (`migration/080_schema_migrate_employees_to_users.sql`)**
- 컬럼/테이블 rename, 새 컬럼 추가, 인덱스/트리거 재정의, 데이터 정규화.
2. **도메인/리포지토리/엔티티 리팩터링**
- `Employee*` 구조체 및 레포지토리를 `User*`로 일괄 교체.
- 정규화/검증 로직(사번 대문자화, 이메일 소문자화, 비밀번호 정책)에 맞춘다.
3. **API 계층 업데이트**
- `/users` 라우팅, 자기 정보 수정/비밀번호 재설정 엔드포인트 추가.
- 응답 스키마를 스펙 문서와 동일하게 맞춘다.
4. **인증 및 세션 플로우 확장**
- `force_password_change` 처리, 세션 만료 훅, 에러 매핑 도입.
5. **연쇄 모듈(승인/트랜잭션/리포트 등) 필드명 치환**
- 모든 사용자 관련 요약 구조체와 JSON 필드를 업데이트하고 단위 테스트 보강.
6. **문서 & QA 체크**
- 변경된 API/스펙 재검증, `doc/frontend_api_alignment_plan.md` 등 연계 문서 업데이트.
- 통합 테스트 및 `cargo check`, `cargo fmt`, `cargo clippy`, `cargo test` 수행.
## 진행 현황 (2025-01-07)
- [x] `migration/080_schema_migrate_employees_to_users.sql` 작성 및 컬럼/인덱스/트리거 갱신.
- [x] 도메인/레포지토리/인증 계층을 `users` 기준으로 리팩터링하고 비밀번호/사번 검증 로직 반영.
- [x] `/api/v1/users` + `/users/me` + `/users/{id}/reset-password` 등 사용자 API 구현 및 기존 `/employees` 제거.
- [x] 인증 토큰 강제 갱신 로직과 세션 무효화 훅 연동.
- [x] 승인/거래/리포트 응답 내 사용자 요약 구조체 추가 정비 및 통합 테스트 확충.
- [x] 문서(`stock_approval_system_api_v4.md`, `stock_approval_system_spec_v4.md`, alignment 보고서) 최종 검수.
## 중단 대비 메모
- `migration/080_schema_migrate_employees_to_users.sql`이 적용된 상태이므로 롤백 시 `employees``users` rename 전후 스키마 차이를 반드시 확인할 것.
- `/api/v1/users` 엔드포인트가 활성화되어 있으며, JWT `pwd_updated_at` 클레임 기반 세션 무효화가 도입되어 이전 토큰은 비밀번호 변경 직후 사용 불가하다.
- 승인/거래/리포트 모듈에서 사용자요약을 읽어가는 경로를 전수 점검 중이므로, 후속 담당자는 변경된 도메인 구조(`ApprovalUserSummary`, `StockTransactionUserSummary` 등)를 참고해 릴레이션 누락이 없는지 점검할 것.
- 리포트/승인/재고 레이어의 사용자 요약 회귀 테스트가 `backend/src/adapters/repositories/` 모듈에 추가돼 있으니 실패 시 최근 사용자 필드 변경 여부부터 확인한다.
- 통합 테스트(`tests/users/`)는 아직 비어 있으므로, 테스트 생성 시 `tests/users/README.md`에 시나리오를 정리하고 `doc/frontend_backend_alignment_report.md`에 기록을 남긴다.

157
doc/user_setting.md Normal file
View File

@@ -0,0 +1,157 @@
# 사용자 설정 및 계정 관리 확장 작업 가이드
입고 등록/사용자 관리 기능에서 작성자(로그인 사용자) 정보를 정확히 추적하고, 관리자가 신규 사용자를 생성·관리할 수 있도록 백엔드/프런트엔드 동시 진행 항목을 정리했습니다. 기존 인증/세션 흐름과 충돌할 수 있으므로, 아래 항목을 참고해 단계별 검증을 병행하세요.
## 결재 플로우 용어 및 권한 매핑 (Approval Flow v2)
- **결재 요청자(Submitter)**: 트랜잭션 작성 시 자동으로 지정되는 사용자. 본인 단계에 대한 회수/재상신만 수행할 수 있으며, 결재 진행 현황을 전체 조회할 수 있다. (참조 문서: `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`)
- **중간 승인자(Intermediate Approver)**: 순차 결재 단계에 배치되는 승인자. 지정된 단계에 도달했을 때만 승인/반려를 수행할 수 있으며, 중복 배치가 허용되지 않는다. (필요 권한: `approval.approve`)
- **최종 승인자(Final Approver)**: 결재 완료 여부를 확정하는 마지막 승인자. 결재 플로우 생성 시 반드시 포함되어야 하며, 승인 후 감사 로그가 기록된다. (필요 권한: `approval.approve`)
- **결재 관리자(Approval Manager)**: 결재 템플릿 생성/수정, 결재 요청 강제 종료 등 관리 기능을 실행하는 역할. 기능 토글(`feature.approval_flow_v2`) 활성화 시 백오피스에서 구성하며, 권한 키는 `approval.manage`로 통일한다.
- **감사 뷰어(Audit Viewer)**: 모든 결재 요청/이력에 대한 열람 권한을 가진 역할. 운영/QA용 계정에 부여하며, 권한 키는 `approval.view_all`을 사용한다.
- **슈퍼 관리자(Super Admin, terabits)**: 시스템 전역을 읽기 전용으로 조회할 수 있는 최고 권한. 결재 이력 수정은 금지되며, 실운영에서 데이터 정제 시 지원 역할로만 사용한다.
## 요구사항 요약
- 작성자는 현재 로그인한 사용자 계정을 사용한다. (기존 더미 계정 `terabits`는 최고 관리자 계정으로 유지하며 삭제하지 않는다)
- 신규 사용자는 관리자만 등록할 수 있으며, 필수 입력 필드는 `employee_id`, `name`, `phone`, `email`, `password`.
- `employee_id`는 영문/숫자만 허용한다. (정규식 예: `^[A-Za-z0-9]{4,32}$`)
- 사용자는 등록 후 `phone`, `email`, `password`만 스스로 수정할 수 있다.
- 관리자 상세 화면에서 **비밀번호 재설정** 버튼을 제공하며, 즉시 8자 랜덤(대/소문자+숫자) 비밀번호를 생성해 이메일로 발송한다.
- 최초 로그인 시 강제 비밀번호 변경을 요구한다.
- 비밀번호 정책: 길이 8~24자, 대/소문자, 숫자, 일반 특수문자 중 각 1자 이상 포함.
- 사용자 비밀번호 변경 화면은 기존 비밀번호, 신규 비밀번호, 신규 비밀번호 확인 3개의 입력란으로 구성한다.
- 로그인 후 우상단 사용자 메뉴에서 이메일/비밀번호/연락처 변경 가능하며 `저장` 버튼으로 확정한다.
- 비밀번호 변경 성공 시 즉시 로그아웃 팝업을 띄우고 확인과 동시에 세션을 만료한다. 취소는 허용하지 않는다.
## 영향 범위 및 문서 동기화 체크리스트
- 사용자 인증, 알림, 입고 등록 플로우 모두에 영향을 준다. 배포 전 후속 회귀 테스트를 위해 `doc/IMPLEMENTATION_TASKS.md`, `doc/frontend_api_alignment_plan.md`, `doc/frontend_backend_alignment_report.md`를 업데이트하고 담당자 서명을 남긴다.
- API/DTO 변경은 `doc/stock_approval_system_api_v4.md`, `doc/stock_approval_system_spec_v4.md`와 중복 정의가 없는지 교차 검토한다. 충돌 시 해당 문서를 동시에 수정한다.
- UI 사양 변경은 `doc/input_widget_guide.md`, `doc/frontend_auto_numbering_update.md`에서 안내하는 컴포넌트 규칙과 일치해야 한다. 불일치 발견 시 문서와 코드 모두 조정한다.
- 새로운 정책 문구는 QA/기획 승인 후 공유하고, 수정 이력은 `doc/DTO_TASKS.md` 또는 관련 변경 로그에 기록한다.
- 사이드 이펙트 방지를 위해 세션 만료, 알림(Notify), 이메일 발송 시스템에 대한 변경 감지 테스트를 작성하고, 관련 서비스 운영자에게 사전 알린다.
- 작업자는 위 문서들을 포함해 최신 요구사항 문서를 모두 검토한 뒤 개발을 시작하며, 수정한 문서 목록을 PR 설명에 명시한다.
## 백엔드 작업 항목
### 1. 도메인 및 저장소 구조 정리
- `users` 테이블/컬렉션에 `employee_id`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 필드를 추가한다.
- `employee_id` 컬럼은 유니크 인덱스를 생성하고 대소문자 구분 여부를 명확히 한다.
- 기존 `terabits` 계정은 최상위 관리자(Super Admin)로 고정 유지하며, 마이그레이션 스크립트에서 권한 승계가 깨지지 않는지 검증한다. 삭제 작업은 서비스 전환 이후 운영 측에서 수동 처리하도록 문서화한다.
- 마이그레이션 파일은 `backend/migrations/2024xxxx_add_user_fields.sql` 형태로 작성하고, 롤백 스크립트에 `DROP COLUMN` 대신 값 초기화를 고려한다.
- 도메인 레이어(`lib/domain/auth/entities/user.dart`)와 데이터 레이어 DTO가 새 필드를 반영하도록 Freezed 모델을 갱신한다.
### 2. 사용자 생성 API (`POST /users`)
- 관리자 권한 체크 로직을 선행한다.
- 요청 본문 검증: `employee_id`(정규식), `name`(40자 내외), `phone`(국내/국제 번호 포맷 허용), `email`(RFC 검증), `password`(정책 준수).
- `password`는 Bcrypt/Scrypt 등 기존 정책에 맞춰 해시 저장한다.
- 생성 직후 `force_password_change = true`로 설정하고 응답에 포함한다.
- 사용자 생성 완료 시 비밀번호 초기값을 메일 발송 큐에 전달한다.
- OpenAPI/Swagger 문서(`backend/openapi/user.yaml`)를 업데이트하고, 프런트엔드와 공유할 샘플 응답을 `doc/frontend_api_alignment_plan.md`에 추가한다.
- 새 API 연동을 위해 `lib/injection_container.dart`에서 `UserRepository` 구현에 `createUser` 메서드를 공개한다.
### 3. 사용자 정보 수정 API
- **자기 정보 수정 (`PATCH /users/me`)**
- 허용 필드: `phone`, `email`, `password`.
- `password` 변경 시 기존 비밀번호 검증 후 `force_password_change = false`, `password_updated_at` 갱신.
- **관리자용 수정 (`PATCH /users/{id}`)**
- 허용 필드: `phone`, `email`, 권한 변경 등 필요한 항목만 열어준다.
- 비밀번호 재설정은 별도 엔드포인트로 분리한다.
- 비밀번호 변경 시 감사 로그(`audit_logs` 테이블)와 보안 이벤트 버스에 `password_change` 이벤트를 발행해 SIEM과 연계한다.
- `PATCH /users/me` 응답에 `forcePasswordChange` 상태를 포함해 프런트 동기화를 용이하게 한다.
### 4. 비밀번호 재설정 API (`POST /users/{id}/reset-password`)
- 관리자 권한 필터 적용.
- 랜덤 8자(대/소문자·숫자 균형) 비밀번호 생성 유틸리티 구현.
- 새 비밀번호 해시 저장 후 `force_password_change = true` 설정.
- 이메일 발송: 템플릿에 임시 비밀번호 및 최초 로그인 안내 포함.
- 감사 로깅: 누가 언제 어떤 사용자 비밀번호를 초기화했는지 저장.
- 임시 비밀번호는 10분 동안만 유효하도록 선택적 만료 토큰을 발급하고, 사용자는 첫 로그인 시 즉시 변경해야 한다는 메시지를 포함한다.
- API 오류 응답 코드(권한 없음, 사용자 없음, 메일 전송 실패)를 정의하고 `doc/error_message_guide.md`와 일치시킨다.
### 5. 최초 로그인 강제 변경 로직
- 로그인 성공 시 `force_password_change``true`면 액세스 토큰 발급 대신 “비밀번호 재설정 필요” 상태 코드/에러 코드를 반환한다.
- 프런트엔드는 이 코드를 받아 전용 비밀번호 변경 화면으로 이동한다.
- 기존 Remember-me/세션 로직과의 호환성을 검증한다.
- 모바일/웹 동시 로그인 시 한쪽에서 비밀번호 변경하면 다른 기기 세션도 강제 만료되도록 Redis 세션 캐시를 무효화한다.
- 인증 로그(`auth_events`)에 `force_password_change_triggered` 이벤트를 남겨 추적 가능하도록 한다.
### 6. 이메일 발송 및 템플릿
- 신규 계정 생성/비밀번호 재설정 공통 템플릿을 작성하고 변수: `name`, `employee_id`, `temp_password`, `reset_url`.
- SMTP/메일 서비스 환경 변수 재점검(`MAIL_FROM`, `RESET_URL_BASE` 등).
- QA 환경에서는 메일 전송을 sandbox로 대체하고 로그로 임시 비밀번호를 출력한다.
- 템플릿 초안은 `doc/qa/email_templates/user_password_reset.md`에 저장하고, PR 시점에 QA 승인 코멘트를 첨부한다.
- 메일 발송 실패 시 재시도 큐를 3회까지 두고, 실패 알림을 Slack/Notify로 전송한다.
### 7. 테스트 & 배포 체크리스트
- [ ] 사용자 생성, 자기 정보 수정, 관리자 초기화 API 통합 테스트 작성.
- [ ] 비밀번호 정책 유효성 테스트 (허용/거부 케이스) 구현.
- [ ] 마이그레이션 스크립트와 롤백 스크립트 준비.
- [ ] 배포 전 staging에서 실제 메일 발송 여부 검증.
- [ ] 기존 로그인 세션/토큰 구조와 충돌 여부 점검.
- [ ] Notify 파이프라인(`notify.py`)을 통해 릴리스 직후 운영자에게 변경 내역을 알린다.
- [ ] 장애 발생 시 롤백 전략(임시 비밀번호 재발급 차단, 구 계정 복구)을 문서화한다.
## 프런트엔드 작업 항목
### 1. 관리자 > 사용자 관리 화면 개편
- 사용자 목록에 `employee_id`, `name`, `phone`, `email`, `role` 컬럼을 추가하고 서버 페이징을 재검토한다.
- 신규 등록 모달/페이지에 입력 필드를 추가하고 프런트 검증(정규식, 필수 여부)을 구현한다.
- 제출 시 비밀번호 정책 위반 시 프론트에서 선제적으로 에러 메시지 표시(한글 메시지, 정책 안내 문구 포함).
- 생성 성공 후 토스트 및 리스트 리프레시, 임시 비밀번호가 이메일로 발송됨을 명시한다.
- UI 구현 시 `lib/features/user_management/presentation/widgets/shad_user_table.dart``ShadTable` 컴포넌트를 기반으로 열 구성을 추가한다.
- 상태 관리는 `lib/features/user_management/presentation/controllers/user_controller.dart``createUser` 액션을 확장하고, 에러 핸들링을 중앙화한다.
### 2. 관리자 > 사용자 상세 보기
- 비밀번호 재설정 버튼 추가: 클릭 시 확인 다이얼로그 → API 호출 → 성공 토스트.
- 다이얼로그 문구에 “임시 비밀번호가 이메일로 발송된다”는 안내 포함.
- 감사 로그 필요 시 별도 이벤트 추적(`analytics`/`Sentry breadcrumb`)을 연동한다.
- 다이얼로그는 `SuperportShadDialog`를 사용하고, 컴포넌트는 `lib/widgets/dialogs/` 밑에 재사용 가능하게 분리한다.
- API 응답(메시지/실패 코드)은 `lib/core/network/api_error_mapper.dart`에서 한글 메시지로 변환한다.
### 3. 우상단 사용자 메뉴 개선
- `내 정보` 패널에서 이메일/연락처 수정 필드를 제공하고, 변경 시 Dirty 상태 감지 → `저장` 버튼 활성화.
- 저장 성공 후 사용자 상태 스토어/Provider를 갱신하여 헤더/다른 화면과 동기화.
- 비밀번호 변경 진입 버튼을 분리하고, 모달 또는 전용 페이지에서 3개 입력 필드를 제공한다.
- `lib/features/profile/presentation/pages/profile_page.dart`에서 폼 상태와 검증 로직을 `lib/features/profile/presentation/controllers/profile_controller.dart`로 분리한다.
- `lib/core/validation/password_rules.dart`에 비밀번호 정책 검증 유틸을 추가하고, 모든 비밀번호 입력 필드에서 재사용한다.
### 4. 비밀번호 변경 플로우
- `현재 비밀번호`, `새 비밀번호`, `새 비밀번호 확인` 필드와 실시간 정책 검증(대/소문자, 숫자, 특수문자) UI를 구현한다.
- 저장 시 API 호출 → 성공하면 즉시 비밀번호 변경 완료 다이얼로그/스낵바 → **강제 로그아웃 팝업** 표출.
- 팝업은 확인 버튼만 제공하며, 클릭 시 `authController.signOut()` 호출 후 로그인 페이지로 리다이렉트.
- 실패 케이스 처리: 기존 비밀번호 불일치, 정책 위반, 서버 에러 각각에 맞는 오류 메시지.
- 팝업 UI는 `lib/features/auth/presentation/widgets/logout_alert.dart`로 분리하여 테스트 가능하도록 한다.
- 로그아웃 후 캐시된 사용자 정보는 `UserSessionStore.clear()`를 호출해 잔여 데이터를 모두 제거한다.
### 5. 최초 로그인 경로 처리
- 로그인 성공 응답이 “비밀번호 변경 필요” 상태이면 인증 토큰을 저장하지 않고, 전용 비밀번호 변경 화면으로 라우팅.
- 비밀번호 변경 완료 후에는 정상 로그인 플로우 재시도(저장된 자격 증명 재사용 금지).
- 라우터(`lib/core/router/app_router.dart`)에 `forcePasswordChange` 상태를 처리하는 보호 라우트를 추가한다.
- 상태 관리(`AuthNotifier`)는 비밀번호 변경 필요 상태를 저장하고, 다른 화면 접근 시 가드를 적용한다.
### 6. 공통 사항
- `employee_id`는 읽기 전용 표시로 유지하며, 자기 정보 수정 화면에서는 비활성화한다.
- 폼 검증 메시지는 `lib/core/validation` 또는 기존 유틸 모듈에 추가하고 재사용한다.
- API 타입 정의(DTO/모델) 업데이트: `forcePasswordChange` 플래그, `phone`/`email` 수정 필드 등 반영.
- 테스트: 관리자 화면 위젯 테스트, 비밀번호 변경 위젯 테스트, 상태 관리 유닛 테스트 작성.
- `dart run build_runner build --delete-conflicting-outputs` 명령으로 DTO 업데이트 후 생성물을 재생성한다.
- Storybook/샘플 화면이 있다면 사용자 메뉴/다이얼로그 스토리를 갱신한다.
### 7. QA 체크리스트
- [ ] 신규 사용자 생성 시 임시 비밀번호 안내 모달과 이메일 발송 메세지가 노출된다.
- [ ] 임시 비밀번호로 로그인하면 곧바로 비밀번호 변경 화면으로 이동한다.
- [ ] 비밀번호 변경 후 로그아웃 팝업이 표시되고, 확인 시 실제 로그아웃 된다.
- [ ] 이메일/연락처 저장 시 즉시 프로필 정보가 갱신된다.
- [ ] 관리자 비밀번호 재설정 후 사용자가 로그인 시 새 임시 비밀번호가 요구된다.
- [ ] API 실패(메일·세션·권한 오류) 시 사용자에게 명확한 한글 메시지가 노출된다.
- [ ] 프로필 데이터 캐시가 오래된 경우 새 정보로 갱신되는지 확인한다.
- [ ] 접근 권한이 없는 사용자가 관리자 화면에 진입하면 즉시 차단된다.
## 동시 작업 및 커뮤니케이션 가이드
- 개발 순서는 **백엔드 구현과 검증을 선행**하고, API 스펙이 확정·배포된 이후 프런트엔드 개발을 진행한다. 프런트에서 임의 Mock을 사용하지 말고, 백엔드가 전달한 실제 응답 계약을 기반으로 연동한다.
- 백엔드 구현 완료 시점에 API 계약서와 샘플 응답을 공유하고, 프런트엔드는 이를 수신한 뒤 화면/로직을 개발한다.
- API 스펙 변경 사항은 `doc/frontend_api_alignment_plan.md`에 연동 기록을 추가하고, DTO 변경은 `dart run build_runner` 실행 시점 합의 후 진행한다.
- 이메일 템플릿, 비밀번호 정책 문구 등 사용자 노출 텍스트는 `product/QA` 승인 후 배포한다.
- 배포 순서: 백엔드 마이그레이션 → 신규 API 배포 → 프런트 배포 → 더미 계정 정리.
- 사이드 이펙트 대비: 로그인 세션 만료, 캐시된 사용자 정보, 기존 Admin UI 권한 체크 로직 변경 시 전체 기능 회귀 테스트를 수행한다.
- 변경 사항은 `notify.py` 워크플로로 전달하고, 영향 범위(입고 등록, 승인 플로우, 보고서 출력)를 명시한다.
- 프런트·백엔드 책임자는 매일 스탠드업에서 진행 현황과 문서 업데이트 여부를 공유한다.

146
flutter_test_config.dart Normal file
View File

@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:io';
/// 스테이징 결재 더블(approval double) 연결 시 인증서 우회를 위한 HttpOverrides.
class _ApprovalStagingHttpOverrides extends HttpOverrides {
_ApprovalStagingHttpOverrides(this._patterns);
final List<_HostPattern> _patterns;
bool _isAllowedHost(String host) {
final normalized = host.trim().toLowerCase();
if (normalized.isEmpty) {
return false;
}
for (final pattern in _patterns) {
if (pattern.matches(normalized)) {
return true;
}
}
return false;
}
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = super.createHttpClient(context);
client.badCertificateCallback = (cert, host, port) {
// 허용된 호스트에 대해서만 자기서명 인증서를 허용한다.
return _patterns.isNotEmpty && _isAllowedHost(host);
};
return client;
}
}
/// 호스트 패턴(정확 일치 또는 서픽스 매칭)을 표현한다.
class _HostPattern {
_HostPattern._(this.value, this.isSuffix);
factory _HostPattern.parse(String raw) {
final trimmed = raw.trim().toLowerCase();
if (trimmed.startsWith('*.')) {
return _HostPattern._(trimmed.substring(2), true);
}
if (trimmed.startsWith('.')) {
return _HostPattern._(trimmed.substring(1), true);
}
return _HostPattern._(trimmed, false);
}
final String value;
final bool isSuffix;
bool get isValid => value.isNotEmpty;
bool matches(String host) {
if (!isValid) {
return false;
}
if (isSuffix) {
return host == value || host.endsWith('.$value');
}
return host == value;
}
}
bool _parseBoolDynamic(String? raw) {
if (raw == null) {
return false;
}
switch (raw.trim().toLowerCase()) {
case '1':
case 'y':
case 'yes':
case 'true':
case 'on':
return true;
default:
return false;
}
}
List<_HostPattern> _collectAllowedPatterns() {
final env = Platform.environment;
final tokens = <String>[];
void addTokens(String? source) {
if (source == null || source.trim().isEmpty) {
return;
}
tokens.addAll(source.split(','));
}
addTokens(env['APPROVAL_DOUBLE_ALLOWED_HOSTS']);
addTokens(
const String.fromEnvironment(
'APPROVAL_DOUBLE_ALLOWED_HOSTS',
defaultValue: '',
),
);
final baseUrl =
env['API_BASE_URL'] ??
const String.fromEnvironment('API_BASE_URL', defaultValue: '');
final uri = Uri.tryParse(baseUrl);
if (uri != null && uri.host.isNotEmpty) {
tokens.add(uri.host);
}
final patterns = <_HostPattern>[];
for (final token in tokens) {
final pattern = _HostPattern.parse(token);
if (pattern.isValid) {
patterns.add(pattern);
}
}
return patterns;
}
/// Flutter 테스트 진입점.
///
/// - `USE_APPROVAL_STAGING_DOUBLE` 토글이 true일 때 HttpOverrides를 등록한다.
/// - `APPROVAL_DOUBLE_ALLOWED_HOSTS` 또는 `API_BASE_URL` 호스트를 인증 허용 목록에 추가한다.
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
final runOverride =
_parseBoolDynamic(Platform.environment['USE_APPROVAL_STAGING_DOUBLE']) ||
const bool.fromEnvironment(
'USE_APPROVAL_STAGING_DOUBLE',
defaultValue: false,
);
HttpOverrides? previous;
if (runOverride) {
final patterns = _collectAllowedPatterns();
if (patterns.isNotEmpty) {
previous = HttpOverrides.current;
HttpOverrides.global = _ApprovalStagingHttpOverrides(patterns);
}
}
try {
await testMain();
} finally {
if (runOverride) {
HttpOverrides.global = previous;
}
}
}

View File

@@ -0,0 +1,566 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/domain/usecases/approve_approval_use_case.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
void main() {
const runFlow = bool.fromEnvironment('STAGING_RUN_APPROVAL_FLOW');
const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_APPROVAL_FLOW');
if (!runFlow) {
testWidgets(
'approval flow e2e (환경 변수 설정 필요: STAGING_RUN_APPROVAL_FLOW=true)',
(tester) async {
tester.printToConsole(
'통합 테스트를 실행하려면 STAGING_RUN_APPROVAL_FLOW=true 를 설정하세요.',
);
},
skip: true,
);
return;
}
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final missingConfigs = <String>[];
if (!useFakeFlow) {
missingConfigs.add('STAGING_USE_FAKE_APPROVAL_FLOW=true');
}
if (missingConfigs.isNotEmpty) {
testWidgets(
'approval flow e2e (환경 변수 설정 필요: ${missingConfigs.join(', ')})',
(tester) async {
tester.printToConsole(
'결재 통합 테스트를 실행하려면 다음 변수를 설정하세요: ${missingConfigs.join(', ')}',
);
},
skip: true,
);
return;
}
testWidgets('inventory inbound → approval submit → approver hand-off', (
tester,
) async {
const transactionTypeId = 501;
const transactionStatusId = 10;
const warehouseId = 30;
const requesterId = 91;
const firstApproverId = 201;
const secondApproverId = 202;
final stockRepository = _FakeStockTransactionRepository(
transactionTypeId: transactionTypeId,
initialStatusId: transactionStatusId,
warehouseId: warehouseId,
employeeId: requesterId,
);
final approvalRepository = _FakeApprovalRepository();
final templateRepository = _FakeApprovalTemplateRepository();
final controller = ApprovalController(
approvalRepository: approvalRepository,
templateRepository: templateRepository,
transactionRepository: stockRepository,
);
final approveUseCase = ApproveApprovalUseCase(
repository: approvalRepository,
);
final now = DateTime.now();
final transactionInput = StockTransactionCreateInput(
transactionTypeId: transactionTypeId,
transactionStatusId: transactionStatusId,
warehouseId: warehouseId,
transactionDate: now,
createdById: requesterId,
note: 'integration-test ${now.toIso8601String()}',
lines: [
TransactionLineCreateInput(
lineNo: 1,
productId: 7001,
quantity: 5,
unitPrice: 1200,
),
],
customers: [TransactionCustomerCreateInput(customerId: 4001)],
approval: StockTransactionApprovalInput(
requestedById: requesterId,
steps: [
ApprovalStepAssignmentItem(stepOrder: 1, approverId: firstApproverId),
ApprovalStepAssignmentItem(
stepOrder: 2,
approverId: secondApproverId,
),
],
note: '입고 결재 테스트',
),
);
final createdTransaction = await stockRepository.create(transactionInput);
expect(createdTransaction.id, isNotNull);
tester.printToConsole('created transaction: ${createdTransaction.id}');
final approvalSubmission = ApprovalSubmissionInput(
transactionId: createdTransaction.id,
statusId: 1,
requesterId: requesterId,
note: transactionInput.approval.note,
steps: transactionInput.approval.steps,
);
final submittedApproval = await approvalRepository.submit(
approvalSubmission,
);
expect(submittedApproval.id, isNotNull);
tester.printToConsole('submitted approval: ${submittedApproval.id}');
await controller.fetch();
final approvals = controller.result?.items ?? const [];
expect(approvals, isNotEmpty);
expect(approvals.first.id, submittedApproval.id);
await controller.selectApproval(submittedApproval.id!);
expect(controller.selected?.currentStep?.stepOrder, 1);
final firstFlow = await approveUseCase(
ApprovalDecisionInput(
approvalId: submittedApproval.id!,
actorId: firstApproverId,
),
);
expect(firstFlow.currentStep?.stepOrder, 2);
tester.printToConsole('first approval completed, moved to step 2');
await controller.selectApproval(firstFlow.id!);
expect(controller.selected?.currentStep?.stepOrder, 2);
expect(controller.canProceedSelected, isTrue);
final finalFlow = await approveUseCase(
ApprovalDecisionInput(
approvalId: firstFlow.id!,
actorId: secondApproverId,
),
);
expect(finalFlow.currentStep, isNull);
expect(finalFlow.status.isTerminal, isTrue);
tester.printToConsole('approval completed by final approver');
await controller.selectApproval(finalFlow.id!);
expect(controller.selected?.status.isTerminal, isTrue);
expect(controller.canProceedSelected, isFalse);
});
}
class _FakeStockTransactionRepository implements StockTransactionRepository {
_FakeStockTransactionRepository({
required this.transactionTypeId,
required this.initialStatusId,
required this.warehouseId,
required this.employeeId,
});
final int transactionTypeId;
final int initialStatusId;
final int warehouseId;
final int employeeId;
int _sequence = 1;
final Map<int, StockTransaction> _transactions = {};
@override
Future<StockTransaction> create(StockTransactionCreateInput input) async {
final id = _sequence++;
final transaction = StockTransaction(
id: id,
transactionNo: 'TRX-${id.toString().padLeft(6, '0')}',
transactionDate: input.transactionDate,
type: StockTransactionType(id: transactionTypeId, name: '입고'),
status: StockTransactionStatus(id: initialStatusId, name: '작성중'),
warehouse: StockTransactionWarehouse(
id: warehouseId,
code: 'WH-$warehouseId',
name: '테스트 창고',
),
createdBy: StockTransactionEmployee(
id: employeeId,
employeeNo: 'EMP-$employeeId',
name: '작성자',
),
note: input.note,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
lines: input.lines
.map(
(line) => StockTransactionLine(
id: line.lineNo,
lineNo: line.lineNo,
product: StockTransactionProduct(
id: line.productId,
code: 'PRD-${line.productId}',
name: '테스트 품목',
),
quantity: line.quantity,
unitPrice: line.unitPrice,
note: line.note,
),
)
.toList(growable: false),
customers: input.customers
.map(
(customer) => StockTransactionCustomer(
id: customer.customerId,
customer: StockTransactionCustomerSummary(
id: customer.customerId,
code: 'CUST-${customer.customerId}',
name: '거래처',
),
note: customer.note,
),
)
.toList(growable: false),
);
_transactions[id] = transaction;
return transaction;
}
@override
Future<StockTransaction> submit(int id, {String? note}) async {
final transaction = _require(id);
final updated = transaction.copyWith(
status: StockTransactionStatus(id: transaction.status.id, name: '제출'),
updatedAt: DateTime.now(),
);
_transactions[id] = updated;
return updated;
}
StockTransaction _require(int id) {
final transaction = _transactions[id];
if (transaction == null) {
throw StateError('트랜잭션($id)을 찾을 수 없습니다.');
}
return transaction;
}
@override
Future<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
}) async {
return PaginatedResult(
items: _transactions.values.toList(growable: false),
page: 1,
pageSize: _transactions.length,
total: _transactions.length,
);
}
@override
Future<StockTransaction> fetchDetail(int id, {List<String>? include}) async {
return _require(id);
}
@override
Future<StockTransaction> update(int id, StockTransactionUpdateInput input) =>
throw UnimplementedError();
@override
Future<void> delete(int id) => throw UnimplementedError();
@override
Future<StockTransaction> restore(int id) => throw UnimplementedError();
@override
Future<StockTransaction> complete(int id, {String? note}) =>
throw UnimplementedError();
@override
Future<StockTransaction> approve(int id, {String? note}) =>
throw UnimplementedError();
@override
Future<StockTransaction> reject(int id, {String? note}) =>
throw UnimplementedError();
@override
Future<StockTransaction> cancel(int id, {String? note}) =>
throw UnimplementedError();
}
class _FakeApprovalRepository implements ApprovalRepository {
int _sequence = 1000;
final Map<int, Approval> _approvals = {};
@override
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
int? transactionId,
int? approvalStatusId,
int? requestedById,
List<String>? statusCodes,
bool includePending = false,
bool includeHistories = false,
bool includeSteps = false,
}) async {
final items = _approvals.values.toList(growable: false);
return PaginatedResult(
items: items,
page: 1,
pageSize: items.length,
total: items.length,
);
}
@override
Future<Approval> fetchDetail(
int id, {
bool includeSteps = true,
bool includeHistories = true,
}) async {
final approval = _approvals[id];
if (approval == null) {
throw StateError('결재($id)를 찾을 수 없습니다.');
}
return approval;
}
@override
Future<Approval> submit(ApprovalSubmissionInput input) async {
final id = _sequence++;
final approvalNo = 'APP-${id.toString().padLeft(6, '0')}';
final status = ApprovalStatus(id: input.statusId, name: '진행중');
final requester = ApprovalRequester(
id: input.requesterId,
employeeNo: 'EMP-${input.requesterId}',
name: '상신자',
);
final steps = input.steps
.map(
(step) => ApprovalStep(
id: step.stepOrder,
stepOrder: step.stepOrder,
approver: ApprovalApprover(
id: step.approverId,
employeeNo: 'EMP-${step.approverId}',
name: '승인자 ${step.approverId}',
),
status: ApprovalStatus(id: 1, name: '대기'),
assignedAt: DateTime.now(),
note: step.note,
),
)
.toList(growable: false);
final approval = Approval(
id: id,
approvalNo: approvalNo,
transactionNo: input.transactionId != null
? 'TRX-${input.transactionId}'
: 'TRX',
status: status,
requester: requester,
requestedAt: DateTime.now(),
note: input.note,
steps: steps,
histories: const [],
currentStep: steps.isEmpty ? null : steps.first,
);
_approvals[id] = approval;
return approval;
}
@override
Future<Approval> approve(ApprovalDecisionInput input) async {
final approval = await fetchDetail(input.approvalId);
final current = approval.steps.firstWhere(
(step) => step.decidedAt == null,
orElse: () => throw StateError('모든 결재 단계가 이미 완료되었습니다.'),
);
if (current.approver.id != input.actorId) {
throw StateError('현재 단계 승인자가 아닙니다.');
}
final steps = approval.steps
.map((step) {
if (step.decidedAt != null) {
return step;
}
if (step.approver.id != input.actorId) {
return step;
}
return step.copyWith(
decidedAt: DateTime.now(),
status: ApprovalStatus(id: 2, name: '승인됨', isTerminal: false),
);
})
.toList(growable: false);
final nextPending = steps.firstWhere(
(step) => step.decidedAt == null,
orElse: () => ApprovalStep(
id: -1,
stepOrder: -1,
approver: ApprovalApprover(
id: approval.requester.id,
employeeNo: approval.requester.employeeNo,
name: approval.requester.name,
),
status: ApprovalStatus(id: 2, name: '완료', isTerminal: true),
assignedAt: DateTime.now(),
),
);
ApprovalStatus nextStatus;
ApprovalStep? currentStep;
if (nextPending.stepOrder == -1) {
nextStatus = ApprovalStatus(id: 9, name: '승인 완료', isTerminal: true);
currentStep = null;
} else {
nextStatus = approval.status;
currentStep = steps.firstWhere((step) => step.decidedAt == null);
}
final updated = approval.copyWith(
status: nextStatus,
steps: steps,
currentStep: currentStep,
updatedAt: DateTime.now(),
);
_approvals[input.approvalId] = updated;
return updated;
}
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
final approval = await fetchDetail(id);
final nextPending = approval.steps.firstWhere(
(step) => step.decidedAt == null,
orElse: () => ApprovalStep(
id: -1,
stepOrder: -1,
approver: ApprovalApprover(
id: approval.requester.id,
employeeNo: approval.requester.employeeNo,
name: approval.requester.name,
),
status: approval.status,
assignedAt: DateTime.now(),
),
);
if (nextPending.stepOrder == -1) {
return ApprovalProceedStatus(
approvalId: id,
canProceed: false,
reason: '모든 단계가 완료되었습니다.',
);
}
return ApprovalProceedStatus(
approvalId: id,
canProceed: true,
reason: null,
);
}
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
return [
ApprovalAction(id: 1, name: '승인', code: 'approve'),
ApprovalAction(id: 2, name: '반려', code: 'reject'),
];
}
@override
Future<Approval> resubmit(ApprovalResubmissionInput input) =>
throw UnimplementedError();
@override
Future<Approval> reject(ApprovalDecisionInput input) =>
throw UnimplementedError();
@override
Future<Approval> recall(ApprovalRecallInput input) =>
throw UnimplementedError();
@override
Future<PaginatedResult<ApprovalHistory>> listHistory({
required int approvalId,
int page = 1,
int pageSize = 20,
DateTime? from,
DateTime? to,
int? actorId,
int? approvalActionId,
}) => throw UnimplementedError();
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) =>
throw UnimplementedError();
@override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) =>
throw UnimplementedError();
@override
Future<Approval> create(ApprovalCreateInput input) =>
throw UnimplementedError();
@override
Future<Approval> update(ApprovalUpdateInput input) =>
throw UnimplementedError();
@override
Future<void> delete(int id) => throw UnimplementedError();
@override
Future<Approval> restore(int id) => throw UnimplementedError();
}
class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository {
@override
Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1,
int pageSize = 20,
String? query,
bool? isActive,
}) async {
return PaginatedResult(
items: const <ApprovalTemplate>[],
page: 1,
pageSize: 0,
total: 0,
);
}
@override
Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true}) =>
throw UnimplementedError();
@override
Future<ApprovalTemplate> create(
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
}) => throw UnimplementedError();
@override
Future<ApprovalTemplate> update(
int id,
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
}) => throw UnimplementedError();
@override
Future<void> delete(int id) => throw UnimplementedError();
@override
Future<ApprovalTemplate> restore(int id) => throw UnimplementedError();
}

View File

@@ -0,0 +1,453 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart';
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const baseUrl = String.fromEnvironment('STAGING_API_BASE_URL');
const token = String.fromEnvironment('STAGING_API_TOKEN');
const runFlow = bool.fromEnvironment('STAGING_RUN_TRANSACTION_FLOW');
const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_FLOW');
const transactionTypeId = int.fromEnvironment(
'STAGING_TRANSACTION_TYPE_ID',
defaultValue: 0,
);
const transactionStatusId = int.fromEnvironment(
'STAGING_TRANSACTION_STATUS_ID',
defaultValue: 0,
);
const warehouseId = int.fromEnvironment(
'STAGING_WAREHOUSE_ID',
defaultValue: 0,
);
const employeeId = int.fromEnvironment(
'STAGING_EMPLOYEE_ID',
defaultValue: 0,
);
const productId = int.fromEnvironment('STAGING_PRODUCT_ID', defaultValue: 0);
const customerId = int.fromEnvironment(
'STAGING_CUSTOMER_ID',
defaultValue: 0,
);
final missingConfigs = <String>[];
if (!runFlow) {
missingConfigs.add('STAGING_RUN_TRANSACTION_FLOW=true');
}
if (!useFakeFlow) {
if (baseUrl.isEmpty) {
missingConfigs.add('STAGING_API_BASE_URL');
}
if (token.isEmpty) {
missingConfigs.add('STAGING_API_TOKEN');
}
if (transactionTypeId == 0) {
missingConfigs.add('STAGING_TRANSACTION_TYPE_ID');
}
if (transactionStatusId == 0) {
missingConfigs.add('STAGING_TRANSACTION_STATUS_ID');
}
if (warehouseId == 0) {
missingConfigs.add('STAGING_WAREHOUSE_ID');
}
if (employeeId == 0) {
missingConfigs.add('STAGING_EMPLOYEE_ID');
}
if (productId == 0) {
missingConfigs.add('STAGING_PRODUCT_ID');
}
if (customerId == 0) {
missingConfigs.add('STAGING_CUSTOMER_ID');
}
}
if (missingConfigs.isNotEmpty) {
final reason = '환경 변수를 설정하세요: ${missingConfigs.join(', ')}';
testWidgets(
'staging transaction flow (환경 변수 설정 필요: STAGING_RUN_TRANSACTION_FLOW 및 API 식별자)',
(tester) async {
tester.printToConsole('통합 테스트를 실행하려면 다음 값을 설정하세요.\n$reason');
},
skip: true,
);
return;
}
testWidgets('stock transaction end-to-end flow succeeds', (tester) async {
final resolvedTransactionTypeId = transactionTypeId == 0
? 100
: transactionTypeId;
final resolvedTransactionStatusId = transactionStatusId == 0
? 10
: transactionStatusId;
final resolvedWarehouseId = warehouseId == 0 ? 1 : warehouseId;
final resolvedEmployeeId = employeeId == 0 ? 1 : employeeId;
final resolvedProductId = productId == 0 ? 1 : productId;
final resolvedCustomerId = customerId == 0 ? 1 : customerId;
late final StockTransactionRepository repository;
if (useFakeFlow) {
repository = _FakeStockTransactionRepository(
transactionTypeId: resolvedTransactionTypeId,
initialStatusId: resolvedTransactionStatusId,
warehouseId: resolvedWarehouseId,
employeeId: resolvedEmployeeId,
productId: resolvedProductId,
customerId: resolvedCustomerId,
);
} else {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
),
);
final apiClient = ApiClient(dio: dio);
repository = StockTransactionRepositoryRemote(apiClient: apiClient);
}
final now = DateTime.now();
StockTransactionCreateInput buildCreateInput(String label) {
final suffix = '$label-${now.millisecondsSinceEpoch}';
return StockTransactionCreateInput(
transactionTypeId: resolvedTransactionTypeId,
transactionStatusId: resolvedTransactionStatusId,
warehouseId: resolvedWarehouseId,
transactionDate: now,
createdById: resolvedEmployeeId,
note: 'integration-test $suffix',
lines: [
TransactionLineCreateInput(
lineNo: 1,
productId: resolvedProductId,
quantity: 1,
unitPrice: 1,
note: 'line $suffix',
),
],
customers: [
TransactionCustomerCreateInput(customerId: resolvedCustomerId),
],
approval: StockTransactionApprovalInput(
requestedById: resolvedEmployeeId,
note: 'approval $suffix',
),
);
}
bool statusChanged(StockTransaction before, StockTransaction after) {
if (before.status.id != after.status.id) {
return true;
}
final beforeName = before.status.name.trim();
final afterName = after.status.name.trim();
return beforeName != afterName;
}
Future<void> safeDelete(int id) async {
try {
await repository.delete(id);
tester.printToConsole('deleted transaction: $id');
} catch (error) {
tester.printToConsole(
'삭제 중 경고: transaction $id 제거 실패 (${error.runtimeType})',
);
}
}
final primary = await repository.create(buildCreateInput('primary'));
expect(primary.id, isNotNull);
final primaryId = primary.id!;
tester.printToConsole('created transaction(primary): $primaryId');
final submitted = await repository.submit(
primaryId,
note: 'integration-submit-primary',
);
expect(submitted.id, equals(primaryId));
if (useFakeFlow) {
expect(submitted.status.name, contains('승인대기'));
} else {
expect(statusChanged(primary, submitted), isTrue);
}
tester.printToConsole(
'submitted transaction: $primaryId (status: ${submitted.status.name})',
);
final approved = await repository.approve(
primaryId,
note: 'integration-approve-primary',
);
expect(approved.id, equals(primaryId));
if (useFakeFlow) {
expect(approved.status.name, contains('승인완료'));
} else {
expect(statusChanged(submitted, approved), isTrue);
}
tester.printToConsole(
'approved transaction: $primaryId (status: ${approved.status.name})',
);
final completed = await repository.complete(
primaryId,
note: 'integration-complete-primary',
);
expect(completed.id, equals(primaryId));
if (useFakeFlow) {
expect(completed.status.name, contains('완료'));
} else {
expect(statusChanged(approved, completed), isTrue);
}
tester.printToConsole(
'completed transaction: $primaryId (status: ${completed.status.name})',
);
await safeDelete(primaryId);
final cancelTarget = await repository.create(buildCreateInput('cancel'));
expect(cancelTarget.id, isNotNull);
final cancelId = cancelTarget.id!;
tester.printToConsole('created transaction(cancel): $cancelId');
final cancelSubmitted = await repository.submit(
cancelId,
note: 'integration-submit-cancel',
);
expect(cancelSubmitted.id, equals(cancelId));
final cancelled = await repository.cancel(
cancelId,
note: 'integration-cancel',
);
expect(cancelled.id, equals(cancelId));
if (useFakeFlow) {
expect(cancelled.status.name, contains('취소'));
} else {
expect(statusChanged(cancelSubmitted, cancelled), isTrue);
}
tester.printToConsole(
'cancelled transaction: $cancelId (status: ${cancelled.status.name})',
);
await safeDelete(cancelId);
final rejectTarget = await repository.create(buildCreateInput('reject'));
expect(rejectTarget.id, isNotNull);
final rejectId = rejectTarget.id!;
tester.printToConsole('created transaction(reject): $rejectId');
final rejectSubmitted = await repository.submit(
rejectId,
note: 'integration-submit-reject',
);
expect(rejectSubmitted.id, equals(rejectId));
final rejected = await repository.reject(
rejectId,
note: 'integration-reject',
);
expect(rejected.id, equals(rejectId));
if (useFakeFlow) {
expect(rejected.status.name, contains('반려'));
} else {
expect(statusChanged(rejectSubmitted, rejected), isTrue);
}
tester.printToConsole(
'rejected transaction: $rejectId (status: ${rejected.status.name})',
);
await safeDelete(rejectId);
});
}
class _FakeStockTransactionRepository implements StockTransactionRepository {
_FakeStockTransactionRepository({
required this.transactionTypeId,
required this.initialStatusId,
required this.warehouseId,
required this.employeeId,
required this.productId,
required this.customerId,
});
final int transactionTypeId;
final int initialStatusId;
final int warehouseId;
final int employeeId;
final int productId;
final int customerId;
int _sequence = 1;
final Map<int, StockTransaction> _transactions = {};
@override
Future<StockTransaction> create(StockTransactionCreateInput input) async {
final id = _sequence++;
final generatedNo = 'FAKE-${id.toString().padLeft(6, '0')}';
final transaction = StockTransaction(
id: id,
transactionNo: generatedNo,
transactionDate: input.transactionDate,
type: StockTransactionType(id: input.transactionTypeId, name: '테스트 트랜잭션'),
status: StockTransactionStatus(id: initialStatusId, name: '작성중'),
warehouse: StockTransactionWarehouse(
id: warehouseId,
code: 'WH-$warehouseId',
name: '테스트 창고',
),
createdBy: StockTransactionEmployee(
id: employeeId,
employeeNo: 'EMP-$employeeId',
name: '통합 테스트 사용자',
),
note: input.note,
isActive: true,
lines: input.lines
.map(
(line) => StockTransactionLine(
id: line.lineNo,
lineNo: line.lineNo,
product: StockTransactionProduct(
id: line.productId,
code: 'P-${line.productId}',
name: '테스트 상품',
),
quantity: line.quantity,
unitPrice: line.unitPrice,
note: line.note,
),
)
.toList(growable: false),
customers: input.customers
.map(
(customer) => StockTransactionCustomer(
id: customer.customerId,
customer: StockTransactionCustomerSummary(
id: customer.customerId,
code: 'C-${customer.customerId}',
name: '테스트 고객',
),
note: customer.note,
),
)
.toList(growable: false),
expectedReturnDate: input.expectedReturnDate,
);
_transactions[id] = transaction;
return transaction;
}
@override
Future<StockTransaction> submit(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 1, name: '승인대기'),
);
}
@override
Future<StockTransaction> complete(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 2, name: '완료'),
);
}
@override
Future<StockTransaction> approve(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 3, name: '승인완료'),
);
}
@override
Future<StockTransaction> reject(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 4, name: '반려'),
);
}
@override
Future<StockTransaction> cancel(int id, {String? note}) async {
return _updateStatus(
id,
StockTransactionStatus(id: initialStatusId + 5, name: '취소'),
);
}
@override
Future<void> delete(int id) async {
_transactions.remove(id);
}
@override
Future<StockTransaction> restore(int id) async {
return _require(id);
}
@override
Future<StockTransaction> update(
int id,
StockTransactionUpdateInput input,
) async {
final current = _require(id);
final updated = current.copyWith(
status: StockTransactionStatus(
id: input.transactionStatusId,
name: '상태${input.transactionStatusId}',
),
note: input.note ?? current.note,
expectedReturnDate:
input.expectedReturnDate ?? current.expectedReturnDate,
);
_transactions[id] = updated;
return updated;
}
@override
Future<StockTransaction> fetchDetail(
int id, {
List<String> include = const ['lines', 'customers', 'approval'],
}) async {
return _require(id);
}
@override
Future<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
}) async {
final items = _transactions.values.toList(growable: false);
return PaginatedResult<StockTransaction>(
items: items,
page: filter?.page ?? 1,
pageSize: filter?.pageSize ?? items.length,
total: items.length,
);
}
StockTransaction _updateStatus(int id, StockTransactionStatus status) {
final current = _require(id);
final updated = current.copyWith(status: status);
_transactions[id] = updated;
return updated;
}
StockTransaction _require(int id) {
final transaction = _transactions[id];
if (transaction == null) {
throw StateError('Transaction $id not found');
}
return transaction;
}
}

View File

@@ -0,0 +1,65 @@
import '../models/paginated_result.dart';
/// 페이지네이션 API에서 모든 항목을 수집하는 도우미.
///
/// - 백엔드가 기본적으로 페이지 크기 제한을 두는 경우, 반복 호출로 전체 목록을 확보한다.
/// - `maxPages`는 안전장치로 사용하며, 비정상 응답으로 무한 루프에 빠지는 것을 막는다.
typedef PaginatedRequest<T> =
Future<PaginatedResult<T>> Function(int page, int pageSize);
/// 페이지 단위로 제공되는 데이터를 모두 불러온다.
///
/// - [request]는 페이지 번호와 페이지 크기를 받아 `PaginatedResult`를 반환해야 한다.
/// - [pageSize]는 첫 호출에 사용할 기본 페이지 크기이며, 서버가 다른 값을 돌려주면
/// 이후 호출에서는 응답 값을 따른다.
/// - [maxPages]는 허용할 최대 페이지 수로, 비정상 응답을 대비한 제한값이다.
Future<List<T>> fetchAllPaginatedItems<T>({
required PaginatedRequest<T> request,
int pageSize = 100,
int maxPages = 50,
}) async {
final results = <T>[];
var currentPage = 1;
var pagesFetched = 0;
var effectivePageSize = pageSize > 0 ? pageSize : 100;
while (pagesFetched < maxPages) {
final previousLength = results.length;
final response = await request(currentPage, effectivePageSize);
pagesFetched += 1;
final items = response.items;
if (items.isEmpty) {
break;
}
results.addAll(items);
final added = results.length - previousLength;
if (added <= 0) {
// 새 항목을 받지 못했다면 더 이상 진행하지 않는다.
break;
}
final receivedPageSize = () {
if (response.pageSize > 0) {
return response.pageSize;
}
if (items.isNotEmpty) {
return items.length;
}
return effectivePageSize;
}();
if (items.length < receivedPageSize) {
// 마지막 페이지에 도달한 경우 즉시 종료한다.
break;
}
effectivePageSize = receivedPageSize;
final nextPage = response.page > 0 ? response.page + 1 : currentPage + 1;
currentPage = nextPage > currentPage ? nextPage : currentPage + 1;
}
return results;
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:superport_v2/core/config/feature_flags.dart';
/// 환경 설정 로더
///
/// - .env.development / .env.production 파일을 로드하여 런타임 설정을 주입한다.
@@ -33,25 +35,31 @@ class Environment {
envName = envFromDefine.toLowerCase();
isProduction = envName == 'production';
final fileName = '.env.$envName';
final candidates = ['.env.$envName', 'assets/.env.$envName'];
var initialized = false;
for (final fileName in candidates) {
if (initialized) {
break;
}
try {
await dotenv.load(fileName: fileName);
initialized = true;
} catch (e) {
if (kDebugMode) {
// 개발 편의를 위해 파일 미존재 시 경고만 출력하고 진행
// 실제 배포에서는 파일 존재가 필수다.
// 웹 번들 자산(.env.*)까지 순차 시도 후 실패 시에만 기본값으로 폴백한다.
// ignore: avoid_print
print('[Environment] $fileName 로드 실패: $e');
}
}
}
if (!initialized) {
dotenv.testLoad();
}
baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080';
FeatureFlags.initialize();
_loadPermissions();
}
@@ -98,7 +106,7 @@ class Environment {
static bool hasPermission(String resource, String action) {
final actions = _permissions[resource.toLowerCase()];
if (actions == null || actions.isEmpty) {
return true;
return false;
}
if (actions.contains('all')) {
// all 키워드는 모든 액션 허용을 의미한다.
@@ -106,4 +114,19 @@ class Environment {
}
return actions.contains(action.toLowerCase());
}
/// 테스트에서 환경 권한 맵을 직접 오버라이드하기 위한 헬퍼.
@visibleForTesting
static void setTestPermissions(Map<String, Set<String>> permissions) {
_permissions
..clear()
..addAll(
permissions.map(
(key, value) => MapEntry(
key.toLowerCase(),
value.map((action) => action.toLowerCase()).toSet(),
),
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// 기능 토글을 중앙에서 관리한다.
///
/// - `Environment.initialize` 후 `FeatureFlags.initialize()`가 호출되어야 한다.
/// - 백엔드의 `feature.*` 키와 기존 `FEATURE_*` 키를 모두 지원한다.
class FeatureFlags {
FeatureFlags._();
static final Map<String, bool> _values = {};
/// 승인 화면 노출 여부.
static bool get approvalsEnabled => _values['approvals_enabled'] ?? false;
/// 재고 전표 화면 노출 여부.
static bool get stockTransitionsEnabled =>
_values['stock_transitions_enabled'] ?? false;
/// Approval Flow v2 기능 토글.
static bool get approvalFlowV2 => _values['approval_flow_v2'] ?? false;
/// 기능 토글을 환경 변수에서 읽어 초기화한다.
static void initialize() {
_values
..clear()
..addAll({
'approvals_enabled': _readFlag(
'FEATURE_APPROVALS_ENABLED',
defaultValue: false,
),
'stock_transitions_enabled': _readFlag(
'FEATURE_STOCK_TRANSITIONS_ENABLED',
defaultValue: true,
),
'approval_flow_v2': _readFlag(
'FEATURE_APPROVAL_FLOW_V2',
aliases: const [
'feature.approval_flow_v2',
'FEATURES_APPROVAL_FLOW_V2',
'features.approval_flow_v2',
],
defaultValue: false,
),
});
}
/// 논리 키 기반으로 토글 값을 조회한다.
static bool isEnabled(String logicalKey) =>
_values[logicalKey.toLowerCase()] ?? false;
static bool _readFlag(
String key, {
bool defaultValue = false,
List<String> aliases = const [],
}) {
for (final candidate in <String>{key, ...aliases}) {
final raw = dotenv.maybeGet(candidate);
if (raw == null) {
continue;
}
final normalized = raw.trim().toLowerCase();
switch (normalized) {
case '1':
case 'y':
case 'yes':
case 'true':
return true;
case '0':
case 'n':
case 'no':
case 'false':
return false;
default:
continue;
}
}
return defaultValue;
}
}

View File

@@ -1,177 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
/// 사이드바/내비게이션용 페이지 정보.
class AppPageDescriptor {
const AppPageDescriptor({
required this.path,
required this.label,
required this.icon,
required this.summary,
});
final String path;
final String label;
final IconData icon;
final String summary;
}
/// 메뉴 섹션을 나타내는 데이터 클래스.
class AppSectionDescriptor {
const AppSectionDescriptor({required this.label, required this.pages});
final String label;
final List<AppPageDescriptor> pages;
}
/// 로그인 라우트 경로.
const loginRoutePath = '/login';
/// 대시보드 라우트 경로.
const dashboardRoutePath = '/dashboard';
/// 네비게이션 구성을 정의한 섹션 목록.
const appSections = <AppSectionDescriptor>[
AppSectionDescriptor(
label: '대시보드',
pages: [
AppPageDescriptor(
path: dashboardRoutePath,
label: '대시보드',
icon: lucide.LucideIcons.layoutDashboard,
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
),
],
),
AppSectionDescriptor(
label: '입·출고',
pages: [
AppPageDescriptor(
path: '/inventory/inbound',
label: '입고',
icon: lucide.LucideIcons.packagePlus,
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
),
AppPageDescriptor(
path: '/inventory/outbound',
label: '출고',
icon: lucide.LucideIcons.packageMinus,
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
),
AppPageDescriptor(
path: '/inventory/rental',
label: '대여',
icon: lucide.LucideIcons.handshake,
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
),
],
),
AppSectionDescriptor(
label: '마스터',
pages: [
AppPageDescriptor(
path: '/masters/vendors',
label: '제조사 관리',
icon: lucide.LucideIcons.factory,
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
),
AppPageDescriptor(
path: '/masters/products',
label: '장비 모델 관리',
icon: lucide.LucideIcons.box,
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/warehouses',
label: '입고지 관리',
icon: lucide.LucideIcons.warehouse,
summary: '창고 주소와 사용여부를 설정합니다.',
),
AppPageDescriptor(
path: '/masters/customers',
label: '회사 관리',
icon: lucide.LucideIcons.building,
summary: '고객사 연락처와 주소 정보를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/users',
label: '사용자 관리',
icon: lucide.LucideIcons.users,
summary: '사번, 그룹, 사용여부를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/groups',
label: '그룹 관리',
icon: lucide.LucideIcons.layers,
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
),
AppPageDescriptor(
path: '/masters/menus',
label: '메뉴 관리',
icon: lucide.LucideIcons.listTree,
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
),
AppPageDescriptor(
path: '/masters/group-permissions',
label: '그룹 메뉴 권한',
icon: lucide.LucideIcons.shieldCheck,
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
),
],
),
AppSectionDescriptor(
label: '결재',
pages: [
AppPageDescriptor(
path: '/approvals/requests',
label: '결재 관리',
icon: lucide.LucideIcons.fileCheck,
summary: '결재 번호, 상태, 상신자를 관리합니다.',
),
AppPageDescriptor(
path: '/approvals/steps',
label: '결재 단계',
icon: lucide.LucideIcons.workflow,
summary: '단계 순서와 승인자 할당을 설정합니다.',
),
AppPageDescriptor(
path: '/approvals/history',
label: '결재 이력',
icon: lucide.LucideIcons.history,
summary: '결재 단계별 변경 이력을 조회합니다.',
),
AppPageDescriptor(
path: '/approvals/templates',
label: '결재 템플릿',
icon: lucide.LucideIcons.fileSpreadsheet,
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
),
],
),
AppSectionDescriptor(
label: '도구',
pages: [
AppPageDescriptor(
path: '/utilities/postal-search',
label: '우편번호 검색',
icon: lucide.LucideIcons.search,
summary: '모달 기반 우편번호 검색 도구입니다.',
),
],
),
AppSectionDescriptor(
label: '보고',
pages: [
AppPageDescriptor(
path: '/reports',
label: '보고서',
icon: lucide.LucideIcons.fileDown,
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
),
],
),
];
List<AppPageDescriptor> get allAppPages => [
for (final section in appSections) ...section.pages,
];

View File

@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../common/utils/pagination_utils.dart';
import '../network/failure.dart';
import '../../features/masters/menu/domain/entities/menu.dart';
import '../../features/masters/menu/domain/repositories/menu_repository.dart';
/// 메뉴 목록을 한 번만 로드해 전역에서 재사용하도록 제공하는 카탈로그.
///
/// - 메뉴 테이블 전체를 페이지네이션 API로 받아 캐시에 저장한다.
/// - AppShell/권한 설정 화면 등에서 공통 데이터를 구독해 재렌더링 비용을 줄인다.
class MenuCatalog extends ChangeNotifier {
MenuCatalog({required MenuRepository repository}) : _repository = repository;
final MenuRepository _repository;
List<MenuItem> _menus = const [];
bool _isLoading = false;
bool _initialized = false;
String? _errorMessage;
Completer<void>? _pendingLoad;
/// 최신 메뉴 목록.
List<MenuItem> get menus => _menus;
/// 현재 로딩 상태.
bool get isLoading => _isLoading;
/// 최초 로드 여부.
bool get isInitialized => _initialized;
/// 마지막 로드 실패 메시지.
String? get errorMessage => _errorMessage;
/// 캐시된 메뉴를 반환하며 필요 시 자동으로 로딩한다.
Future<List<MenuItem>> ensureLoaded({bool forceRefresh = false}) async {
if (_initialized && !forceRefresh) {
return _menus;
}
await refresh();
return _menus;
}
/// 백엔드에서 메뉴를 다시 불러와 캐시를 갱신한다.
Future<void> refresh() async {
if (_pendingLoad != null) {
await _pendingLoad!.future;
if (_errorMessage != null) {
throw StateError(_errorMessage!);
}
return;
}
final completer = Completer<void>();
_pendingLoad = completer;
_isLoading = true;
notifyListeners();
try {
final menus = await fetchAllPaginatedItems<MenuItem>(
request: (page, pageSize) => _repository.list(
page: page,
pageSize: pageSize,
includeDeleted: false,
),
);
_menus = List.unmodifiable(menus);
_initialized = true;
_errorMessage = null;
completer.complete();
} catch (error, stackTrace) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
completer.completeError(error, stackTrace);
rethrow;
} finally {
_isLoading = false;
_pendingLoad = null;
notifyListeners();
}
}
/// 외부에서 최신 메뉴 셋을 주입해 테스트/동기화를 돕는다.
void replaceAll(List<MenuItem> menus) {
_menus = List.unmodifiable(menus);
_initialized = true;
_errorMessage = null;
notifyListeners();
}
/// 특정 코드에 해당하는 메뉴를 조회한다.
MenuItem? findByCode(String menuCode) {
for (final menu in _menus) {
if (menu.menuCode == menuCode) {
return menu;
}
}
return null;
}
/// 캐시를 초기 상태로 되돌린다.
void reset() {
_menus = const [];
_initialized = false;
_errorMessage = null;
notifyListeners();
}
}
/// [MenuCatalog]를 위젯 트리에 노출하는 Inherited 래퍼.
class MenuCatalogScope extends InheritedNotifier<MenuCatalog> {
const MenuCatalogScope({
super.key,
required MenuCatalog catalog,
required super.child,
}) : super(notifier: catalog);
/// 현재 컨텍스트에서 [MenuCatalog]를 조회한다.
static MenuCatalog of(BuildContext context) {
final scope = context
.dependOnInheritedWidgetOfExactType<MenuCatalogScope>();
assert(
scope != null,
'MenuCatalogScope.of() called with no MenuCatalogScope ancestor.',
);
return scope!.notifier!;
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import '../../features/approvals/history/presentation/pages/approval_history_page.dart';
import '../../features/approvals/request/presentation/pages/approval_request_page.dart';
import '../../features/approvals/step/presentation/pages/approval_step_page.dart';
import '../../features/approvals/template/presentation/pages/approval_template_page.dart';
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/masters/customer/presentation/pages/customer_page.dart';
import '../../features/masters/group/presentation/pages/group_page.dart';
import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart';
import '../../features/masters/menu/presentation/pages/menu_page.dart';
import '../../features/masters/product/presentation/pages/product_page.dart';
import '../../features/masters/user/presentation/pages/user_page.dart';
import '../../features/masters/vendor/presentation/pages/vendor_page.dart';
import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart';
import '../../features/reporting/presentation/pages/reporting_page.dart';
import '../../features/util/postal_search/presentation/pages/postal_search_page.dart';
import '../permissions/permission_manager.dart';
import '../permissions/permission_resources.dart';
import '../routing/auth_guard.dart';
import 'route_paths.dart';
typedef MenuPageBuilder = Widget Function(BuildContext context, GoRouterState state);
/// 메뉴 코드 ↔ 라우트 정의를 연결하는 매니페스트.
class MenuRouteDefinition {
const MenuRouteDefinition({
required this.menuCode,
this.aliases = const {},
required this.routePath,
required this.defaultLabel,
required this.icon,
required this.builder,
this.defaultOrder = 0,
this.extraRequirements = const [],
this.showInNavigation = true,
});
final String menuCode;
final Set<String> aliases;
final String routePath;
final String defaultLabel;
final IconData icon;
final MenuPageBuilder builder;
final int defaultOrder;
final List<PermissionRequirement> extraRequirements;
final bool showInNavigation;
Iterable<String> get codes sync* {
yield menuCode;
for (final alias in aliases) {
yield alias;
}
}
Iterable<PermissionRequirement> get requirements sync* {
yield PermissionRequirement(resource: routePath);
for (final requirement in extraRequirements) {
yield requirement;
}
}
bool canAccess(PermissionManager manager) {
for (final requirement in requirements) {
if (!manager.can(requirement.resource, requirement.action)) {
return false;
}
}
return true;
}
RouteGuard? buildGuard({String fallback = dashboardRoutePath}) {
final guards = requirements.toList(growable: false);
if (guards.isEmpty) {
return null;
}
return AuthGuard.requireAll(requirements: guards, fallback: fallback);
}
}
final List<MenuRouteDefinition> menuRouteDefinitions = [
MenuRouteDefinition(
menuCode: 'dashboard',
routePath: dashboardRoutePath,
defaultLabel: '대시보드',
icon: lucide.LucideIcons.layoutDashboard,
builder: (context, state) => const DashboardPage(),
defaultOrder: 10,
),
MenuRouteDefinition(
menuCode: 'inventory.summary',
aliases: {'inventory'},
routePath: inventorySummaryRoutePath,
defaultLabel: '재고 현황',
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
builder: (context, state) => InventorySummaryPage(routeUri: state.uri),
defaultOrder: 11,
extraRequirements: const [
PermissionRequirement(resource: PermissionResources.inventoryScope),
],
),
MenuRouteDefinition(
menuCode: 'inventory.receipts',
aliases: {'inventory.inbound'},
routePath: inventoryReceiptsRoutePath,
defaultLabel: '입고',
icon: lucide.LucideIcons.packagePlus,
builder: (context, state) => InboundPage(routeUri: state.uri),
defaultOrder: 12,
),
MenuRouteDefinition(
menuCode: 'inventory.issues',
aliases: {'inventory.outbound'},
routePath: inventoryIssuesRoutePath,
defaultLabel: '출고',
icon: lucide.LucideIcons.packageMinus,
builder: (context, state) => OutboundPage(routeUri: state.uri),
defaultOrder: 13,
),
MenuRouteDefinition(
menuCode: 'inventory.rentals',
routePath: inventoryRentalsRoutePath,
defaultLabel: '대여',
icon: lucide.LucideIcons.handshake,
builder: (context, state) => RentalPage(routeUri: state.uri),
defaultOrder: 14,
),
MenuRouteDefinition(
menuCode: 'inventory.vendors',
aliases: {'inventory.manufacturers', 'masters.vendors'},
routePath: inventoryVendorsRoutePath,
defaultLabel: '제조사 관리',
icon: lucide.LucideIcons.factory,
builder: (context, state) => VendorPage(routeUri: state.uri),
defaultOrder: 30,
),
MenuRouteDefinition(
menuCode: 'inventory.products',
aliases: {'inventory.models', 'masters.products'},
routePath: inventoryProductsRoutePath,
defaultLabel: '제품 관리',
icon: lucide.LucideIcons.box,
builder: (context, state) => ProductPage(routeUri: state.uri),
defaultOrder: 31,
),
MenuRouteDefinition(
menuCode: 'inventory.warehouses',
aliases: {'masters.warehouses'},
routePath: inventoryWarehousesRoutePath,
defaultLabel: '입고지 관리',
icon: lucide.LucideIcons.warehouse,
builder: (context, state) => WarehousePage(routeUri: state.uri),
defaultOrder: 32,
),
MenuRouteDefinition(
menuCode: 'inventory.customers',
aliases: {'masters.customers'},
routePath: inventoryCustomersRoutePath,
defaultLabel: '회사 관리',
icon: lucide.LucideIcons.building,
builder: (context, state) => CustomerPage(routeUri: state.uri),
defaultOrder: 33,
),
MenuRouteDefinition(
menuCode: 'settings.users',
aliases: {'masters.users'},
routePath: settingsUsersRoutePath,
defaultLabel: '사용자 관리',
icon: lucide.LucideIcons.users,
builder: (context, state) => const UserPage(),
defaultOrder: 40,
),
MenuRouteDefinition(
menuCode: 'settings.groups',
aliases: {'masters.groups'},
routePath: settingsGroupsRoutePath,
defaultLabel: '그룹 관리',
icon: lucide.LucideIcons.layers,
builder: (context, state) => const GroupPage(),
defaultOrder: 41,
),
MenuRouteDefinition(
menuCode: 'settings.menus',
aliases: {'masters.menus'},
routePath: settingsMenusRoutePath,
defaultLabel: '메뉴 관리',
icon: lucide.LucideIcons.listTree,
builder: (context, state) => const MenuPage(),
defaultOrder: 42,
),
MenuRouteDefinition(
menuCode: 'settings.group_permissions',
aliases: {'settings.group-permissions', 'masters.group-permissions'},
routePath: settingsGroupPermissionsRoutePath,
defaultLabel: '그룹 메뉴 권한',
icon: lucide.LucideIcons.shieldCheck,
builder: (context, state) => const GroupPermissionPage(),
defaultOrder: 43,
),
MenuRouteDefinition(
menuCode: 'approvals.requests',
aliases: {'approvals'},
routePath: approvalsRequestsRoutePath,
defaultLabel: '결재 관리',
icon: lucide.LucideIcons.fileCheck,
builder: (context, state) => ApprovalRequestPage(routeUri: state.uri),
defaultOrder: 50,
),
MenuRouteDefinition(
menuCode: 'approvals.steps',
routePath: approvalsStepsRoutePath,
defaultLabel: '결재 단계',
icon: lucide.LucideIcons.workflow,
builder: (context, state) => const ApprovalStepPage(),
defaultOrder: 51,
),
MenuRouteDefinition(
menuCode: 'approvals.history',
routePath: approvalsHistoryRoutePath,
defaultLabel: '결재 이력',
icon: lucide.LucideIcons.history,
builder: (context, state) => const ApprovalHistoryPage(),
defaultOrder: 52,
),
MenuRouteDefinition(
menuCode: 'approvals.templates',
routePath: approvalsTemplatesRoutePath,
defaultLabel: '결재 템플릿',
icon: lucide.LucideIcons.fileSpreadsheet,
builder: (context, state) => const ApprovalTemplatePage(),
defaultOrder: 53,
),
MenuRouteDefinition(
menuCode: 'utilities.zipcodes',
aliases: {'utilities.postal-search'},
routePath: utilitiesPostalSearchRoutePath,
defaultLabel: '우편번호 검색',
icon: lucide.LucideIcons.search,
builder: (context, state) => const PostalSearchPage(),
defaultOrder: 60,
),
MenuRouteDefinition(
menuCode: 'reports.overview',
aliases: {'reports'},
routePath: reportsOverviewRoutePath,
defaultLabel: '보고서',
icon: lucide.LucideIcons.fileDown,
builder: (context, state) => const ReportingPage(),
defaultOrder: 70,
),
];
/// menu_code → 정의를 빠르게 조회하기 위한 맵.
final Map<String, MenuRouteDefinition> menuRouteDefinitionByCode = {
for (final definition in menuRouteDefinitions)
for (final code in definition.codes) code: definition,
};

View File

@@ -0,0 +1,24 @@
/// 라우트 경로 상수를 한 곳에서 관리한다.
///
/// - 메뉴/권한/딥링크에서 동일한 경로 문자열을 참조해 불일치를 줄인다.
/// - 로그인/대시보드 등 빈번히 참조되는 경로는 별도의 상수로 노출한다.
const loginRoutePath = '/login';
const dashboardRoutePath = '/dashboard';
const inventorySummaryRoutePath = '/inventory/summary';
const inventoryReceiptsRoutePath = '/inventory/receipts';
const inventoryIssuesRoutePath = '/inventory/issues';
const inventoryRentalsRoutePath = '/inventory/rentals';
const inventoryVendorsRoutePath = '/inventory/vendors';
const inventoryProductsRoutePath = '/inventory/products';
const inventoryWarehousesRoutePath = '/inventory/warehouses';
const inventoryCustomersRoutePath = '/inventory/customers';
const settingsUsersRoutePath = '/settings/users';
const settingsGroupsRoutePath = '/settings/groups';
const settingsMenusRoutePath = '/settings/menus';
const settingsGroupPermissionsRoutePath = '/settings/group-permissions';
const approvalsRequestsRoutePath = '/approvals/requests';
const approvalsStepsRoutePath = '/approvals/steps';
const approvalsHistoryRoutePath = '/approvals/history';
const approvalsTemplatesRoutePath = '/approvals/templates';
const utilitiesPostalSearchRoutePath = '/utilities/zipcodes';
const reportsOverviewRoutePath = '/reports';

View File

@@ -1,3 +1,5 @@
import 'dart:collection';
import 'package:dio/dio.dart';
import 'api_error.dart';
@@ -12,6 +14,117 @@ class ApiClient {
final Dio _dio;
final ApiErrorMapper _errorMapper;
static const _dataKey = 'data';
/// 경로 세그먼트를 조합해 일관된 요청 경로를 생성한다.
///
/// - 각 세그먼트의 선행/후행 `/`를 제거한 뒤 단일 슬래시로 결합한다.
/// - 첫 번째 세그먼트가 `/`로 시작하면 결과 역시 `/`를 유지한다.
static String buildPath(
Object base, [
Iterable<Object?> segments = const [],
]) {
final joined = <String>[];
var leadingSlash = false;
void append(Object? value, {bool isFirst = false}) {
if (value == null) {
return;
}
var text = value.toString();
if (text.isEmpty) {
return;
}
if (isFirst && text.startsWith('/')) {
leadingSlash = true;
}
text = text.replaceAll(RegExp(r'^/+'), '').replaceAll(RegExp(r'/+$'), '');
if (text.isEmpty) {
return;
}
joined.add(text);
}
append(base, isFirst: true);
for (final segment in segments) {
append(segment);
}
if (joined.isEmpty) {
return leadingSlash ? '/' : '';
}
final path = joined.join('/');
return leadingSlash ? '/$path' : path;
}
/// 페이지네이션/검색/필터 파라미터를 표준 규칙에 맞춰 구성한다.
///
/// - 문자열은 trim 후 빈 값이면 제외한다.
/// - `include`는 중복 제거 후 콤마로 연결한다.
/// - `DateTime`은 UTC ISO8601 문자열로 직렬화한다.
/// - [filters]가 동일 키를 포함하면 앞선 설정을 덮어쓴다.
static Map<String, dynamic> buildQuery({
int? page,
int? pageSize,
String? q,
String? sort,
String? order,
Iterable<String>? include,
DateTime? updatedSince,
Map<String, dynamic>? filters,
}) {
final query = <String, dynamic>{};
void put(String key, dynamic value) {
if (value == null) {
return;
}
if (value is String) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
query[key] = trimmed;
return;
}
if (value is DateTime) {
query[key] = value.toUtc().toIso8601String();
return;
}
if (value is Iterable) {
final sanitized = <String>[];
for (final element in value) {
if (element == null) {
continue;
}
final text = element.toString().trim();
if (text.isEmpty) {
continue;
}
sanitized.add(text);
}
if (sanitized.isEmpty) {
return;
}
final unique = LinkedHashSet<String>.from(sanitized);
query[key] = unique.join(',');
return;
}
query[key] = value;
}
put('page', page);
put('page_size', pageSize);
put('q', q);
put('sort', sort);
put('order', order?.trim().toLowerCase());
put('include', include);
put('updated_since', updatedSince);
filters?.forEach(put);
return Map.unmodifiable(query);
}
/// 내부에서 사용하는 Dio 인스턴스
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
@@ -99,4 +212,37 @@ class ApiClient {
throw _errorMapper.map(error);
}
}
/// `{ "data": ... }` 형태의 응답에서 내부 데이터를 추출한다.
///
/// - `data` 키가 존재하면 해당 값을 반환한다.
/// - `data`가 맵 타입이 아니거나 null이면 원본 본문을 그대로 돌려준다.
/// - 최종적으로 맵이 아니면 빈 맵을 반환한다.
Map<String, dynamic> unwrapAsMap(Response<dynamic> response) {
final payload = _maybeUnwrap(response.data);
if (payload is Map<String, dynamic>) {
return payload;
}
final original = response.data;
if (original is Map<String, dynamic>) {
return original;
}
return const <String, dynamic>{};
}
/// 응답 본문에서 envelope을 제거해 반환한다.
///
/// - `{ "data": [...] }`는 내부 리스트를 돌려준다.
/// - `data` 구조가 아니면 원본을 그대로 반환한다.
dynamic unwrap(Response<dynamic> response) => _maybeUnwrap(response.data);
dynamic _maybeUnwrap(dynamic body) {
if (body is Map<String, dynamic> && body.containsKey(_dataKey)) {
final nested = body[_dataKey];
if (nested != null) {
return nested;
}
}
return body;
}
}

View File

@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
enum ApiErrorCode {
badRequest,
unauthorized,
forbidden,
notFound,
conflict,
unprocessableEntity,
@@ -43,6 +44,7 @@ class ApiErrorMapper {
final status = error.response?.statusCode;
final data = error.response?.data;
final message = _resolveMessage(error, data);
final details = _extractDetails(data);
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
@@ -75,7 +77,6 @@ class ApiErrorMapper {
}
if (status != null) {
final details = _extractDetails(data);
switch (status) {
case 400:
return ApiException(
@@ -88,8 +89,17 @@ class ApiErrorMapper {
case 401:
return ApiException(
code: ApiErrorCode.unauthorized,
message: '세션이 만료되었습니다. 다시 로그인해 주세요.',
message: _localizeUnauthorizedMessage(message),
statusCode: status,
details: details,
cause: error,
);
case 403:
return ApiException(
code: ApiErrorCode.forbidden,
message: message,
statusCode: status,
details: details,
cause: error,
);
case 404:
@@ -97,6 +107,7 @@ class ApiErrorMapper {
code: ApiErrorCode.notFound,
message: '요청한 리소스를 찾을 수 없습니다.',
statusCode: status,
details: details,
cause: error,
);
case 409:
@@ -124,6 +135,7 @@ class ApiErrorMapper {
code: ApiErrorCode.unknown,
message: message,
statusCode: status,
details: details,
cause: error,
);
}
@@ -131,24 +143,217 @@ class ApiErrorMapper {
/// 응답 바디 혹은 Dio 예외 객체에서 사용자용 메시지를 추출한다.
String _resolveMessage(DioException error, dynamic data) {
if (data is Map<String, dynamic>) {
final message = data['message'] ?? data['error'];
if (message is String && message.isNotEmpty) {
return message;
final candidates = [
data['message'],
data['error_message'],
data['errorMessage'],
data['detail'],
];
for (final candidate in candidates) {
if (candidate is String && candidate.trim().isNotEmpty) {
return candidate.trim();
}
} else if (data is String && data.isNotEmpty) {
return data;
}
return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
final errorNode = data['error'];
if (errorNode is String && errorNode.trim().isNotEmpty) {
return errorNode.trim();
}
if (errorNode is Map<String, dynamic>) {
final nested = [
errorNode['message'],
errorNode['title'],
errorNode['detail'],
errorNode['error'],
];
for (final candidate in nested) {
if (candidate is String && candidate.trim().isNotEmpty) {
return candidate.trim();
}
}
}
final messagesNode = data['messages'];
if (messagesNode is Iterable) {
final flattened = _flattenMessages(messagesNode);
if (flattened != null) {
return flattened;
}
}
final errorsNode = data['errors'];
final flattenedErrors = _flattenMessages(errorsNode);
if (flattenedErrors != null) {
return flattenedErrors;
}
} else if (data is String && data.trim().isNotEmpty) {
return data.trim();
}
return error.message?.trim() ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
}
/// 422/409 등에서 제공되는 필드별 오류 정보를 추출한다.
Map<String, dynamic>? _extractDetails(dynamic data) {
if (data is Map<String, dynamic>) {
final errors = data['errors'];
if (errors is Map<String, dynamic>) {
return errors;
}
}
if (data is! Map) {
return null;
}
Map<String, dynamic>? result;
void merge(dynamic node) {
if (node == null) {
return;
}
if (node is Map) {
if (node.isEmpty) {
return;
}
result ??= <String, dynamic>{};
node.forEach((rawKey, rawValue) {
final key = rawKey?.toString() ?? '';
if (key.isEmpty) {
return;
}
final values = _asList(rawValue);
if (values.isEmpty) {
return;
}
final existing = result![key];
if (existing == null) {
result![key] = values.length == 1 ? values.first : values;
return;
}
final merged = _asList(existing)..addAll(values);
result![key] = merged.length == 1 ? merged.first : merged;
});
return;
}
if (node is Iterable) {
final mapped = _mapFromErrors(node);
if (mapped != null) {
merge(mapped);
}
return;
}
final mapped = _mapFromErrors([node]);
if (mapped != null) {
merge(mapped);
}
}
final map = data.cast<dynamic, dynamic>();
merge(map['errors']);
merge(map['details']);
merge(map['context']);
final errorNode = map['error'];
if (errorNode is Map) {
merge(errorNode['errors']);
merge(errorNode['details']);
merge(errorNode['context']);
}
return result;
}
String? _flattenMessages(dynamic source) {
if (source == null) {
return null;
}
if (source is String) {
final trimmed = source.trim();
return trimmed.isEmpty ? null : trimmed;
}
if (source is Iterable) {
final buffer = <String>[];
for (final item in source) {
final value = _flattenMessages(item);
if (value != null && value.isNotEmpty) {
buffer.add(value);
}
}
if (buffer.isEmpty) {
return null;
}
return buffer.join('\n');
}
if (source is Map) {
final message =
source['message'] ??
source['detail'] ??
source['error'] ??
source['description'];
final flattened = _flattenMessages(message);
if (flattened != null) {
return flattened;
}
return _flattenMessages(source.values);
}
if (source is num || source is bool) {
return source.toString();
}
return source.toString();
}
Map<String, dynamic>? _mapFromErrors(Iterable<dynamic> errors) {
final result = <String, dynamic>{};
for (final entry in errors) {
if (entry is Map) {
final field = entry['field'] ?? entry['name'] ?? entry['key'];
final messages =
entry['messages'] ??
entry['message'] ??
entry['detail'] ??
entry['error'];
if (field is String) {
final bucket = result[field];
final nextMessages = _asList(messages);
if (bucket is List) {
bucket.addAll(nextMessages);
} else if (bucket != null) {
result[field] = _asList(bucket)..addAll(nextMessages);
} else if (nextMessages.length == 1) {
result[field] = nextMessages.first;
} else {
result[field] = nextMessages;
}
continue;
}
}
final list =
result.putIfAbsent('general', () => <dynamic>[]) as List<dynamic>;
list.add(entry);
}
return result.isEmpty ? null : result;
}
List<dynamic> _asList(dynamic value) {
if (value == null) {
return <dynamic>[];
}
if (value is List) {
return value;
}
if (value is Iterable) {
return value.toList();
}
return <dynamic>[value];
}
/// 인증 실패 응답 메시지를 한글 안내로 정규화한다.
String _localizeUnauthorizedMessage(String message) {
final trimmed = message.trim();
if (trimmed.isEmpty) {
return '세션이 만료되었습니다. 다시 로그인해 주세요.';
}
final normalized = trimmed.toLowerCase();
switch (normalized) {
case 'invalid credentials':
return '아이디 또는 비밀번호가 올바르지 않습니다.';
case 'account is inactive':
return '비활성 계정입니다. 관리자에게 문의하세요.';
case 'token expired':
return '세션이 만료되었습니다. 다시 로그인해 주세요.';
case 'invalid token':
return '유효하지 않은 토큰입니다. 다시 로그인해 주세요.';
default:
return trimmed;
}
}
}

View File

@@ -0,0 +1,37 @@
import 'api_client.dart';
/// API 경로 상수 모음
/// - 버전 prefix 등을 중앙에서 관리해 중복을 방지한다.
class ApiRoutes {
const ApiRoutes._();
/// API v1 prefix
static const apiV1 = '/api/v1';
/// 결재(Approval) 관련 엔드포인트
static const approvals = '$apiV1/approvals';
static const approvalRoot = '$apiV1/approval';
static const approvalSteps = '$apiV1/approval-steps';
static const approvalHistory = '$apiV1/approval/history';
static const approvalActions = '$apiV1/approval-actions';
static const approvalDrafts = '$apiV1/approval-drafts';
static const approvalTemplates = '$apiV1/approval/templates';
static const approvalTemplatesLegacy = '$apiV1/approval-templates';
/// 결재 행위 전용 경로(`/approval/{action}`)를 반환한다.
static String approvalAction(String action) {
final sanitized = action
.trim()
.replaceAll(RegExp(r'^/+'), '')
.replaceAll(RegExp(r'/+$'), '');
return '$approvalRoot/$sanitized';
}
/// 재고 현황 요약 목록 경로.
static const inventorySummary = '$apiV1/inventory/summary';
/// 재고 현황 단건 경로를 조합한다.
static String inventorySummaryDetail(Object productId) {
return ApiClient.buildPath(inventorySummary, [productId]);
}
}

View File

@@ -0,0 +1,448 @@
import 'package:dio/dio.dart';
import 'api_error.dart';
/// 서버/네트워크 예외를 사용자 메시지와 필드 오류 형태로 정규화한 객체.
class Failure {
const Failure({
required this.message,
this.code,
this.statusCode,
this.reasons = const [],
this.fieldErrors = const {},
this.raw,
});
/// 사용자에게 우선 노출할 기본 메시지.
final String message;
/// [ApiErrorCode] 또는 유사 코드.
final ApiErrorCode? code;
/// HTTP 상태 코드(존재하는 경우).
final int? statusCode;
/// 추가 설명 메시지 목록.
final List<String> reasons;
/// 필드별 오류 메시지 묶음.
final Map<String, List<String>> fieldErrors;
/// 원본 예외 객체.
final Object? raw;
/// 필드 오류가 존재하는지 여부.
bool get hasFieldErrors => fieldErrors.isNotEmpty;
/// 추가 설명이 존재하는지 여부.
bool get hasReasons => reasons.isNotEmpty;
/// 필드명에 해당하는 첫 번째 오류 메시지를 반환한다.
String? firstFieldError(String field) {
final messages = fieldErrors[field];
if (messages == null) {
return null;
}
for (final message in messages) {
final trimmed = message.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
}
return null;
}
/// 필드 오류를 줄바꿈으로 합친 문자열 맵을 반환한다.
Map<String, String> get fieldErrorMessages {
final result = <String, String>{};
fieldErrors.forEach((key, value) {
final messages = _dedupeStrings(value);
if (messages.isEmpty) {
return;
}
result[key] = messages.join('\n');
});
return result;
}
/// 기본 메시지와 추가 설명 및 필드 오류를 단일 문자열로 합친다.
String describe({String separator = '\n', bool includeFieldErrors = true}) {
final lines = <String>[];
final base = message.trim();
if (base.isNotEmpty) {
lines.add(base);
}
for (final reason in reasons) {
final trimmed = reason.trim();
if (trimmed.isEmpty) {
continue;
}
final duplicate = lines.any(
(existing) => existing.toLowerCase() == trimmed.toLowerCase(),
);
if (!duplicate) {
lines.add(trimmed);
}
}
if (includeFieldErrors && fieldErrors.isNotEmpty) {
fieldErrors.forEach((key, value) {
final first = value.firstWhere(
(message) => message.trim().isNotEmpty,
orElse: () => '',
);
final trimmed = first.trim();
if (trimmed.isEmpty) {
return;
}
final line = '$key: $trimmed';
final duplicate = lines.any(
(existing) => existing.toLowerCase() == line.toLowerCase(),
);
if (!duplicate) {
lines.add(line);
}
});
}
return lines.join(separator).trim();
}
/// 임의 객체에서 [Failure]를 생성한다.
static Failure from(Object error) => const FailureParser().parse(error);
}
/// [Failure] 변환을 담당하는 파서.
class FailureParser {
const FailureParser();
Failure parse(Object error) {
if (error is Failure) {
return error;
}
if (error is ApiException) {
return _fromApiException(error);
}
if (error is DioException) {
final mapped = const ApiErrorMapper().map(error);
return _fromApiException(mapped);
}
if (error is Exception || error is Error) {
final message = _exceptionMessage(error);
return Failure(message: message, raw: error);
}
final fallback = error.toString().trim();
return Failure(
message: fallback.isEmpty ? '요청 처리 중 오류가 발생했습니다.' : fallback,
raw: error,
);
}
Failure _fromApiException(ApiException exception) {
final payload = _FailurePayload();
payload.consumeDetails(exception.details);
payload.consumeResponseData(exception.cause?.response?.data);
final message =
_firstNonEmpty([payload.message, exception.message]) ??
'요청 처리 중 오류가 발생했습니다.';
final reasons = payload.reasons
.where(
(reason) =>
reason.trim().toLowerCase() != message.trim().toLowerCase(),
)
.toList(growable: false);
return Failure(
message: message,
code: exception.code,
statusCode: exception.statusCode,
reasons: reasons,
fieldErrors: payload.fieldErrors.map(
(key, value) => MapEntry(key, _dedupeStrings(value)),
),
raw: exception,
);
}
String _exceptionMessage(Object error) {
final raw = error.toString();
final trimmed = raw.trim();
if (trimmed.startsWith('Exception: ')) {
final stripped = trimmed.substring('Exception: '.length).trim();
if (stripped.isNotEmpty) {
return stripped;
}
}
if (trimmed.startsWith('Error: ')) {
final stripped = trimmed.substring('Error: '.length).trim();
if (stripped.isNotEmpty) {
return stripped;
}
}
return trimmed.isEmpty ? '요청 처리 중 오류가 발생했습니다.' : trimmed;
}
}
class _FailurePayload {
String? message;
final List<String> reasons = [];
final Map<String, List<String>> fieldErrors = {};
void consumeDetails(Map<String, dynamic>? details) {
if (details == null || details.isEmpty) {
return;
}
details.forEach((rawKey, rawValue) {
final key = rawKey.toString();
final values = _flattenMessages(rawValue);
if (values.isEmpty) {
return;
}
if (_isGeneralKey(key)) {
reasons.addAll(values);
} else {
fieldErrors.putIfAbsent(key, () => <String>[]).addAll(values);
}
});
}
void consumeResponseData(dynamic data) {
if (data == null) {
return;
}
if (data is Map<String, dynamic>) {
_consumeMap(data);
return;
}
if (data is Iterable) {
reasons.addAll(_flattenIterable(data));
return;
}
final text = _toMessage(data);
if (text != null) {
reasons.add(text);
}
}
void _consumeMap(Map<String, dynamic> map) {
final messageCandidate = _firstNonEmpty([
_toMessage(map['message']),
_toMessage(map['error_message']),
_toMessage(map['errorMessage']),
_toMessage(map['detail']),
]);
if (messageCandidate != null) {
message = messageCandidate;
}
_consumeErrorsNode(map['errors']);
_consumeErrorsNode(map['details']);
_consumeErrorsNode(map['reason']);
_consumeErrorsNode(map['reasons']);
_consumeErrorsNode(map['context']);
final errorNode = map['error'];
if (errorNode is Map<String, dynamic>) {
final nodeMessage = _firstNonEmpty([
_toMessage(errorNode['message']),
_toMessage(errorNode['title']),
_toMessage(errorNode['detail']),
]);
message ??= nodeMessage;
_consumeErrorsNode(errorNode['errors']);
_consumeErrorsNode(errorNode['details']);
_consumeErrorsNode(errorNode['reason']);
_consumeErrorsNode(errorNode['reasons']);
_consumeErrorsNode(errorNode['context']);
final extra = _flattenIterable(_ensureIterable(errorNode['messages']));
reasons.addAll(extra);
} else if (errorNode != null) {
final text = _toMessage(errorNode);
if (text != null) {
reasons.add(text);
}
}
final messagesNode = map['messages'];
reasons.addAll(_flattenIterable(_ensureIterable(messagesNode)));
}
void _consumeErrorsNode(dynamic node) {
if (node == null) {
return;
}
if (node is Map<String, dynamic>) {
node.forEach((rawKey, rawValue) {
final key = rawKey.toString();
final values = _flattenMessages(rawValue);
if (values.isEmpty) {
return;
}
if (_isGeneralKey(key)) {
reasons.addAll(values);
} else {
fieldErrors.putIfAbsent(key, () => <String>[]).addAll(values);
}
});
return;
}
if (node is Iterable) {
for (final item in node) {
if (item is Map<String, dynamic>) {
final field = _firstNonEmpty([
_toMessage(item['field']),
_toMessage(item['name']),
_toMessage(item['key']),
]);
final messages = _flattenMessages(
item['messages'] ??
item['message'] ??
item['detail'] ??
item['error'],
);
if (field != null && field.trim().isNotEmpty) {
if (messages.isEmpty) {
final fallback = _flattenMessages(item);
fieldErrors.putIfAbsent(field, () => <String>[]).addAll(fallback);
} else {
fieldErrors.putIfAbsent(field, () => <String>[]).addAll(messages);
}
} else if (messages.isNotEmpty) {
reasons.addAll(messages);
} else {
reasons.addAll(_flattenMessages(item.values));
}
} else {
reasons.addAll(_flattenMessages(item));
}
}
return;
}
final values = _flattenMessages(node);
if (values.isNotEmpty) {
reasons.addAll(values);
}
}
}
const Set<String> _generalErrorKeys = {
'general',
'base',
'common',
'global',
'_global',
'_common',
'_general',
'summary',
'message',
'messages',
'detail',
'details',
'reason',
'reasons',
'non_field_errors',
'nonfielderrors',
};
bool _isGeneralKey(String key) {
final normalized = key.trim().toLowerCase();
return _generalErrorKeys.contains(normalized);
}
Iterable<dynamic> _ensureIterable(dynamic value) {
if (value is Iterable) {
return value;
}
if (value == null) {
return const [];
}
return [value];
}
String? _firstNonEmpty(Iterable<String?> candidates) {
for (final candidate in candidates) {
final text = candidate?.trim();
if (text != null && text.isNotEmpty) {
return text;
}
}
return null;
}
String? _toMessage(dynamic value) {
if (value == null) {
return null;
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
if (value is num || value is bool) {
return value.toString();
}
return null;
}
List<String> _flattenIterable(Iterable<dynamic> values) {
final aggregated = <String>[];
for (final value in values) {
aggregated.addAll(_flattenMessages(value));
}
return aggregated;
}
List<String> _flattenMessages(dynamic value) {
if (value == null) {
return const [];
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? const [] : [trimmed];
}
if (value is num || value is bool) {
return [value.toString()];
}
if (value is Iterable) {
return _flattenIterable(value);
}
if (value is Map) {
final map = value.cast<dynamic, dynamic>();
final candidates = <String>[];
final directMessage = _firstNonEmpty([
_toMessage(map['message']),
_toMessage(map['detail']),
_toMessage(map['error']),
_toMessage(map['description']),
_toMessage(map['reason']),
_toMessage(map['title']),
]);
if (directMessage != null) {
candidates.add(directMessage);
}
final messagesNode = map['messages'];
if (messagesNode is Iterable) {
candidates.addAll(_flattenIterable(messagesNode));
}
if (candidates.isNotEmpty) {
return candidates;
}
return _flattenIterable(map.values);
}
return [_toMessage(value) ?? value.toString()];
}
List<String> _dedupeStrings(Iterable<String> values) {
final result = <String>[];
final seen = <String>{};
for (final value in values) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
continue;
}
final normalized = trimmed.toLowerCase();
if (seen.add(normalized)) {
result.add(trimmed);
}
}
return result;
}

View File

@@ -0,0 +1,124 @@
import 'package:superport_v2/core/navigation/menu_catalog.dart';
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,
MenuCatalog? menuCatalog,
}) : _manager = manager,
_groupRepository = groupRepository,
_groupPermissionRepository = groupPermissionRepository,
_menuCatalog = menuCatalog;
final PermissionManager _manager;
final GroupRepository _groupRepository;
final GroupPermissionRepository _groupPermissionRepository;
final MenuCatalog? _menuCatalog;
/// 세션의 권한 목록과 그룹 권한을 적용한다.
Future<void> apply(AuthSession session) async {
_manager.clearServerPermissions();
final aggregated = <String, Set<PermissionAction>>{};
var hasMenuPermission = false;
void merge(Map<String, Set<PermissionAction>> map) {
if (map.isEmpty) {
return;
}
for (final entry in map.entries) {
final target = aggregated.putIfAbsent(
entry.key,
() => <PermissionAction>{},
);
target.addAll(entry.value);
if (!entry.key.startsWith('scope:')) {
hasMenuPermission = true;
}
}
}
for (final permission in session.permissions) {
merge(permission.toPermissionMap());
}
if (!hasMenuPermission) {
final map = await _loadGroupPermissions(
groupId: session.user.primaryGroupId,
);
merge(map);
}
if (aggregated.isNotEmpty) {
_manager.applyServerPermissions(aggregated);
return;
}
await _synchronizePermissions(groupId: session.user.primaryGroupId);
}
Future<void> _synchronizePermissions({int? groupId}) async {
final targetGroupId = await _resolveGroupId(groupId);
if (targetGroupId == null) {
return;
}
final synchronizer = PermissionSynchronizer(
repository: _groupPermissionRepository,
manager: _manager,
menuCatalog: _menuCatalog,
);
await synchronizer.syncForGroup(targetGroupId);
}
Future<Map<String, Set<PermissionAction>>> _loadGroupPermissions({
int? groupId,
}) async {
final targetGroupId = await _resolveGroupId(groupId);
if (targetGroupId == null) {
return const {};
}
final synchronizer = PermissionSynchronizer(
repository: _groupPermissionRepository,
manager: _manager,
menuCatalog: _menuCatalog,
);
return synchronizer.fetchPermissionMap(targetGroupId);
}
Future<int?> _resolveGroupId(int? groupId) async {
if (groupId != null) {
return groupId;
}
final defaultGroups = await _groupRepository.list(
page: 1,
pageSize: 1,
isDefault: true,
);
var targetGroup = _firstGroupWithId(defaultGroups.items);
if (targetGroup == null) {
final fallbackGroups = await _groupRepository.list(page: 1, pageSize: 1);
targetGroup = _firstGroupWithId(fallbackGroups.items);
}
return targetGroup?.id;
}
Group? _firstGroupWithId(List<Group> groups) {
for (final group in groups) {
if (group.id != null) {
return group;
}
}
return null;
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import '../config/environment.dart';
import 'permission_resources.dart';
/// 권한 체크를 위한 액션 종류.
enum PermissionAction { view, create, edit, delete, restore, approve }
@@ -9,16 +10,20 @@ enum PermissionAction { view, create, edit, delete, restore, approve }
class PermissionManager extends ChangeNotifier {
PermissionManager({Map<String, Set<PermissionAction>>? overrides}) {
if (overrides != null) {
_overrides.addAll(overrides);
updateOverrides(overrides);
}
}
/// 리소스별 임시 권한 집합을 보관한다.
final Map<String, Set<PermissionAction>> _overrides = {};
/// 서버에서 내려받은 실제 권한 집합.
final Map<String, Set<PermissionAction>> _serverPermissions = {};
/// 지정한 리소스/행동이 허용되는지 여부를 반환한다.
bool can(String resource, PermissionAction action) {
final override = _overrides[resource];
final key = _normalize(resource);
final override = _overrides[key];
if (override != null) {
// View 권한은 최소 접근을 허용하기 위해 별도로 처리한다.
if (override.contains(PermissionAction.view) &&
@@ -27,16 +32,60 @@ class PermissionManager extends ChangeNotifier {
}
return override.contains(action);
}
return Environment.hasPermission(resource, action.name);
final server = _serverPermissions[key];
if (server != null) {
if (action == PermissionAction.view) {
return server.contains(PermissionAction.view);
}
return server.contains(action);
}
if (key.startsWith('scope:')) {
return false;
}
// 서버/오버라이드 권한이 없으면 기본적으로 거부하고,
// .env에 명시된 PERMISSION__ 항목만 허용한다.
final fallbackAllowed = Environment.hasPermission(key, action.name);
if (!fallbackAllowed) {
return false;
}
return true;
}
/// 개발/테스트 환경에서 사용할 임시 오버라이드 값을 설정한다.
void updateOverrides(Map<String, Set<PermissionAction>> overrides) {
_overrides
..clear()
..addAll(overrides);
..addAll(
overrides.map((key, value) => MapEntry(_normalize(key), value.toSet())),
);
notifyListeners();
}
/// 서버에서 내려온 권한 정보를 적용한다.
void applyServerPermissions(Map<String, Set<PermissionAction>> permissions) {
_serverPermissions
..clear()
..addAll(
permissions.map(
(key, value) => MapEntry(_normalize(key), value.toSet()),
),
);
notifyListeners();
}
/// 서버 권한 정보를 초기화한다.
void clearServerPermissions() {
if (_serverPermissions.isEmpty) {
return;
}
_serverPermissions.clear();
notifyListeners();
}
String _normalize(String resource) => PermissionResources.normalize(resource);
}
/// 위젯 트리에 [PermissionManager]를 전달하는 Inherited 위젯.

View File

@@ -0,0 +1,138 @@
/// 권한 검사에 사용하는 리소스 경로 상수를 정의하고 정규화 유틸을 제공한다.
///
/// - UI 라우트 경로(`/inventory/inbound` 등)를 서버 표준 경로(`/stock-transactions`)
/// 로 변환해 [PermissionManager]와 환경 설정이 동일한 키를 사용하도록 맞춘다.
class PermissionResources {
const PermissionResources._();
static const String dashboard = '/dashboard';
static const String stockTransactions = '/stock-transactions';
static const String approvals = '/approvals';
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';
static const String warehouses = '/warehouses';
static const String customers = '/customers';
static const String users = '/users';
static const String groups = '/groups';
static const String menus = '/menus';
static const String postalSearch = '/zipcodes';
static const String reports = '/reports';
static const String reportsTransactions = '/reports/transactions';
static const String reportsApprovals = '/reports/approvals';
/// 라우트/엔드포인트 별칭을 표준 경로로 매핑한 테이블.
static const Map<String, String> _aliases = {
'/dashboard': dashboard,
'/inventory': stockTransactions,
'/inventory/inbound': stockTransactions,
'/inventory/outbound': stockTransactions,
'/inventory/rental': stockTransactions,
'/inventory/rentals': stockTransactions,
'/inventory/receipts': stockTransactions,
'/inventory/issues': stockTransactions,
'/approvals/requests': approvals,
'/approvals': approvals,
'/approvals/steps': approvalSteps,
'/approval-steps': approvalSteps,
'/approvals/history': approvalHistories,
'/approvals/histories': approvalHistories,
'/approval-histories': approvalHistories,
'/approvals/templates': approvalTemplates,
'/approval/templates': approvalTemplates,
'/approval-templates': approvalTemplates,
'/inventory/summary': inventorySummary,
'/masters/group-permissions': groupMenuPermissions,
'/settings/group-permissions': groupMenuPermissions,
'/group-menu-permissions': groupMenuPermissions,
'/masters/vendors': vendors,
'/inventory/vendors': vendors,
'/inventory/manufacturers': vendors,
'/vendors': vendors,
'/masters/products': products,
'/inventory/products': products,
'/inventory/models': products,
'/products': products,
'/masters/warehouses': warehouses,
'/inventory/warehouses': warehouses,
'/warehouses': warehouses,
'/masters/customers': customers,
'/inventory/customers': customers,
'/customers': customers,
'/masters/users': users,
'/settings/users': users,
'/users': users,
'/masters/groups': groups,
'/settings/groups': groups,
'/groups': groups,
'/masters/menus': menus,
'/settings/menus': menus,
'/menus': menus,
'/utilities/postal-search': postalSearch,
'/utilities/zipcodes': postalSearch,
'/zipcodes': postalSearch,
'/reports': reports,
'/reports/transactions': reportsTransactions,
'/reports/transactions/export': reportsTransactions,
'/reports/approvals': reportsApprovals,
'/reports/approvals/export': reportsApprovals,
};
/// 주어진 [resource] 문자열을 서버 표준 경로로 정규화한다.
///
/// - 앞뒤 공백 및 대소문자를 정리한다.
/// - 맵에 등록된 라우트/별칭을 모두 표준 경로로 통일한다.
static String normalize(String resource) {
final sanitized = _sanitize(resource);
if (sanitized.isEmpty) {
return sanitized;
}
return _aliases[sanitized] ?? sanitized;
}
static String _sanitize(String resource) {
final trimmed = resource.trim();
if (trimmed.isEmpty) {
return '';
}
final lowered = trimmed.toLowerCase();
if (lowered.startsWith('scope:')) {
return lowered;
}
var normalized = lowered;
// 절대 URL이 들어오면 path 부분만 추출한다.
final uri = Uri.tryParse(normalized);
if (uri != null && uri.hasScheme) {
normalized = uri.path;
}
// 쿼리스트링이나 프래그먼트를 제거해 순수 경로만 남긴다.
final queryIndex = normalized.indexOf('?');
if (queryIndex != -1) {
normalized = normalized.substring(0, queryIndex);
}
final hashIndex = normalized.indexOf('#');
if (hashIndex != -1) {
normalized = normalized.substring(0, hashIndex);
}
if (!normalized.startsWith('/')) {
normalized = '/$normalized';
}
while (normalized.contains('//')) {
normalized = normalized.replaceAll('//', '/');
}
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
}

View File

@@ -0,0 +1,97 @@
import 'permission_manager.dart';
import 'permission_resources.dart';
/// 서버가 내려주는 scope 권한 코드를 실사용 리소스 권한으로 변환한다.
class PermissionScopeMapper {
const PermissionScopeMapper._();
/// scope:<code> 형식의 권한에서 [PermissionManager]가 이해할 수 있는 리소스 맵을 생성한다.
static Map<String, Set<PermissionAction>>? map(String scope) {
final code = _normalize(scope);
if (code.isEmpty) {
return null;
}
final definition = _definitions[code];
if (definition == null || definition.isEmpty) {
return null;
}
final mapped = <String, Set<PermissionAction>>{};
for (final entry in definition.entries) {
mapped[entry.key] = entry.value.toSet();
}
return mapped;
}
static String _normalize(String value) {
final trimmed = value.trim().toLowerCase();
if (trimmed.isEmpty) {
return '';
}
if (trimmed.startsWith('scope:')) {
return trimmed.substring('scope:'.length);
}
return trimmed;
}
static const Map<String, Map<String, Set<PermissionAction>>> _definitions = {
'approval.approve': {
PermissionResources.approvals: {PermissionAction.approve},
},
'approvals': {
PermissionResources.approvals: {PermissionAction.view},
},
'approvals.history': {
PermissionResources.approvalHistories: {PermissionAction.view},
},
'approvals.steps': {
PermissionResources.approvalSteps: {PermissionAction.view},
},
'approvals.templates': {
PermissionResources.approvalTemplates: {PermissionAction.view},
},
'dashboard': {
PermissionResources.dashboard: {PermissionAction.view},
},
'dashboard.view': {
PermissionResources.dashboard: {PermissionAction.view},
},
'approval.view_all': {
PermissionResources.approvals: {PermissionAction.view},
PermissionResources.approvalSteps: {PermissionAction.view},
PermissionResources.approvalHistories: {PermissionAction.view},
},
'approval.manage': {
PermissionResources.approvals: {
PermissionAction.view,
PermissionAction.create,
PermissionAction.edit,
PermissionAction.delete,
},
PermissionResources.approvalSteps: {
PermissionAction.view,
PermissionAction.create,
PermissionAction.edit,
PermissionAction.delete,
},
PermissionResources.approvalHistories: {PermissionAction.view},
PermissionResources.approvalTemplates: {
PermissionAction.view,
PermissionAction.create,
PermissionAction.edit,
PermissionAction.delete,
},
},
'inventory.view': {
PermissionResources.inventorySummary: {PermissionAction.view},
},
'inventory.receipts': {
PermissionResources.stockTransactions: {PermissionAction.view},
},
'inventory.issues': {
PermissionResources.stockTransactions: {PermissionAction.view},
},
'inventory.rentals': {
PermissionResources.stockTransactions: {PermissionAction.view},
},
};
}

View File

@@ -1,27 +1,12 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import '../../features/approvals/history/presentation/pages/approval_history_page.dart';
import '../../features/approvals/request/presentation/pages/approval_request_page.dart';
import '../../features/approvals/step/presentation/pages/approval_step_page.dart';
import '../../features/approvals/template/presentation/pages/approval_template_page.dart';
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/auth/application/auth_service.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';
import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart';
import '../../features/masters/menu/presentation/pages/menu_page.dart';
import '../../features/masters/product/presentation/pages/product_page.dart';
import '../../features/masters/user/presentation/pages/user_page.dart';
import '../../features/masters/vendor/presentation/pages/vendor_page.dart';
import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart';
import '../../features/reporting/presentation/pages/reporting_page.dart';
import '../../features/util/postal_search/presentation/pages/postal_search_page.dart';
import '../../widgets/app_shell.dart';
import '../constants/app_sections.dart';
import '../navigation/menu_route_definitions.dart';
import '../navigation/route_paths.dart';
/// 전역 네비게이터 키(로그인/셸 라우터 공용).
final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
@@ -34,6 +19,18 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
final appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: loginRoutePath,
redirect: (context, state) {
final authService = GetIt.I<AuthService>();
final loggedIn = authService.session != null;
final loggingIn = state.uri.path == loginRoutePath;
if (!loggedIn && !loggingIn) {
return loginRoutePath;
}
if (loggedIn && loggingIn) {
return dashboardRoutePath;
}
return null;
},
routes: [
GoRoute(path: '/', redirect: (_, __) => loginRoutePath),
GoRoute(
@@ -45,95 +42,12 @@ final appRouter = GoRouter(
builder: (context, state, child) =>
AppShell(currentLocation: state.uri.toString(), child: child),
routes: [
for (final definition in menuRouteDefinitions)
GoRoute(
path: dashboardRoutePath,
name: 'dashboard',
builder: (context, state) => const DashboardPage(),
),
GoRoute(
path: '/inventory/inbound',
name: 'inventory-inbound',
builder: (context, state) => InboundPage(routeUri: state.uri),
),
GoRoute(
path: '/inventory/outbound',
name: 'inventory-outbound',
builder: (context, state) => OutboundPage(routeUri: state.uri),
),
GoRoute(
path: '/inventory/rental',
name: 'inventory-rental',
builder: (context, state) => RentalPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/vendors',
name: 'masters-vendors',
builder: (context, state) => VendorPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/products',
name: 'masters-products',
builder: (context, state) => ProductPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/warehouses',
name: 'masters-warehouses',
builder: (context, state) => WarehousePage(routeUri: state.uri),
),
GoRoute(
path: '/masters/customers',
name: 'masters-customers',
builder: (context, state) => CustomerPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/users',
name: 'masters-users',
builder: (context, state) => const UserPage(),
),
GoRoute(
path: '/masters/groups',
name: 'masters-groups',
builder: (context, state) => const GroupPage(),
),
GoRoute(
path: '/masters/menus',
name: 'masters-menus',
builder: (context, state) => const MenuPage(),
),
GoRoute(
path: '/masters/group-permissions',
name: 'masters-group-permissions',
builder: (context, state) => const GroupPermissionPage(),
),
GoRoute(
path: '/approvals/requests',
name: 'approvals-requests',
builder: (context, state) => const ApprovalRequestPage(),
),
GoRoute(
path: '/approvals/steps',
name: 'approvals-steps',
builder: (context, state) => const ApprovalStepPage(),
),
GoRoute(
path: '/approvals/history',
name: 'approvals-history',
builder: (context, state) => const ApprovalHistoryPage(),
),
GoRoute(
path: '/approvals/templates',
name: 'approvals-templates',
builder: (context, state) => const ApprovalTemplatePage(),
),
GoRoute(
path: '/utilities/postal-search',
name: 'utilities-postal-search',
builder: (context, state) => const PostalSearchPage(),
),
GoRoute(
path: '/reports',
name: 'reports',
builder: (context, state) => const ReportingPage(),
path: definition.routePath,
name: definition.menuCode,
redirect: definition.buildGuard(),
builder: definition.builder,
),
],
),

View File

@@ -0,0 +1,72 @@
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import '../permissions/permission_manager.dart';
typedef RouteGuard = String? Function(BuildContext, GoRouterState);
/// 라우트 접근 시 권한을 검사하는 가드.
class AuthGuard {
const AuthGuard._();
/// [resource]와 [action]에 대한 접근 권한을 확인한다.
static bool can(
String resource, {
PermissionAction action = PermissionAction.view,
}) {
if (!GetIt.I.isRegistered<PermissionManager>()) {
return false;
}
return GetIt.I<PermissionManager>().can(resource, action);
}
/// 권한이 없을 경우 [fallback] 경로로 리다이렉트하는 Guard를 생성한다.
static RouteGuard require({
required String resource,
PermissionAction action = PermissionAction.view,
required String fallback,
}) {
return requireAll(
requirements: [
PermissionRequirement(resource: resource, action: action),
],
fallback: fallback,
);
}
/// 여러 권한 요구사항을 모두 만족해야 통과시키는 가드를 생성한다.
static RouteGuard requireAll({
required Iterable<PermissionRequirement> requirements,
required String fallback,
}) {
final guards = requirements.toList(growable: false);
if (guards.isEmpty) {
return (context, state) => null;
}
return (context, state) {
if (!GetIt.I.isRegistered<PermissionManager>()) {
return null;
}
final manager = GetIt.I<PermissionManager>();
for (final requirement in guards) {
final allowed = manager.can(requirement.resource, requirement.action);
if (!allowed) {
return fallback;
}
}
return null;
};
}
}
/// 라우트 접근 시 필요한 권한 정보를 표현한다.
class PermissionRequirement {
const PermissionRequirement({
required this.resource,
this.action = PermissionAction.view,
});
final String resource;
final PermissionAction action;
}

View File

@@ -0,0 +1,15 @@
import 'dart:typed_data';
import 'file_saver_stub.dart' if (dart.library.html) 'file_saver_web.dart';
/// 바이트 데이터를 로컬 파일로 저장한다.
Future<void> saveFileBytes({
required Uint8List bytes,
required String filename,
required String mimeType,
}) async {
assert(filename.isNotEmpty, 'filename은 비어 있을 수 없습니다.');
assert(bytes.isNotEmpty, 'bytes는 비어 있을 수 없습니다.');
await saveFileBytesImpl(bytes: bytes, filename: filename, mimeType: mimeType);
}

View File

@@ -0,0 +1,10 @@
import 'dart:typed_data';
/// 웹 외 플랫폼에서 파일 저장이 호출되면 예외를 발생시킨다.
Future<void> saveFileBytesImpl({
required Uint8List bytes,
required String filename,
required String mimeType,
}) async {
throw UnsupportedError('현재 플랫폼에서는 파일 저장을 지원하지 않습니다.');
}

View File

@@ -0,0 +1,20 @@
import 'dart:typed_data';
import 'package:web/web.dart' as web;
/// 웹 환경에서 Anchor 요소를 사용해 파일 저장을 트리거한다.
Future<void> saveFileBytesImpl({
required Uint8List bytes,
required String filename,
required String mimeType,
}) async {
final dataUrl = Uri.dataFromBytes(bytes, mimeType: mimeType).toString();
final anchor = web.document.createElement('a') as web.HTMLAnchorElement
..href = dataUrl
..download = filename;
anchor.style.display = 'none';
web.document.body?.append(anchor);
anchor.click();
anchor.remove();
}

View File

@@ -4,6 +4,7 @@ import 'token_storage.dart';
/// 안전한 스토리지에 저장할 액세스 토큰 키.
const _kAccessTokenKey = 'access_token';
/// 안전한 스토리지에 저장할 리프레시 토큰 키.
const _kRefreshTokenKey = 'refresh_token';

View File

@@ -5,6 +5,7 @@ import 'token_storage.dart';
/// 웹 로컬스토리지에 저장할 액세스 토큰 키.
const _kAccessTokenKey = 'access_token';
/// 웹 로컬스토리지에 저장할 리프레시 토큰 키.
const _kRefreshTokenKey = 'refresh_token';

View File

@@ -0,0 +1,55 @@
/// 비밀번호 정책을 검증하기 위한 규칙 모음.
class PasswordRules {
PasswordRules._();
/// 허용 최소 길이.
static const int minLength = 8;
/// 허용 최대 길이.
static const int maxLength = 24;
static final RegExp _uppercase = RegExp(r'[A-Z]');
static final RegExp _lowercase = RegExp(r'[a-z]');
static final RegExp _digit = RegExp(r'[0-9]');
static final RegExp _special = RegExp(
"[!@#\$%\\^&*()_+\\-={}\\[\\]\\\\|:;\"'<>,.?/~`]",
);
/// 입력이 모든 비밀번호 규칙을 만족하는지 검사한다.
static bool isValid(String value) => validate(value).isEmpty;
/// 비밀번호 정책 위반 항목을 반환한다.
static List<PasswordRuleViolation> validate(String value) {
final violations = <PasswordRuleViolation>[];
final length = value.length;
if (length < minLength) {
violations.add(PasswordRuleViolation.tooShort);
}
if (length > maxLength) {
violations.add(PasswordRuleViolation.tooLong);
}
if (!_uppercase.hasMatch(value)) {
violations.add(PasswordRuleViolation.missingUppercase);
}
if (!_lowercase.hasMatch(value)) {
violations.add(PasswordRuleViolation.missingLowercase);
}
if (!_digit.hasMatch(value)) {
violations.add(PasswordRuleViolation.missingDigit);
}
if (!_special.hasMatch(value)) {
violations.add(PasswordRuleViolation.missingSpecial);
}
return violations;
}
}
/// 비밀번호 규칙 위반 유형.
enum PasswordRuleViolation {
tooShort,
tooLong,
missingUppercase,
missingLowercase,
missingDigit,
missingSpecial,
}

View File

@@ -0,0 +1,156 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
import 'approval_step_dto.dart';
/// 결재 감사 로그(Audit) DTO.
class ApprovalAuditDto {
ApprovalAuditDto({
this.id,
required this.action,
this.fromStatus,
required this.toStatus,
required this.actor,
required this.actionAt,
this.note,
this.actionCode,
this.payload,
});
final int? id;
final ApprovalActionDto action;
final ApprovalStatusDto? fromStatus;
final ApprovalStatusDto toStatus;
final ApprovalApproverDto actor;
final DateTime actionAt;
final String? note;
final String? actionCode;
final Map<String, dynamic>? payload;
factory ApprovalAuditDto.fromJson(Map<String, dynamic> json) {
final actionMap = {
...?_asMap(json['action']),
...?_asMap(json['approval_action']),
};
final fallbackActionId = json['approval_action_id'] ?? json['action_id'];
if (fallbackActionId != null) {
actionMap.putIfAbsent('id', () => fallbackActionId);
}
final fallbackActionName = _firstNonEmpty(<String?>[
_readString(json, 'action_name'),
_readString(json, 'approval_action_name'),
]);
if (fallbackActionName != null) {
actionMap.putIfAbsent('name', () => fallbackActionName);
}
final rootActionCode = _firstNonEmpty(<String?>[
_readString(json, 'action_code'),
_readString(json, 'approval_action_code'),
]);
if (rootActionCode != null) {
actionMap.putIfAbsent('code', () => rootActionCode);
actionMap.putIfAbsent('action_code', () => rootActionCode);
}
final actionDto = ApprovalActionDto.fromJson(actionMap);
final fromStatusMap = _asMap(json['from_status']);
final toStatusMap = _asMap(json['to_status']) ?? const <String, dynamic>{};
final actorMap =
_asMap(json['actor']) ??
_asMap(json['approver']) ??
const <String, dynamic>{};
final resolvedActionCode = rootActionCode ?? actionDto.code;
return ApprovalAuditDto(
id: JsonUtils.readInt(json, 'id'),
action: actionDto,
fromStatus: fromStatusMap == null
? null
: ApprovalStatusDto.fromJson(fromStatusMap),
toStatus: ApprovalStatusDto.fromJson(toStatusMap),
actor: ApprovalApproverDto.fromJson(actorMap),
actionAt: _parseDate(json['action_at']) ?? DateTime.now(),
note: _readString(json, 'note'),
actionCode: resolvedActionCode,
payload: _asMap(
json['payload'],
)?.map((key, value) => MapEntry(key, value)),
);
}
ApprovalHistory toEntity() => ApprovalHistory(
id: id,
action: action.toEntity(),
fromStatus: fromStatus?.toEntity(),
toStatus: toStatus.toEntity(),
approver: actor.toEntity(),
actionAt: actionAt,
note: note,
actionCode: actionCode,
payload: payload,
);
}
/// 결재 감사 로그 액션 DTO.
class ApprovalActionDto {
ApprovalActionDto({required this.id, required this.name, this.code});
final int id;
final String name;
final String? code;
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
if (json['action'] is Map<String, dynamic>) {
return ApprovalActionDto.fromJson(json['action'] as Map<String, dynamic>);
}
final id = JsonUtils.readInt(json, 'id', fallback: 0);
final name = _firstNonEmpty(<String?>[
_readString(json, 'name'),
_readString(json, 'action_name'),
]);
if (name == null) {
throw const FormatException('결재 감사 로그 액션 이름이 누락되었습니다.');
}
final code = _firstNonEmpty(<String?>[
_readString(json, 'code'),
_readString(json, 'action_code'),
]);
return ApprovalActionDto(id: id, name: name, code: code);
}
ApprovalAction toEntity() => ApprovalAction(id: id, name: name, code: code);
}
Map<String, dynamic>? _asMap(dynamic value) =>
value is Map<String, dynamic> ? value : null;
String? _readString(
Map<String, dynamic>? source,
String key, {
String? fallback,
}) {
if (source == null) return fallback;
final value = source[key];
if (value is String) return value;
if (value == null) return fallback;
return value.toString();
}
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}
String? _firstNonEmpty(Iterable<String?> values) {
for (final value in values) {
if (value == null) {
continue;
}
final trimmed = value.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
}
return null;
}

View File

@@ -0,0 +1,268 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval_draft.dart';
/// 결재 초안 단계 DTO.
class ApprovalDraftStepDto {
ApprovalDraftStepDto({
required this.stepOrder,
required this.approverId,
this.approverRole,
this.note,
this.isOptional = false,
});
final int stepOrder;
final int approverId;
final String? approverRole;
final String? note;
final bool isOptional;
factory ApprovalDraftStepDto.fromJson(Map<String, dynamic> json) {
return ApprovalDraftStepDto(
stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0),
approverId: JsonUtils.readInt(json, 'approver_id', fallback: 0),
approverRole: _readString(json['approver_role']),
note: _readString(json['note']),
isOptional: json['is_optional'] is bool
? json['is_optional'] as bool
: false,
);
}
ApprovalDraftStep toEntity() => ApprovalDraftStep(
stepOrder: stepOrder,
approverId: approverId,
approverRole: approverRole,
note: note,
isOptional: isOptional,
);
}
/// 결재 초안 페이로드 DTO.
class ApprovalDraftPayloadDto {
ApprovalDraftPayloadDto({
this.title,
this.summary,
this.note,
this.templateId,
this.metadata,
this.steps = const [],
});
final String? title;
final String? summary;
final String? note;
final int? templateId;
final Map<String, dynamic>? metadata;
final List<ApprovalDraftStepDto> steps;
factory ApprovalDraftPayloadDto.fromJson(Map<String, dynamic> json) {
final steps = (json['steps'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(ApprovalDraftStepDto.fromJson)
.toList(growable: false);
final metadata = json['metadata'] is Map<String, dynamic>
? Map<String, dynamic>.from(json['metadata'] as Map)
: null;
return ApprovalDraftPayloadDto(
title: _readString(json['title']),
summary: _readString(json['summary']),
note: _readString(json['note']),
templateId: json['template_id'] as int?,
metadata: metadata,
steps: steps,
);
}
ApprovalDraftPayload toEntity() => ApprovalDraftPayload(
title: title,
summary: summary,
note: note,
templateId: templateId,
metadata: metadata,
steps: steps.map((step) => step.toEntity()).toList(growable: false),
);
}
/// 결재 초안 요약 DTO.
class ApprovalDraftSummaryDto {
ApprovalDraftSummaryDto({
required this.id,
required this.requesterId,
required this.status,
required this.savedAt,
this.requestId,
this.transactionId,
this.templateId,
this.title,
this.summary,
this.expiresAt,
this.sessionKey,
this.stepCount = 0,
});
final int id;
final int requesterId;
final ApprovalDraftStatus status;
final DateTime savedAt;
final int? requestId;
final int? transactionId;
final int? templateId;
final String? title;
final String? summary;
final DateTime? expiresAt;
final String? sessionKey;
final int stepCount;
factory ApprovalDraftSummaryDto.fromJson(Map<String, dynamic> json) {
final savedAtRaw = json['saved_at'];
final expiresAtRaw = json['expires_at'];
return ApprovalDraftSummaryDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0),
status: _parseStatus(_readString(json['status'])),
savedAt: _parseDate(savedAtRaw) ?? DateTime.now().toUtc(),
requestId: json['request_id'] as int?,
transactionId: json['transaction_id'] as int?,
templateId: json['template_id'] as int?,
title: _readString(json['title']),
summary: _readString(json['summary']),
expiresAt: _parseDate(expiresAtRaw),
sessionKey: _readString(json['session_key']),
stepCount: JsonUtils.readInt(json, 'step_count', fallback: 0),
);
}
ApprovalDraftSummary toEntity() => ApprovalDraftSummary(
id: id,
requesterId: requesterId,
status: status,
savedAt: savedAt,
requestId: requestId,
transactionId: transactionId,
templateId: templateId,
title: title,
summary: summary,
expiresAt: expiresAt,
sessionKey: sessionKey,
stepCount: stepCount,
);
}
/// 결재 초안 상세 DTO.
class ApprovalDraftDetailDto {
ApprovalDraftDetailDto({
required this.id,
required this.requesterId,
required this.payload,
required this.savedAt,
this.transactionId,
this.templateId,
this.expiresAt,
this.sessionKey,
});
final int id;
final int requesterId;
final ApprovalDraftPayloadDto payload;
final DateTime savedAt;
final int? transactionId;
final int? templateId;
final DateTime? expiresAt;
final String? sessionKey;
factory ApprovalDraftDetailDto.fromJson(Map<String, dynamic> json) {
final payloadMap = json['payload'] is Map<String, dynamic>
? json['payload'] as Map<String, dynamic>
: const <String, dynamic>{};
return ApprovalDraftDetailDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0),
payload: ApprovalDraftPayloadDto.fromJson(payloadMap),
savedAt: _parseDate(json['saved_at']) ?? DateTime.now().toUtc(),
transactionId: json['transaction_id'] as int?,
templateId: json['template_id'] as int?,
expiresAt: _parseDate(json['expires_at']),
sessionKey: _readString(json['session_key']),
);
}
ApprovalDraftDetail toEntity() => ApprovalDraftDetail(
id: id,
requesterId: requesterId,
payload: payload.toEntity(),
savedAt: savedAt,
transactionId: transactionId,
templateId: templateId,
expiresAt: expiresAt,
sessionKey: sessionKey,
);
}
class ApprovalDraftDto {
ApprovalDraftDto._();
static PaginatedResult<ApprovalDraftSummary> parsePaginated(
Map<String, dynamic>? json,
) {
final items = JsonUtils.extractList(json)
.map(ApprovalDraftSummaryDto.fromJson)
.map((dto) => dto.toEntity())
.toList(growable: false);
return PaginatedResult<ApprovalDraftSummary>(
items: items,
page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: JsonUtils.readInt(json, 'total', fallback: items.length),
);
}
static ApprovalDraftDetail? parseDetail(Map<String, dynamic>? json) {
if (json == null || json.isEmpty) {
return null;
}
final map = JsonUtils.extractMap(json);
if (map.isEmpty) {
return null;
}
return ApprovalDraftDetailDto.fromJson(map).toEntity();
}
}
String? _readString(dynamic value) {
if (value == null) {
return null;
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
return value.toString();
}
DateTime? _parseDate(dynamic value) {
if (value == null) {
return null;
}
if (value is DateTime) {
return value.toUtc();
}
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value)?.toUtc();
}
return null;
}
ApprovalDraftStatus _parseStatus(String? value) {
switch (value) {
case 'expired':
return ApprovalDraftStatus.expired;
case 'archived':
return ApprovalDraftStatus.archived;
case 'active':
default:
return ApprovalDraftStatus.active;
}
}

View File

@@ -2,6 +2,8 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
import 'approval_audit_dto.dart';
import 'approval_step_dto.dart';
/// 결재 API 응답을 표현하는 DTO.
///
@@ -11,7 +13,9 @@ class ApprovalDto {
ApprovalDto({
this.id,
required this.approvalNo,
this.transactionId,
this.transactionNo,
this.transactionUpdatedAt,
required this.status,
this.currentStep,
required this.requester,
@@ -28,7 +32,9 @@ class ApprovalDto {
final int? id;
final String approvalNo;
final int? transactionId;
final String? transactionNo;
final DateTime? transactionUpdatedAt;
final ApprovalStatusDto status;
final ApprovalStepDto? currentStep;
final ApprovalRequesterDto requester;
@@ -38,44 +44,104 @@ class ApprovalDto {
final bool isActive;
final bool isDeleted;
final List<ApprovalStepDto> steps;
final List<ApprovalHistoryDto> histories;
final List<ApprovalAuditDto> histories;
final DateTime? createdAt;
final DateTime? updatedAt;
/// API 응답 JSON을 [ApprovalDto]로 변환한다.
factory ApprovalDto.fromJson(Map<String, dynamic> json) {
final approvalEnvelope = _mapOrEmpty(json['approval']);
final statusMap = _firstNonEmptyMap([
json['status'],
json['approval_status'],
approvalEnvelope['status'],
approvalEnvelope['approval_status'],
]);
final rawRequesterMap = _firstNonEmptyMap([
json['requester'],
json['requested_by'],
approvalEnvelope['requester'],
approvalEnvelope['requested_by'],
]);
final currentStepMap = _firstNonEmptyMap([
json['current_step'],
json['currentStep'],
approvalEnvelope['current_step'],
]);
final transactionMap = _mapOrEmpty(json['transaction']);
final envelopeTransactionMap = _mapOrEmpty(approvalEnvelope['transaction']);
var stepsSource = _asListOfMap(json['steps']);
if (stepsSource.isEmpty) {
stepsSource = _asListOfMap(approvalEnvelope['steps']);
}
var historiesSource = _asListOfMap(json['histories']);
if (historiesSource.isEmpty) {
historiesSource = _asListOfMap(approvalEnvelope['histories']);
}
final currentStepDto = currentStepMap.isEmpty
? null
: ApprovalStepDto.fromJson(currentStepMap);
final approvalNo =
_pickString(
[json, approvalEnvelope],
const ['approval_no', 'approvalNo'],
) ??
'-';
final transactionNo = _pickString(
[json, transactionMap, approvalEnvelope, envelopeTransactionMap],
const ['transaction_no', 'transactionNo'],
);
final transactionId =
json['transaction_id'] as int? ??
approvalEnvelope['transaction_id'] as int? ??
transactionMap['id'] as int? ??
envelopeTransactionMap['id'] as int?;
final transactionUpdatedAt = _parseDate(
transactionMap['updated_at'] ??
envelopeTransactionMap['updated_at'] ??
json['transaction_updated_at'] ??
approvalEnvelope['transaction_updated_at'],
);
return ApprovalDto(
id: json['id'] as int?,
approvalNo: json['approval_no'] as String,
transactionNo: json['transaction'] is Map<String, dynamic>
? (json['transaction']['transaction_no'] as String?)
: json['transaction_no'] as String?,
status: ApprovalStatusDto.fromJson(
(json['status'] as Map<String, dynamic>? ?? const {}),
),
currentStep: json['current_step'] is Map<String, dynamic>
? ApprovalStepDto.fromJson(
json['current_step'] as Map<String, dynamic>,
)
: null,
id: json['id'] as int? ?? approvalEnvelope['id'] as int?,
approvalNo: approvalNo,
transactionId: transactionId,
transactionNo: transactionNo,
transactionUpdatedAt: transactionUpdatedAt,
status: ApprovalStatusDto.fromJson(statusMap),
currentStep: currentStepDto,
requester: ApprovalRequesterDto.fromJson(
(json['requester'] as Map<String, dynamic>? ?? const {}),
_resolveRequesterMap(json, approvalEnvelope, rawRequesterMap),
),
requestedAt:
_parseDate(
json['requested_at'] ?? approvalEnvelope['requested_at'],
) ??
DateTime.now(),
decidedAt: _parseDate(
json['decided_at'] ?? approvalEnvelope['decided_at'],
),
note: _readString(json['note']) ?? _readString(approvalEnvelope['note']),
isActive:
(json['is_active'] as bool?) ??
(approvalEnvelope['is_active'] as bool?) ??
true,
isDeleted:
(json['is_deleted'] as bool?) ??
(approvalEnvelope['is_deleted'] as bool?) ??
false,
steps: stepsSource.map(ApprovalStepDto.fromJson).toList(growable: false),
histories: historiesSource
.map(ApprovalAuditDto.fromJson)
.toList(growable: false),
createdAt: _parseDate(
json['created_at'] ?? approvalEnvelope['created_at'],
),
updatedAt: _parseDate(
json['updated_at'] ?? approvalEnvelope['updated_at'],
),
requestedAt: _parseDate(json['requested_at']) ?? DateTime.now(),
decidedAt: _parseDate(json['decided_at']),
note: json['note'] as String?,
isActive: (json['is_active'] as bool?) ?? true,
isDeleted: (json['is_deleted'] as bool?) ?? false,
steps: (json['steps'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalStepDto.fromJson)
.toList(),
histories: (json['histories'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalHistoryDto.fromJson)
.toList(),
createdAt: _parseDate(json['created_at']),
updatedAt: _parseDate(json['updated_at']),
);
}
@@ -83,7 +149,9 @@ class ApprovalDto {
Approval toEntity() => Approval(
id: id,
approvalNo: approvalNo,
transactionId: transactionId,
transactionNo: transactionNo ?? '-',
transactionUpdatedAt: transactionUpdatedAt,
status: status.toEntity(),
currentStep: currentStep?.toEntity(),
requester: requester.toEntity(),
@@ -114,26 +182,6 @@ class ApprovalDto {
}
}
/// 결재 상태(Status) DTO.
class ApprovalStatusDto {
ApprovalStatusDto({required this.id, required this.name, this.color});
final int id;
final String name;
final String? color;
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
return ApprovalStatusDto(
id: json['id'] as int? ?? json['status_id'] as int? ?? 0,
name: json['name'] as String? ?? json['status_name'] as String? ?? '-',
color: json['color'] as String?,
);
}
/// DTO를 [ApprovalStatus]로 변환한다.
ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color);
}
/// 결재 요청자 DTO.
class ApprovalRequesterDto {
ApprovalRequesterDto({
@@ -148,9 +196,15 @@ class ApprovalRequesterDto {
factory ApprovalRequesterDto.fromJson(Map<String, dynamic> json) {
return ApprovalRequesterDto(
id: json['id'] as int? ?? json['employee_id'] as int? ?? 0,
employeeNo: json['employee_no'] as String? ?? '-',
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
id: JsonUtils.readInt(json, 'id', fallback: 0),
employeeNo:
_readString(json['employee_no']) ??
_readString(json['employee_id']) ??
'-',
name:
_readString(json['name']) ??
_readString(json['employee_name']) ??
'-',
);
}
@@ -159,156 +213,114 @@ class ApprovalRequesterDto {
ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
}
/// 결재 승인자 DTO.
class ApprovalApproverDto {
ApprovalApproverDto({
required this.id,
required this.employeeNo,
required this.name,
});
List<Map<String, dynamic>> _asListOfMap(dynamic value) {
if (value is List) {
return value.whereType<Map<String, dynamic>>().toList(growable: false);
}
return const [];
}
final int id;
final String employeeNo;
final String name;
Map<String, dynamic> _mapOrEmpty(dynamic value) =>
value is Map<String, dynamic> ? value : const {};
factory ApprovalApproverDto.fromJson(Map<String, dynamic> json) {
return ApprovalApproverDto(
id: json['id'] as int? ?? json['approver_id'] as int? ?? 0,
employeeNo: json['employee_no'] as String? ?? '-',
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
Map<String, dynamic> _firstNonEmptyMap(List<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is Map<String, dynamic> && candidate.isNotEmpty) {
return candidate;
}
}
return const {};
}
Map<String, dynamic> _resolveRequesterMap(
Map<String, dynamic> root,
Map<String, dynamic> envelope,
Map<String, dynamic> candidate,
) {
if (candidate.isNotEmpty) {
return candidate;
}
final resolved = <String, dynamic>{};
final rootRequestedBy = _mapOrEmpty(root['requested_by']);
if (rootRequestedBy.isNotEmpty) {
resolved.addAll(rootRequestedBy);
}
final envelopeRequestedBy = _mapOrEmpty(envelope['requested_by']);
if (resolved.isEmpty && envelopeRequestedBy.isNotEmpty) {
resolved.addAll(envelopeRequestedBy);
} else if (envelopeRequestedBy.isNotEmpty) {
for (final entry in envelopeRequestedBy.entries) {
resolved.putIfAbsent(entry.key, () => entry.value);
}
}
final fallbackId = _pickInt(
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
const ['requester_id', 'requested_by_id', 'id'],
);
if (fallbackId != null) {
resolved['id'] = fallbackId;
}
/// DTO를 [ApprovalApprover]로 변환한다.
ApprovalApprover toEntity() =>
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
}
/// 결재 단계 DTO.
class ApprovalStepDto {
ApprovalStepDto({
this.id,
required this.stepOrder,
required this.approver,
required this.status,
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
});
final int? id;
final int stepOrder;
final ApprovalApproverDto approver;
final ApprovalStatusDto status;
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
return ApprovalStepDto(
id: json['id'] as int?,
stepOrder: json['step_order'] as int? ?? 0,
approver: ApprovalApproverDto.fromJson(
(json['approver'] as Map<String, dynamic>? ?? const {}),
),
status: ApprovalStatusDto.fromJson(
(json['status'] as Map<String, dynamic>? ?? const {}),
),
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
decidedAt: _parseDate(json['decided_at']),
note: json['note'] as String?,
isDeleted:
json['is_deleted'] as bool? ??
(json['deleted_at'] != null ||
(json['is_active'] is bool && !(json['is_active'] as bool))),
final fallbackEmployeeNo = _pickString(
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
const [
'employee_no',
'employee_id',
'requester_employee_no',
'requested_by_employee_no',
],
);
if (fallbackEmployeeNo != null) {
resolved['employee_no'] = fallbackEmployeeNo;
}
/// DTO를 [ApprovalStep]으로 변환한다.
ApprovalStep toEntity() => ApprovalStep(
id: id,
stepOrder: stepOrder,
approver: approver.toEntity(),
status: status.toEntity(),
assignedAt: assignedAt,
decidedAt: decidedAt,
note: note,
isDeleted: isDeleted,
final fallbackName = _pickString(
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
const ['name', 'employee_name', 'requester_name', 'requested_by_name'],
);
if (fallbackName != null) {
resolved['name'] = fallbackName;
}
/// 결재 이력 DTO.
class ApprovalHistoryDto {
ApprovalHistoryDto({
this.id,
required this.action,
this.fromStatus,
required this.toStatus,
required this.approver,
required this.actionAt,
this.note,
});
final int? id;
final ApprovalActionDto action;
final ApprovalStatusDto? fromStatus;
final ApprovalStatusDto toStatus;
final ApprovalApproverDto approver;
final DateTime actionAt;
final String? note;
factory ApprovalHistoryDto.fromJson(Map<String, dynamic> json) {
return ApprovalHistoryDto(
id: json['id'] as int?,
action: ApprovalActionDto.fromJson(
(json['action'] as Map<String, dynamic>? ?? const {}),
),
fromStatus: json['from_status'] is Map<String, dynamic>
? ApprovalStatusDto.fromJson(
json['from_status'] as Map<String, dynamic>,
)
: null,
toStatus: ApprovalStatusDto.fromJson(
(json['to_status'] as Map<String, dynamic>? ?? const {}),
),
approver: ApprovalApproverDto.fromJson(
(json['approver'] as Map<String, dynamic>? ?? const {}),
),
actionAt: _parseDate(json['action_at']) ?? DateTime.now(),
note: json['note'] as String?,
);
return resolved;
}
/// DTO를 [ApprovalHistory]로 변환한다.
ApprovalHistory toEntity() => ApprovalHistory(
id: id,
action: action.toEntity(),
fromStatus: fromStatus?.toEntity(),
toStatus: toStatus.toEntity(),
approver: approver.toEntity(),
actionAt: actionAt,
note: note,
);
String? _pickString(List<dynamic> sources, List<String> keys) {
for (final source in sources) {
if (source is Map<String, dynamic>) {
for (final key in keys) {
final value = source[key];
if (value is String && value.isNotEmpty) {
return value;
}
}
}
}
return null;
}
/// 결재 행위(Action) DTO.
class ApprovalActionDto {
ApprovalActionDto({required this.id, required this.name});
final int id;
final String name;
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
return ApprovalActionDto(
id: json['id'] as int? ?? json['action_id'] as int? ?? 0,
name: json['name'] as String? ?? json['action_name'] as String? ?? '-',
);
int? _pickInt(List<dynamic> sources, List<String> keys) {
for (final source in sources) {
if (source is Map<String, dynamic>) {
for (final key in keys) {
final value = source[key];
if (value is int) {
return value;
}
/// DTO를 [ApprovalAction]으로 변환한다.
ApprovalAction toEntity() => ApprovalAction(id: id, name: name);
if (value is num) {
return value.toInt();
}
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) {
return parsed;
}
}
}
}
}
return null;
}
/// 문자열/DateTime 입력을 DateTime으로 변환한다.
@@ -318,3 +330,17 @@ DateTime? _parseDate(Object? value) {
if (value is String) return DateTime.tryParse(value);
return null;
}
String? _readString(dynamic value) {
if (value == null) {
return null;
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
if (value is num || value is bool) {
return value.toString();
}
return null;
}

View File

@@ -0,0 +1,28 @@
import '../../domain/entities/approval_proceed_status.dart';
/// 결재 진행 가능 여부(can-proceed) 응답 DTO.
class ApprovalProceedStatusDto {
ApprovalProceedStatusDto({
required this.approvalId,
required this.canProceed,
this.reason,
});
final int approvalId;
final bool canProceed;
final String? reason;
factory ApprovalProceedStatusDto.fromJson(Map<String, dynamic> json) {
return ApprovalProceedStatusDto(
approvalId: json['id'] as int? ?? json['approval_id'] as int? ?? 0,
canProceed: json['can_proceed'] as bool? ?? false,
reason: json['reason'] as String?,
);
}
ApprovalProceedStatus toEntity() => ApprovalProceedStatus(
approvalId: approvalId,
canProceed: canProceed,
reason: reason,
);
}

View File

@@ -0,0 +1,254 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
import 'approval_audit_dto.dart';
/// 결재 상신(Submit) 요청 DTO.
class ApprovalSubmitRequestDto {
ApprovalSubmitRequestDto({required this.approval, required this.steps});
final ApprovalCreatePayloadDto approval;
final List<ApprovalStepInputDto> steps;
Map<String, dynamic> toJson() {
return {
'approval': approval.toJson(),
'steps': steps.map((e) => e.toJson()).toList(growable: false),
};
}
}
/// 결재 재상신 요청 DTO.
class ApprovalResubmitRequestDto {
ApprovalResubmitRequestDto({
required this.approvalId,
required this.actorId,
required this.steps,
this.note,
this.expectedUpdatedAt,
this.transactionExpectedUpdatedAt,
});
final int approvalId;
final int actorId;
final List<ApprovalStepInputDto> steps;
final String? note;
final DateTime? expectedUpdatedAt;
final DateTime? transactionExpectedUpdatedAt;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'approval_id': approvalId,
'actor_id': actorId,
'steps': steps.map((e) => e.toJson()).toList(growable: false),
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (expectedUpdatedAt != null)
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
if (transactionExpectedUpdatedAt != null)
'transaction_expected_updated_at': transactionExpectedUpdatedAt!
.toUtc()
.toIso8601String(),
};
}
}
/// 결재 승인/반려 요청 DTO.
class ApprovalDecisionRequestDto {
ApprovalDecisionRequestDto({
required this.approvalId,
required this.actorId,
this.note,
this.expectedUpdatedAt,
});
final int approvalId;
final int actorId;
final String? note;
final DateTime? expectedUpdatedAt;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'approval_id': approvalId,
'actor_id': actorId,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (expectedUpdatedAt != null)
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
};
}
}
/// 결재 회수 요청 DTO.
class ApprovalRecallRequestDto {
ApprovalRecallRequestDto({
required this.approvalId,
required this.actorId,
this.note,
this.expectedUpdatedAt,
this.transactionExpectedUpdatedAt,
});
final int approvalId;
final int actorId;
final String? note;
final DateTime? expectedUpdatedAt;
final DateTime? transactionExpectedUpdatedAt;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'approval_id': approvalId,
'actor_id': actorId,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (expectedUpdatedAt != null)
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
if (transactionExpectedUpdatedAt != null)
'transaction_expected_updated_at': transactionExpectedUpdatedAt!
.toUtc()
.toIso8601String(),
};
}
}
/// 결재 본문 생성 DTO.
class ApprovalCreatePayloadDto {
ApprovalCreatePayloadDto({
this.transactionId,
this.templateId,
required this.statusId,
required this.requesterId,
this.finalApproverId,
this.requestedAt,
this.decidedAt,
this.cancelledAt,
this.lastActionAt,
this.title,
this.summary,
this.note,
this.metadata,
});
final int? transactionId;
final int? templateId;
final int statusId;
final int requesterId;
final int? finalApproverId;
final DateTime? requestedAt;
final DateTime? decidedAt;
final DateTime? cancelledAt;
final DateTime? lastActionAt;
final String? title;
final String? summary;
final String? note;
final Map<String, dynamic>? metadata;
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
final sanitizedTitle = title?.trim();
final sanitizedSummary = summary?.trim();
return {
'transaction_id': transactionId,
'template_id': templateId,
'approval_status_id': statusId,
'requested_by_id': requesterId,
'final_approver_id': finalApproverId,
if (requestedAt != null)
'requested_at': requestedAt!.toUtc().toIso8601String(),
if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(),
if (cancelledAt != null)
'cancelled_at': cancelledAt!.toUtc().toIso8601String(),
if (lastActionAt != null)
'last_action_at': lastActionAt!.toUtc().toIso8601String(),
if (sanitizedTitle != null && sanitizedTitle.isNotEmpty)
'title': sanitizedTitle,
if (sanitizedSummary != null && sanitizedSummary.isNotEmpty)
'summary': sanitizedSummary,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata,
};
}
factory ApprovalCreatePayloadDto.fromSubmission(
ApprovalSubmissionInput input,
) {
return ApprovalCreatePayloadDto(
transactionId: input.transactionId,
templateId: input.templateId,
statusId: input.statusId,
requesterId: input.requesterId,
finalApproverId: input.finalApproverId,
requestedAt: input.requestedAt,
decidedAt: input.decidedAt,
cancelledAt: input.cancelledAt,
lastActionAt: input.lastActionAt,
title: input.title,
summary: input.summary,
note: input.note,
metadata: input.metadata,
);
}
}
/// 결재 단계 생성 입력 DTO.
class ApprovalStepInputDto {
ApprovalStepInputDto({
required this.stepOrder,
required this.approverId,
this.note,
});
final int stepOrder;
final int approverId;
final String? note;
factory ApprovalStepInputDto.fromDomain(ApprovalStepAssignmentItem item) {
return ApprovalStepInputDto(
stepOrder: item.stepOrder,
approverId: item.approverId,
note: item.note,
);
}
Map<String, dynamic> toJson() {
final sanitizedNote = note?.trim();
return {
'step_order': stepOrder,
'approver_id': approverId,
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
'note': sanitizedNote,
};
}
}
/// 감사 로그 리스트 응답 DTO.
class ApprovalAuditListDto {
ApprovalAuditListDto({
required this.items,
required this.page,
required this.pageSize,
required this.total,
});
final List<ApprovalAuditDto> items;
final int page;
final int pageSize;
final int total;
factory ApprovalAuditListDto.fromJson(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
.map((item) => ApprovalAuditDto.fromJson(item))
.toList(growable: false);
return ApprovalAuditListDto(
items: items,
page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: JsonUtils.readInt(json, 'total', fallback: items.length),
);
}
}

View File

@@ -0,0 +1,207 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart';
/// 결재 상태(summary) DTO.
class ApprovalStatusDto {
ApprovalStatusDto({
required this.id,
required this.name,
this.color,
bool? isBlockingNext,
bool? isTerminal,
}) : isBlockingNext = isBlockingNext ?? true,
isTerminal = isTerminal ?? false;
final int id;
final String name;
final String? color;
final bool isBlockingNext;
final bool isTerminal;
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
if (json['status'] is Map<String, dynamic>) {
return ApprovalStatusDto.fromJson(json['status'] as Map<String, dynamic>);
}
final resolvedName =
_readString(json, 'name') ??
_readString(json, 'status_name') ??
_readString(json, 'statusName') ??
'-';
final rawColor =
_readString(json, 'color') ??
_readString(json, 'status_color') ??
_readString(json, 'statusColor');
return ApprovalStatusDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
name: resolvedName,
color: rawColor,
isBlockingNext:
_readBool(json, 'is_blocking_next', fallback: true) ??
_readBool(json, 'isBlockingNext', fallback: true) ??
true,
isTerminal:
_readBool(json, 'is_terminal', fallback: false) ??
_readBool(json, 'isTerminal', fallback: false) ??
false,
);
}
ApprovalStatus toEntity() => ApprovalStatus(
id: id,
name: name,
color: color,
isBlockingNext: isBlockingNext,
isTerminal: isTerminal,
);
}
/// 결재 사용자 요약 DTO.
class ApprovalApproverDto {
ApprovalApproverDto({
required this.id,
required this.employeeNo,
required this.name,
});
final int id;
final String employeeNo;
final String name;
factory ApprovalApproverDto.fromJson(Map<String, dynamic> json) {
final employeeNo =
_readString(json, 'employee_no') ??
_readString(json, 'employee_id', fallback: '-');
return ApprovalApproverDto(
id: JsonUtils.readInt(json, 'id', fallback: 0),
employeeNo: employeeNo ?? '-',
name: _readString(json, 'name', fallback: '-') ?? '-',
);
}
ApprovalApprover toEntity() =>
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
}
/// 결재 단계(summary) DTO.
class ApprovalStepDto {
ApprovalStepDto({
this.id,
this.requestId,
required this.stepOrder,
this.templateStepId,
this.approverRole,
required this.approver,
required this.status,
this.assignedAt,
this.decidedAt,
this.actionAt,
this.note,
this.isDeleted = false,
this.isOptional = false,
this.escalationMinutes,
this.metadata,
});
final int? id;
final int? requestId;
final int stepOrder;
final int? templateStepId;
final String? approverRole;
final ApprovalApproverDto approver;
final ApprovalStatusDto status;
final DateTime? assignedAt;
final DateTime? decidedAt;
final DateTime? actionAt;
final String? note;
final bool isDeleted;
final bool isOptional;
final int? escalationMinutes;
final Map<String, dynamic>? metadata;
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
final statusMap =
_asMap(json['status']) ?? _asMap(json['step_status']) ?? const {};
final approverMap = _asMap(json['approver']) ?? const {};
final assignedAt = _parseDate(json['assigned_at']);
final decidedAt = _parseDate(json['decided_at']);
final actionAt = _parseDate(json['action_at']);
return ApprovalStepDto(
id: JsonUtils.readInt(json, 'id'),
requestId: JsonUtils.readInt(json, 'request_id'),
stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0),
templateStepId: JsonUtils.readInt(json, 'template_step_id'),
approverRole: _readString(json, 'approver_role'),
approver: ApprovalApproverDto.fromJson(approverMap),
status: ApprovalStatusDto.fromJson(statusMap),
assignedAt: assignedAt,
decidedAt: decidedAt,
actionAt: actionAt,
note: _readString(json, 'note'),
isDeleted:
_readBool(json, 'is_deleted') ??
(json['deleted_at'] != null ||
(json['is_active'] is bool && !(json['is_active'] as bool))),
isOptional: _readBool(json, 'is_optional', fallback: false) ?? false,
escalationMinutes: JsonUtils.readInt(json, 'escalation_minutes'),
metadata: _asMap(
json['metadata'],
)?.map((key, value) => MapEntry(key, value)),
);
}
ApprovalStep toEntity() => ApprovalStep(
id: id,
requestId: requestId,
stepOrder: stepOrder,
templateStepId: templateStepId,
approverRole: approverRole,
approver: approver.toEntity(),
status: status.toEntity(),
assignedAt: assignedAt ?? DateTime.now(),
decidedAt: decidedAt,
actionAt: actionAt,
note: note,
isDeleted: isDeleted,
isOptional: isOptional,
escalationMinutes: escalationMinutes,
metadata: metadata,
);
}
String? _readString(
Map<String, dynamic>? source,
String key, {
String? fallback,
}) {
if (source == null) return fallback;
final value = source[key];
if (value is String) return value;
if (value == null) return fallback;
return value.toString();
}
bool? _readBool(Map<String, dynamic>? source, String key, {bool? fallback}) {
if (source == null) return fallback;
final value = source[key];
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final normalized = value.trim().toLowerCase();
if (normalized.isEmpty) return fallback;
return ['1', 'y', 'yes', 'true'].contains(normalized);
}
return fallback;
}
Map<String, dynamic>? _asMap(dynamic value) =>
value is Map<String, dynamic> ? value : null;
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}

View File

@@ -0,0 +1,78 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval_draft.dart';
import '../../domain/repositories/approval_draft_repository.dart';
import '../dtos/approval_draft_dto.dart';
/// 결재 초안을 원격 저장소로 관리한다.
class ApprovalDraftRepositoryRemote implements ApprovalDraftRepository {
ApprovalDraftRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
@override
Future<PaginatedResult<ApprovalDraftSummary>> list(
ApprovalDraftListFilter filter,
) async {
final query = ApiClient.buildQuery(
page: filter.page,
pageSize: filter.pageSize,
filters: {
'requester_id': filter.requesterId,
if (filter.transactionId != null)
'transaction_id': filter.transactionId,
if (filter.includeExpired) 'include_expired': filter.includeExpired,
},
);
final response = await _api.get<Map<String, dynamic>>(
ApiRoutes.approvalDrafts,
query: query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDraftDto.parsePaginated(response.data);
}
@override
Future<ApprovalDraftDetail?> fetch({
required int id,
required int requesterId,
}) async {
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
final response = await _api.get<Map<String, dynamic>>(
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
query: query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDraftDto.parseDetail(response.data);
}
@override
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input) async {
final payload = input.toJson();
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalDrafts,
data: payload,
options: Options(responseType: ResponseType.json),
);
final detail = ApprovalDraftDto.parseDetail(response.data);
if (detail == null) {
throw const FormatException('초안 저장 응답이 비어 있습니다.');
}
return detail;
}
@override
Future<void> delete({required int id, required int requesterId}) async {
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
await _api.delete<void>(
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
query: query,
options: Options(responseType: ResponseType.json),
);
}
}

View File

@@ -1,10 +1,17 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_error.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/errors/approval_access_denied_exception.dart';
import '../../domain/repositories/approval_repository.dart';
import '../dtos/approval_audit_dto.dart';
import '../dtos/approval_dto.dart';
import '../dtos/approval_proceed_status_dto.dart';
import '../dtos/approval_request_dto.dart';
/// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체.
///
@@ -15,35 +22,49 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
final ApiClient _api;
static const _basePath = '/approvals';
static const _basePath = ApiRoutes.approvals;
/// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다.
@override
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
String? query,
String? status,
DateTime? from,
DateTime? to,
int? transactionId,
int? approvalStatusId,
int? requestedById,
List<String>? statusCodes,
bool includePending = false,
bool includeHistories = false,
bool includeSteps = false,
}) async {
final includeParts = <String>['requested_by', 'transaction'];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final query = ApiClient.buildQuery(
page: page,
pageSize: pageSize,
include: includeParts,
filters: {
if (transactionId != null) 'transaction_id': transactionId,
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
if (requestedById != null) 'requested_by_id': requestedById,
if (statusCodes != null && statusCodes.isNotEmpty)
'status': statusCodes,
if (includePending) 'include_pending': includePending,
},
);
return _guardApprovalAccess(() async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (status != null && status.isNotEmpty) 'status': status,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
if (includeHistories) 'include_histories': true,
if (includeSteps) 'include_steps': true,
},
query: query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.parsePaginated(response.data ?? const {});
});
}
/// 결재 상세를 조회한다. 단계/이력 포함 여부를 쿼리 파라미터로 제어한다.
@@ -53,24 +74,116 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
bool includeSteps = true,
bool includeHistories = true,
}) async {
final includeParts = <String>['transaction', 'requested_by'];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final query = ApiClient.buildQuery(include: includeParts);
return _guardApprovalAccess(() async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {
if (includeSteps) 'include_steps': true,
if (includeHistories) 'include_histories': true,
},
ApiClient.buildPath(_basePath, [id]),
query: query.isEmpty ? null : query,
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
return _mapApprovalFromResponse(response.data);
});
}
@override
Future<Approval> submit(ApprovalSubmissionInput input) async {
final payload = ApprovalSubmitRequestDto(
approval: ApprovalCreatePayloadDto.fromSubmission(input),
steps: _mapSteps(input.steps),
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('submit'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> resubmit(ApprovalResubmissionInput input) async {
final payload = ApprovalResubmitRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
steps: _mapSteps(input.submission.steps),
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('resubmit'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> approve(ApprovalDecisionInput input) async {
final payload = ApprovalDecisionRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('approve'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> reject(ApprovalDecisionInput input) async {
final payload = ApprovalDecisionRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('reject'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> recall(ApprovalRecallInput input) async {
final payload = ApprovalRecallRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('recall'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
/// 활성화된 결재 행위 목록을 조회한다.
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
final query = ApiClient.buildQuery(
page: 1,
pageSize: 100,
filters: {if (activeOnly) 'active': true},
);
final response = await _api.get<Map<String, dynamic>>(
'/approval-actions',
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
ApiRoutes.approvalActions,
query: query,
options: Options(responseType: ResponseType.json),
);
final items = (response.data?['items'] as List<dynamic>? ?? [])
@@ -81,11 +194,50 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return items;
}
@override
Future<PaginatedResult<ApprovalHistory>> listHistory({
required int approvalId,
int page = 1,
int pageSize = 20,
DateTime? from,
DateTime? to,
int? actorId,
int? approvalActionId,
}) async {
final query = ApiClient.buildQuery(
page: page,
pageSize: pageSize,
filters: {
'approval_id': approvalId,
if (from != null) 'action_from': from,
if (to != null) 'action_to': to,
if (actorId != null) 'approver_id': actorId,
if (approvalActionId != null) 'approval_action_id': approvalActionId,
},
);
final response = await _api.get<Map<String, dynamic>>(
ApiRoutes.approvalHistory,
query: query,
options: Options(responseType: ResponseType.json),
);
final dto = ApprovalAuditListDto.fromJson(response.data ?? const {});
return PaginatedResult<ApprovalHistory>(
items: dto.items.map((e) => e.toEntity()).toList(growable: false),
page: dto.page,
pageSize: dto.pageSize,
total: dto.total,
);
}
/// 결재 단계 행위를 수행하고 업데이트된 결재 정보를 반환한다.
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
final path = ApiClient.buildPath(ApiRoutes.approvalSteps, [
input.stepId,
'actions',
]);
final response = await _api.post<Map<String, dynamic>>(
'/approval-steps/${input.stepId}/actions',
path,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
@@ -101,8 +253,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
/// 결재 단계들을 일괄로 생성하거나 재배치한다.
@override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
final path = ApiClient.buildPath(_basePath, [input.approvalId, 'steps']);
final response = await _api.post<Map<String, dynamic>>(
'/approvals/${input.approvalId}/steps',
path,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
@@ -115,45 +268,86 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(approvalJson).toEntity();
}
/// 결재가 다음 단계로 진행 가능한지 확인한다.
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
final response = await _api.get<Map<String, dynamic>>(
ApiClient.buildPath(_basePath, [id, 'can-proceed']),
options: Options(responseType: ResponseType.json),
);
return ApprovalProceedStatusDto.fromJson(
_api.unwrapAsMap(response),
).toEntity();
}
/// 새로운 결재를 생성한다.
@override
Future<Approval> create(ApprovalInput input) async {
Future<Approval> create(ApprovalCreateInput input) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
return _mapApprovalFromResponse(response.data);
}
/// 결재 기본 정보를 수정한다.
@override
Future<Approval> update(int id, ApprovalInput input) async {
Future<Approval> update(ApprovalUpdateInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
ApiClient.buildPath(_basePath, [input.id]),
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
return _mapApprovalFromResponse(response.data);
}
/// 결재를 삭제(비활성화)한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
await _api.delete<void>(ApiClient.buildPath(_basePath, [id]));
}
/// 삭제된 결재를 복구한다.
@override
Future<Approval> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
ApiClient.buildPath(_basePath, [id, 'restore']),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
return _mapApprovalFromResponse(response.data);
}
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
Approval _mapApprovalFromResponse(Map<String, dynamic>? body) {
final payload = _extractApprovalPayload(body);
if (payload.isEmpty) {
throw StateError('결재 응답에 결재 데이터가 없습니다.');
}
return ApprovalDto.fromJson(payload).toEntity();
}
Map<String, dynamic> _extractApprovalPayload(Map<String, dynamic>? body) {
if (body == null || body.isEmpty) {
return const <String, dynamic>{};
}
final data = body['data'];
if (data is Map<String, dynamic>) {
final approval = _selectApprovalPayload(data);
if (approval != null) {
return approval;
}
return Map<String, dynamic>.from(data);
}
final approval = _selectApprovalPayload(body);
if (approval != null) {
return approval;
}
return Map<String, dynamic>.from(body);
}
List<ApprovalStepInputDto> _mapSteps(List<ApprovalStepAssignmentItem> items) {
return items.map(ApprovalStepInputDto.fromDomain).toList(growable: false);
}
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
@@ -161,21 +355,277 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
Map<String, dynamic> body,
) {
final data = body['data'];
if (data is Map<String, dynamic>) {
if (data['approval'] is Map<String, dynamic>) {
return data['approval'] as Map<String, dynamic>;
final dataMap = data is Map<String, dynamic> ? data : null;
Map<String, dynamic>? approval = _selectApprovalPayload(dataMap);
approval ??= _selectApprovalPayload(body);
if (approval == null) {
return null;
}
if (data['approval_data'] is Map<String, dynamic>) {
return data['approval_data'] as Map<String, dynamic>;
final merged = Map<String, dynamic>.from(approval);
if (dataMap != null) {
final steps = _mergeStepsPayload(
existing: merged['steps'],
data: dataMap,
);
if (steps != null) {
merged['steps'] = steps;
}
final histories = _mergeHistoriesPayload(
existing: merged['histories'],
data: dataMap,
);
if (histories != null) {
merged['histories'] = histories;
}
}
return merged;
}
Map<String, dynamic>? _selectApprovalPayload(Map<String, dynamic>? source) {
if (source == null) {
return null;
}
if (source['approval'] is Map<String, dynamic>) {
return Map<String, dynamic>.from(
source['approval'] as Map<String, dynamic>,
);
}
if (source['approval_data'] is Map<String, dynamic>) {
return Map<String, dynamic>.from(
source['approval_data'] as Map<String, dynamic>,
);
}
final hasStatus =
data.containsKey('status') || data.containsKey('approval_status');
if (data.containsKey('approval_no') && hasStatus) {
return data;
source.containsKey('status') || source.containsKey('approval_status');
if (source.containsKey('approval_no') && hasStatus) {
return Map<String, dynamic>.from(source);
}
if (source['approval'] == null && source['data'] is Map<String, dynamic>) {
return _selectApprovalPayload(source['data'] as Map<String, dynamic>);
}
return null;
}
List<Map<String, dynamic>>? _mergeStepsPayload({
required dynamic existing,
required Map<String, dynamic> data,
}) {
final steps = <Map<String, dynamic>>[];
void upsert(Map<String, dynamic> step) {
final id = step['id'] as int?;
final order = step['step_order'] as int?;
final index = steps.indexWhere((element) {
final elementId = element['id'] as int?;
if (elementId != null && id != null) {
return elementId == id;
}
if (order != null) {
return element['step_order'] == order;
}
return false;
});
final copy = Map<String, dynamic>.from(step);
if (index >= 0) {
steps[index] = copy;
} else {
steps.add(copy);
}
}
if (existing is List) {
for (final item in existing) {
if (item is Map<String, dynamic>) {
upsert(item);
}
}
}
final responseSteps = data['steps'];
if (responseSteps is List) {
for (final item in responseSteps) {
if (item is Map<String, dynamic>) {
upsert(item);
}
}
}
if (data['step'] is Map<String, dynamic>) {
upsert(data['step'] as Map<String, dynamic>);
}
if (data['next_step'] is Map<String, dynamic>) {
upsert(data['next_step'] as Map<String, dynamic>);
}
if (steps.isEmpty) {
return existing is List
? existing
.whereType<Map<String, dynamic>>()
.map((step) => Map<String, dynamic>.from(step))
.toList()
: null;
}
steps.sort((a, b) {
final orderA = a['step_order'] as int? ?? 0;
final orderB = b['step_order'] as int? ?? 0;
return orderA.compareTo(orderB);
});
return steps;
}
List<Map<String, dynamic>>? _mergeHistoriesPayload({
required dynamic existing,
required Map<String, dynamic> data,
}) {
final histories = <Map<String, dynamic>>[];
void append(Map<String, dynamic> history) {
histories.add(Map<String, dynamic>.from(history));
}
if (existing is List) {
for (final item in existing) {
if (item is Map<String, dynamic>) {
append(item);
}
}
}
final responseHistories = data['histories'];
if (responseHistories is List) {
for (final item in responseHistories) {
if (item is Map<String, dynamic>) {
append(item);
}
}
}
if (data['history'] is Map<String, dynamic>) {
append(data['history'] as Map<String, dynamic>);
}
if (histories.isEmpty) {
return existing is List
? existing
.whereType<Map<String, dynamic>>()
.map((history) => Map<String, dynamic>.from(history))
.toList()
: null;
}
DateTime? parseTime(Map<String, dynamic> json) {
String? read(dynamic value) {
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
return null;
}
final raw =
read(json['action_at']) ??
read(json['created_at']) ??
read(json['updated_at']);
if (raw == null) {
return null;
}
return DateTime.tryParse(raw);
}
histories.sort((a, b) {
final timeA = parseTime(a);
final timeB = parseTime(b);
if (timeA == null && timeB == null) {
return 0;
}
if (timeA == null) {
return 1;
}
if (timeB == null) {
return -1;
}
return timeA.compareTo(timeB);
});
return histories;
}
Future<T> _guardApprovalAccess<T>(Future<T> Function() action) async {
try {
return await action();
} on ApiException catch (error) {
if (_isApprovalAccessDenied(error)) {
throw ApprovalAccessDeniedException(
message: _accessDeniedMessage(error),
cause: error,
);
}
rethrow;
}
}
bool _isApprovalAccessDenied(ApiException error) {
if (error.code != ApiErrorCode.forbidden) {
return false;
}
final serverCode = _extractServerErrorCode(error);
if (serverCode != null &&
serverCode.toUpperCase() == 'APPROVAL_ACCESS_DENIED') {
return true;
}
final message = error.message.trim().toLowerCase();
if (message.contains('approval access denied')) {
return true;
}
final reasons = error.details?.values
.whereType<String>()
.map((value) => value.toLowerCase())
.toList(growable: false);
if (reasons != null &&
reasons.any((value) => value.contains('approval access denied'))) {
return true;
}
return false;
}
String _accessDeniedMessage(ApiException error) {
final message = error.message.trim();
if (message.isNotEmpty) {
return message;
}
final code = _extractServerErrorCode(error);
if (code != null && code.isNotEmpty) {
return '결재를 조회할 권한이 없습니다. (code: $code)';
}
return '결재를 조회할 권한이 없습니다. 관리자에게 권한을 요청하세요.';
}
String? _extractServerErrorCode(ApiException error) {
final details = error.details;
if (details != null) {
final detailCode = details['code'] ?? details['error_code'];
if (detailCode is String && detailCode.trim().isNotEmpty) {
return detailCode.trim();
}
}
final data = error.cause?.response?.data;
return _readErrorCodeFromPayload(data);
}
String? _readErrorCodeFromPayload(dynamic data) {
if (data is Map<String, dynamic>) {
final direct = data['error_code'] ?? data['code'];
if (direct is String && direct.trim().isNotEmpty) {
return direct.trim();
}
final errorNode = data['error'];
if (errorNode is Map<String, dynamic>) {
final nested = errorNode['code'] ?? errorNode['error_code'];
if (nested is String && nested.trim().isNotEmpty) {
return nested.trim();
}
}
if (body['approval'] is Map<String, dynamic>) {
return body['approval'] as Map<String, dynamic>;
}
return null;
}

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../../core/common/models/paginated_result.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_routes.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../dtos/approval_template_dto.dart';
@@ -16,7 +17,10 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
final ApiClient _api;
static const _basePath = '/approval-templates';
static const _basePaths = <String>[
ApiRoutes.approvalTemplates,
ApiRoutes.approvalTemplatesLegacy,
];
/// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다.
@override
@@ -26,8 +30,9 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
String? query,
bool? isActive,
}) async {
return _withTemplateRoute((basePath) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
basePath,
query: {
'page': page,
'page_size': pageSize,
@@ -37,6 +42,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.parsePaginated(response.data);
});
}
/// 템플릿 상세 정보를 조회한다. 필요 시 단계 포함 여부를 지정한다.
@@ -45,15 +51,17 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
int id, {
bool includeSteps = true,
}) async {
return _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id]);
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
path,
query: {if (includeSteps) 'include': 'steps'},
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalTemplateDto.fromJson(
data,
_api.unwrapAsMap(response),
).toEntity(includeSteps: includeSteps);
});
}
/// 템플릿을 생성하고 필요하면 단계까지 함께 등록한다.
@@ -62,19 +70,20 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
}) async {
return _withTemplateRoute((basePath) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
basePath,
data: input.toCreatePayload(),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
final created = ApprovalTemplateDto.fromJson(
data,
_api.unwrapAsMap(response),
).toEntity(includeSteps: false);
if (steps.isNotEmpty) {
await _postSteps(created.id, steps);
await _postSteps(created.id, steps, basePath: basePath);
}
return fetchDetail(created.id, includeSteps: true);
});
}
/// 템플릿 기본 정보와 단계 구성을 수정한다.
@@ -84,42 +93,54 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
}) async {
return _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id]);
await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
path,
data: input.toUpdatePayload(id),
options: Options(responseType: ResponseType.json),
);
if (steps != null) {
await _patchSteps(id, steps);
await _patchSteps(id, steps, basePath: basePath);
}
return fetchDetail(id, includeSteps: true);
});
}
/// 템플릿을 삭제한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
await _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id]);
await _api.delete<void>(path);
});
}
/// 삭제된 템플릿을 복구한다.
@override
Future<ApprovalTemplate> restore(int id) async {
return _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id, 'restore']);
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
path,
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalTemplateDto.fromJson(data).toEntity(includeSteps: false);
return ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: false);
});
}
/// 템플릿 단계 전체를 신규로 등록한다.
Future<void> _postSteps(
int templateId,
List<ApprovalTemplateStepInput> steps,
) async {
List<ApprovalTemplateStepInput> steps, {
required String basePath,
}) async {
if (steps.isEmpty) return;
final path = ApiClient.buildPath(basePath, [templateId, 'steps']);
await _api.post<Map<String, dynamic>>(
'$_basePath/$templateId/steps',
path,
data: {
'id': templateId,
'steps': steps.map((step) => step.toJson(includeId: false)).toList(),
@@ -131,10 +152,12 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
/// 템플릿 단계 정보를 부분 수정한다.
Future<void> _patchSteps(
int templateId,
List<ApprovalTemplateStepInput> steps,
) async {
List<ApprovalTemplateStepInput> steps, {
required String basePath,
}) async {
final path = ApiClient.buildPath(basePath, [templateId, 'steps']);
await _api.patch<Map<String, dynamic>>(
'$_basePath/$templateId/steps',
path,
data: {
'id': templateId,
'steps': steps.map((step) => step.toJson()).toList(),
@@ -142,4 +165,25 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
options: Options(responseType: ResponseType.json),
);
}
Future<T> _withTemplateRoute<T>(
Future<T> Function(String basePath) operation,
) async {
DioException? lastNotFound;
for (final basePath in _basePaths) {
try {
return await operation(basePath);
} on DioException catch (error) {
if (error.response?.statusCode == 404) {
lastNotFound = error;
continue;
}
rethrow;
}
}
if (lastNotFound != null) {
throw lastNotFound;
}
throw StateError('템플릿 경로 후보가 정의되지 않았습니다.');
}
}

View File

@@ -6,7 +6,9 @@ class Approval {
Approval({
this.id,
required this.approvalNo,
this.transactionId,
required this.transactionNo,
this.transactionUpdatedAt,
required this.status,
this.currentStep,
required this.requester,
@@ -23,7 +25,9 @@ class Approval {
final int? id;
final String approvalNo;
final int? transactionId;
final String transactionNo;
final DateTime? transactionUpdatedAt;
final ApprovalStatus status;
final ApprovalStep? currentStep;
final ApprovalRequester requester;
@@ -40,7 +44,9 @@ class Approval {
Approval copyWith({
int? id,
String? approvalNo,
int? transactionId,
String? transactionNo,
DateTime? transactionUpdatedAt,
ApprovalStatus? status,
ApprovalStep? currentStep,
ApprovalRequester? requester,
@@ -57,7 +63,9 @@ class Approval {
return Approval(
id: id ?? this.id,
approvalNo: approvalNo ?? this.approvalNo,
transactionId: transactionId ?? this.transactionId,
transactionNo: transactionNo ?? this.transactionNo,
transactionUpdatedAt: transactionUpdatedAt ?? this.transactionUpdatedAt,
status: status ?? this.status,
currentStep: currentStep ?? this.currentStep,
requester: requester ?? this.requester,
@@ -75,11 +83,35 @@ class Approval {
}
class ApprovalStatus {
ApprovalStatus({required this.id, required this.name, this.color});
ApprovalStatus({
required this.id,
required this.name,
this.color,
this.isBlockingNext = true,
this.isTerminal = false,
});
final int id;
final String name;
final String? color;
final bool isBlockingNext;
final bool isTerminal;
ApprovalStatus copyWith({
int? id,
String? name,
String? color,
bool? isBlockingNext,
bool? isTerminal,
}) {
return ApprovalStatus(
id: id ?? this.id,
name: name ?? this.name,
color: color ?? this.color,
isBlockingNext: isBlockingNext ?? this.isBlockingNext,
isTerminal: isTerminal ?? this.isTerminal,
);
}
}
class ApprovalRequester {
@@ -97,43 +129,71 @@ class ApprovalRequester {
class ApprovalStep {
ApprovalStep({
this.id,
this.requestId,
required this.stepOrder,
this.templateStepId,
this.approverRole,
required this.approver,
required this.status,
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
this.actionAt,
this.isOptional = false,
this.escalationMinutes,
this.metadata,
});
final int? id;
final int? requestId;
final int stepOrder;
final int? templateStepId;
final String? approverRole;
final ApprovalApprover approver;
final ApprovalStatus status;
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
final DateTime? actionAt;
final bool isOptional;
final int? escalationMinutes;
final Map<String, dynamic>? metadata;
ApprovalStep copyWith({
int? id,
int? requestId,
int? stepOrder,
int? templateStepId,
String? approverRole,
ApprovalApprover? approver,
ApprovalStatus? status,
DateTime? assignedAt,
DateTime? decidedAt,
String? note,
bool? isDeleted,
DateTime? actionAt,
bool? isOptional,
int? escalationMinutes,
Map<String, dynamic>? metadata,
}) {
return ApprovalStep(
id: id ?? this.id,
requestId: requestId ?? this.requestId,
stepOrder: stepOrder ?? this.stepOrder,
templateStepId: templateStepId ?? this.templateStepId,
approverRole: approverRole ?? this.approverRole,
approver: approver ?? this.approver,
status: status ?? this.status,
assignedAt: assignedAt ?? this.assignedAt,
decidedAt: decidedAt ?? this.decidedAt,
note: note ?? this.note,
isDeleted: isDeleted ?? this.isDeleted,
actionAt: actionAt ?? this.actionAt,
isOptional: isOptional ?? this.isOptional,
escalationMinutes: escalationMinutes ?? this.escalationMinutes,
metadata: metadata ?? this.metadata,
);
}
}
@@ -159,6 +219,8 @@ class ApprovalHistory {
required this.approver,
required this.actionAt,
this.note,
this.actionCode,
this.payload,
});
final int? id;
@@ -168,13 +230,16 @@ class ApprovalHistory {
final ApprovalApprover approver;
final DateTime actionAt;
final String? note;
final String? actionCode;
final Map<String, dynamic>? payload;
}
class ApprovalAction {
ApprovalAction({required this.id, required this.name});
ApprovalAction({required this.id, required this.name, this.code});
final int id;
final String name;
final String? code;
}
/// 결재 단계에서 수행 가능한 행위 타입
@@ -198,14 +263,50 @@ extension ApprovalStepActionTypeX on ApprovalStepActionType {
}
/// 결재 생성 입력 모델
class ApprovalInput {
ApprovalInput({required this.transactionId, this.note});
/// 결재 신규 생성 입력 모델
///
/// - 트랜잭션, 결재번호, 상태, 상신자 정보를 백엔드 계약에 맞춰 전달한다.
class ApprovalCreateInput {
ApprovalCreateInput({
required this.transactionId,
required this.approvalStatusId,
required this.requestedById,
this.note,
});
final int transactionId;
final int approvalStatusId;
final int requestedById;
final String? note;
Map<String, dynamic> toPayload() {
return {'transaction_id': transactionId, 'note': note};
final trimmedNote = note?.trim();
return {
'transaction_id': transactionId,
'approval_status_id': approvalStatusId,
'requested_by_id': requestedById,
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
};
}
}
/// 결재 기본 정보 수정 입력 모델
///
/// - 상태/비고 변경 시 결재 식별자를 포함해 패치를 수행한다.
class ApprovalUpdateInput {
ApprovalUpdateInput({required this.id, this.approvalStatusId, this.note});
final int id;
final int? approvalStatusId;
final String? note;
Map<String, dynamic> toPayload() {
final trimmedNote = note?.trim();
return {
'id': id,
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
};
}
}
@@ -264,3 +365,85 @@ class ApprovalStepAssignmentItem {
};
}
}
/// 결재 상신 입력 모델.
class ApprovalSubmissionInput {
ApprovalSubmissionInput({
this.transactionId,
this.templateId,
required this.statusId,
required this.requesterId,
this.finalApproverId,
this.requestedAt,
this.decidedAt,
this.cancelledAt,
this.lastActionAt,
this.title,
this.summary,
this.note,
this.metadata,
this.steps = const [],
});
final int? transactionId;
final int? templateId;
final int statusId;
final int requesterId;
final int? finalApproverId;
final DateTime? requestedAt;
final DateTime? decidedAt;
final DateTime? cancelledAt;
final DateTime? lastActionAt;
final String? title;
final String? summary;
final String? note;
final Map<String, dynamic>? metadata;
final List<ApprovalStepAssignmentItem> steps;
}
/// 결재 승인/반려 입력 모델.
class ApprovalDecisionInput {
ApprovalDecisionInput({
required this.approvalId,
required this.actorId,
this.note,
this.expectedUpdatedAt,
});
final int approvalId;
final int actorId;
final String? note;
final DateTime? expectedUpdatedAt;
}
/// 결재 회수 입력 모델.
class ApprovalRecallInput extends ApprovalDecisionInput {
ApprovalRecallInput({
required super.approvalId,
required super.actorId,
super.note,
super.expectedUpdatedAt,
this.transactionExpectedUpdatedAt,
});
final DateTime? transactionExpectedUpdatedAt;
}
/// 결재 재상신 입력 모델.
class ApprovalResubmissionInput {
ApprovalResubmissionInput({
required this.approvalId,
required this.actorId,
required this.submission,
this.note,
this.expectedUpdatedAt,
this.transactionExpectedUpdatedAt,
});
final int approvalId;
final int actorId;
final ApprovalSubmissionInput submission;
final String? note;
final DateTime? expectedUpdatedAt;
final DateTime? transactionExpectedUpdatedAt;
}

View File

@@ -0,0 +1,286 @@
import 'dart:collection';
import 'approval.dart';
/// 결재 초안 상태를 표현하는 열거형.
enum ApprovalDraftStatus { active, expired, archived }
/// 결재 초안 단계 정보를 나타낸다.
class ApprovalDraftStep {
ApprovalDraftStep({
required this.stepOrder,
required this.approverId,
this.approverRole,
this.note,
this.isOptional = false,
});
final int stepOrder;
final int approverId;
final String? approverRole;
final String? note;
final bool isOptional;
ApprovalStepAssignmentItem toAssignment() {
return ApprovalStepAssignmentItem(
stepOrder: stepOrder,
approverId: approverId,
note: note,
);
}
Map<String, dynamic> toJson() {
final trimmedNote = note?.trim();
return {
'step_order': stepOrder,
'approver_id': approverId,
if (approverRole != null && approverRole!.trim().isNotEmpty)
'approver_role': approverRole,
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
'is_optional': isOptional,
};
}
}
/// 결재 초안 본문을 나타낸다.
class ApprovalDraftPayload {
ApprovalDraftPayload({
this.title,
this.summary,
this.note,
this.templateId,
Map<String, dynamic>? metadata,
List<ApprovalDraftStep>? steps,
}) : metadata = metadata == null
? null
: Map.unmodifiable(Map<String, dynamic>.from(metadata)),
_steps = steps == null
? const []
: List<ApprovalDraftStep>.unmodifiable(steps);
final String? title;
final String? summary;
final String? note;
final int? templateId;
final Map<String, dynamic>? metadata;
final List<ApprovalDraftStep> _steps;
UnmodifiableListView<ApprovalDraftStep> get steps =>
UnmodifiableListView(_steps);
List<ApprovalStepAssignmentItem> toAssignments() {
return _steps.map((step) => step.toAssignment()).toList(growable: false);
}
}
/// 결재 초안 요약 정보를 담는다.
class ApprovalDraftSummary {
ApprovalDraftSummary({
required this.id,
required this.requesterId,
required this.status,
required this.savedAt,
this.requestId,
this.transactionId,
this.templateId,
this.title,
this.summary,
this.expiresAt,
this.sessionKey,
this.stepCount = 0,
});
final int id;
final int requesterId;
final ApprovalDraftStatus status;
final DateTime savedAt;
final int? requestId;
final int? transactionId;
final int? templateId;
final String? title;
final String? summary;
final DateTime? expiresAt;
final String? sessionKey;
final int stepCount;
}
/// 결재 초안 상세 정보를 나타낸다.
class ApprovalDraftDetail {
ApprovalDraftDetail({
required this.id,
required this.requesterId,
required this.payload,
required this.savedAt,
this.transactionId,
this.templateId,
this.expiresAt,
this.sessionKey,
});
final int id;
final int requesterId;
final ApprovalDraftPayload payload;
final DateTime savedAt;
final int? transactionId;
final int? templateId;
final DateTime? expiresAt;
final String? sessionKey;
Map<String, dynamic>? get sanitizedMetadata =>
_stripClientState(payload.metadata);
ApprovalSubmissionInput toSubmissionInput({
int? defaultStatusId,
int? transactionIdOverride,
}) {
final statusId = _extractStatusId(payload.metadata) ?? defaultStatusId ?? 0;
final assignments = payload.toAssignments();
final cleanedMetadata = _stripClientState(payload.metadata);
return ApprovalSubmissionInput(
transactionId: transactionIdOverride ?? transactionId,
templateId: payload.templateId ?? templateId,
statusId: statusId,
requesterId: requesterId,
finalApproverId: assignments.isEmpty ? null : assignments.last.approverId,
title: payload.title,
summary: payload.summary,
note: payload.note,
metadata: cleanedMetadata,
steps: assignments,
);
}
}
/// 결재 초안 목록 필터.
class ApprovalDraftListFilter {
const ApprovalDraftListFilter({
required this.requesterId,
this.page = 1,
this.pageSize = 20,
this.transactionId,
this.includeExpired = false,
}) : assert(page > 0, 'page는 1 이상이어야 합니다.');
final int requesterId;
final int page;
final int pageSize;
final int? transactionId;
final bool includeExpired;
Map<String, dynamic> toQuery() {
return {
'page': page,
'page_size': pageSize,
'requester_id': requesterId,
if (transactionId != null) 'transaction_id': transactionId,
if (includeExpired) 'include_expired': includeExpired,
};
}
}
/// 결재 초안 저장 입력 모델.
class ApprovalDraftSaveInput {
ApprovalDraftSaveInput({
required this.requesterId,
required List<ApprovalDraftStep> steps,
this.requestId,
this.transactionId,
this.templateId,
this.title,
this.summary,
this.note,
Map<String, dynamic>? metadata,
this.sessionKey,
this.statusId,
}) : metadata = metadata == null
? null
: Map.unmodifiable(Map<String, dynamic>.from(metadata)),
steps = List<ApprovalDraftStep>.unmodifiable(steps);
final int requesterId;
final List<ApprovalDraftStep> steps;
final int? requestId;
final int? transactionId;
final int? templateId;
final String? title;
final String? summary;
final String? note;
final Map<String, dynamic>? metadata;
final String? sessionKey;
final int? statusId;
bool get hasSteps => steps.isNotEmpty;
Map<String, dynamic> toJson() {
final payload = <String, dynamic>{
'requester_id': requesterId,
if (requestId != null) 'request_id': requestId,
if (transactionId != null) 'transaction_id': transactionId,
if (templateId != null) 'template_id': templateId,
if (title != null && title!.trim().isNotEmpty) 'title': title,
if (summary != null && summary!.trim().isNotEmpty) 'summary': summary,
if (note != null && note!.trim().isNotEmpty) 'note': note,
if (sessionKey != null && sessionKey!.trim().isNotEmpty)
'session_key': sessionKey,
};
final mergedMetadata = _mergeStatus(source: metadata, statusId: statusId);
if (mergedMetadata != null && mergedMetadata.isNotEmpty) {
payload['metadata'] = mergedMetadata;
}
payload['steps'] = steps
.map((step) => step.toJson())
.toList(growable: false);
return payload;
}
}
const _clientStateKey = '_client_state';
const _statusKey = 'status_id';
Map<String, dynamic>? _mergeStatus({
Map<String, dynamic>? source,
int? statusId,
}) {
if (statusId == null) {
return source;
}
final merged = source == null
? <String, dynamic>{}
: Map<String, dynamic>.from(source);
final client = merged[_clientStateKey];
final state = client is Map<String, dynamic>
? Map<String, dynamic>.from(client)
: <String, dynamic>{};
state[_statusKey] = statusId;
merged[_clientStateKey] = state;
return merged;
}
int? _extractStatusId(Map<String, dynamic>? metadata) {
if (metadata == null || metadata.isEmpty) {
return null;
}
final client = metadata[_clientStateKey];
if (client is Map<String, dynamic>) {
final value = client[_statusKey];
if (value is int) {
return value;
}
if (value is String) {
return int.tryParse(value);
}
}
return null;
}
Map<String, dynamic>? _stripClientState(Map<String, dynamic>? metadata) {
if (metadata == null || metadata.isEmpty) {
return metadata;
}
if (!metadata.containsKey(_clientStateKey)) {
return metadata;
}
final cloned = Map<String, dynamic>.from(metadata);
cloned.remove(_clientStateKey);
return cloned.isEmpty ? null : cloned;
}

View File

@@ -0,0 +1,167 @@
import '../entities/approval.dart';
/// 결재 흐름(Approval Flow)을 표현하는 도메인 엔티티.
///
/// - 상신자, 최종 승인자, 단계 목록, 이력, 상태 요약을 한 번에 제공한다.
/// - presentation 레이어에서는 이 엔티티만 의존해 UI를 구성한다.
class ApprovalFlow {
ApprovalFlow({
required Approval approval,
ApprovalApprover? finalApprover,
ApprovalFlowStatusSummary? statusSummary,
}) : _approval = approval,
finalApprover = finalApprover ?? _inferFinalApprover(approval.steps),
statusSummary =
statusSummary ??
ApprovalFlowStatusSummary.from(
status: approval.status,
steps: approval.steps,
currentStep: approval.currentStep,
),
_steps = List<ApprovalStep>.unmodifiable(approval.steps),
_histories = List<ApprovalHistory>.unmodifiable(approval.histories);
/// 결재 원본 데이터
final Approval _approval;
/// 결재 단계 목록
final List<ApprovalStep> _steps;
/// 결재 이력 목록
final List<ApprovalHistory> _histories;
/// 최종 승인자 정보 (단계 목록 기반 추론 결과)
final ApprovalApprover? finalApprover;
/// 결재 상태 요약 정보
final ApprovalFlowStatusSummary statusSummary;
/// 원본 결재 엔티티에 접근한다.
Approval get approval => _approval;
/// 결재 식별자(ID)
int? get id => _approval.id;
/// 결재 번호(APP-YYYYMMDDNNNN 형식)
String get approvalNo => _approval.approvalNo;
/// 연동된 전표 번호
String get transactionNo => _approval.transactionNo;
/// 연동된 전표 ID
int? get transactionId => _approval.transactionId;
/// 연동된 전표 최신 수정 시각
DateTime? get transactionUpdatedAt => _approval.transactionUpdatedAt;
/// 현재 결재 상태
ApprovalStatus get status => _approval.status;
/// 현재 진행 중인 단계 정보
ApprovalStep? get currentStep => _approval.currentStep;
/// 상신자 정보
ApprovalRequester get requester => _approval.requester;
/// 상신 일시
DateTime get requestedAt => _approval.requestedAt;
/// 최종 결정 일시
DateTime? get decidedAt => _approval.decidedAt;
/// 결재 메모
String? get note => _approval.note;
/// 생성 일시
DateTime? get createdAt => _approval.createdAt;
/// 변경 일시
DateTime? get updatedAt => _approval.updatedAt;
/// 단계 목록을 반환한다.
List<ApprovalStep> get steps => _steps;
/// 이력 목록을 반환한다.
List<ApprovalHistory> get histories => _histories;
/// [Approval] 엔티티에서 [ApprovalFlow]를 생성하는 팩토리.
factory ApprovalFlow.fromApproval(Approval approval) =>
ApprovalFlow(approval: approval);
static ApprovalApprover? _inferFinalApprover(List<ApprovalStep> steps) {
if (steps.isEmpty) {
return null;
}
final sorted = List<ApprovalStep>.from(steps)
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
return sorted.last.approver;
}
}
/// 결재 상태 요약 정보.
///
/// - 전체 단계 수, 완료 단계 수, 대기 단계 수, 현재 단계 순번을 제공한다.
class ApprovalFlowStatusSummary {
ApprovalFlowStatusSummary({
required this.status,
required this.totalSteps,
required this.completedSteps,
required this.pendingSteps,
this.currentStepOrder,
});
/// 전체 결재 상태
final ApprovalStatus status;
/// 총 단계 수
final int totalSteps;
/// 완료된 단계 수
final int completedSteps;
/// 대기 중인 단계 수
final int pendingSteps;
/// 현재 진행 중인 단계 순번 (없으면 null)
final int? currentStepOrder;
/// 완료율(%)을 정수로 반환한다.
int get completionRate {
if (totalSteps <= 0) {
return 0;
}
final ratio = (completedSteps / totalSteps) * 100;
return ratio.isFinite ? ratio.round() : 0;
}
/// 결재 상태와 단계 목록을 기반으로 요약 정보를 생성한다.
factory ApprovalFlowStatusSummary.from({
required ApprovalStatus status,
required List<ApprovalStep> steps,
ApprovalStep? currentStep,
}) {
final total = steps.length;
final completed = steps.where((step) => step.decidedAt != null).length;
final pending = total - completed;
final currentOrder = currentStep?.stepOrder ?? _findCurrentStepOrder(steps);
return ApprovalFlowStatusSummary(
status: status,
totalSteps: total,
completedSteps: completed,
pendingSteps: pending < 0 ? 0 : pending,
currentStepOrder: currentOrder,
);
}
static int? _findCurrentStepOrder(List<ApprovalStep> steps) {
for (final step in steps) {
if (step.decidedAt == null) {
return step.stepOrder;
}
}
if (steps.isEmpty) {
return null;
}
return steps.last.stepOrder;
}
}

View File

@@ -0,0 +1,14 @@
/// 결재 진행 가능 여부(can-proceed) 응답 엔티티.
///
/// - 백엔드 `GET /approvals/{id}/can-proceed` 결과를 표현한다.
class ApprovalProceedStatus {
const ApprovalProceedStatus({
required this.approvalId,
required this.canProceed,
this.reason,
});
final int approvalId;
final bool canProceed;
final String? reason;
}

View File

@@ -0,0 +1,21 @@
import '../../../../core/network/api_error.dart';
/// 결재 열람 권한이 없을 때 던지는 예외.
///
/// - 목록/상세 API가 `403`(`APPROVAL_ACCESS_DENIED`)을 반환하면 이 예외로 변환한다.
class ApprovalAccessDeniedException implements Exception {
const ApprovalAccessDeniedException({
this.message = '결재를 조회할 권한이 없습니다.',
this.cause,
});
/// 사용자에게 노출할 안내 메시지.
final String message;
/// 원본 API 예외.
final ApiException? cause;
@override
String toString() =>
'ApprovalAccessDeniedException(message: $message, cause: $cause)';
}

View File

@@ -0,0 +1,19 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_draft.dart';
/// 결재 초안 저장소 인터페이스.
abstract class ApprovalDraftRepository {
Future<PaginatedResult<ApprovalDraftSummary>> list(
ApprovalDraftListFilter filter,
);
Future<ApprovalDraftDetail?> fetch({
required int id,
required int requesterId,
});
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input);
Future<void> delete({required int id, required int requesterId});
}

View File

@@ -1,6 +1,7 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval.dart';
import '../entities/approval_proceed_status.dart';
/// 결재 도메인에서 사용하는 저장소 인터페이스.
///
@@ -10,10 +11,11 @@ abstract class ApprovalRepository {
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
String? query,
String? status,
DateTime? from,
DateTime? to,
int? transactionId,
int? approvalStatusId,
int? requestedById,
List<String>? statusCodes,
bool includePending = false,
bool includeHistories = false,
bool includeSteps = false,
});
@@ -25,6 +27,32 @@ abstract class ApprovalRepository {
bool includeHistories = true,
});
/// 결재를 상신한다.
Future<Approval> submit(ApprovalSubmissionInput input);
/// 결재를 재상신한다.
Future<Approval> resubmit(ApprovalResubmissionInput input);
/// 결재를 승인한다.
Future<Approval> approve(ApprovalDecisionInput input);
/// 결재를 반려한다.
Future<Approval> reject(ApprovalDecisionInput input);
/// 결재를 회수한다.
Future<Approval> recall(ApprovalRecallInput input);
/// 결재 감사 로그를 조회한다.
Future<PaginatedResult<ApprovalHistory>> listHistory({
required int approvalId,
int page,
int pageSize,
DateTime? from,
DateTime? to,
int? actorId,
int? approvalActionId,
});
/// 활성화된 결재 행위(approve/reject/comment 등) 목록 조회
Future<List<ApprovalAction>> listActions({bool activeOnly = true});
@@ -34,11 +62,14 @@ abstract class ApprovalRepository {
/// 결재 단계 일괄 생성/재배치
Future<Approval> assignSteps(ApprovalStepAssignmentInput input);
/// 결재가 다음 단계로 진행 가능한지 여부를 확인한다.
Future<ApprovalProceedStatus> canProceed(int id);
/// 결재를 생성한다.
Future<Approval> create(ApprovalInput input);
Future<Approval> create(ApprovalCreateInput input);
/// 결재를 수정한다.
Future<Approval> update(int id, ApprovalInput input);
Future<Approval> update(ApprovalUpdateInput input);
/// 결재를 삭제한다.
Future<void> delete(int id);

View File

@@ -0,0 +1,58 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../entities/approval_template.dart';
import '../repositories/approval_repository.dart';
import '../repositories/approval_template_repository.dart';
/// 결재 템플릿을 결재 요청에 적용하는 유즈케이스.
///
/// - 템플릿 단계를 정렬해 [ApprovalStepAssignmentInput]으로 변환한 뒤 저장소에 위임한다.
class ApplyApprovalTemplateUseCase {
ApplyApprovalTemplateUseCase({
required ApprovalTemplateRepository templateRepository,
required ApprovalRepository approvalRepository,
}) : _templateRepository = templateRepository,
_approvalRepository = approvalRepository;
final ApprovalTemplateRepository _templateRepository;
final ApprovalRepository _approvalRepository;
/// [templateId]에 해당하는 템플릿을 [approvalId] 결재에 적용한다.
///
/// 템플릿에 단계가 없으면 [StateError]를 던진다.
Future<ApprovalFlow> call({
required int approvalId,
required int templateId,
}) async {
final template = await _templateRepository.fetchDetail(
templateId,
includeSteps: true,
);
if (template.steps.isEmpty) {
throw StateError('단계가 없는 결재 템플릿은 적용할 수 없습니다.');
}
final steps = _mapTemplateSteps(template);
final assignment = ApprovalStepAssignmentInput(
approvalId: approvalId,
steps: steps,
);
final approval = await _approvalRepository.assignSteps(assignment);
return ApprovalFlow.fromApproval(approval);
}
List<ApprovalStepAssignmentItem> _mapTemplateSteps(
ApprovalTemplate template,
) {
final sorted = List<ApprovalTemplateStep>.of(template.steps)
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
return sorted
.map(
(step) => ApprovalStepAssignmentItem(
stepOrder: step.stepOrder,
approverId: step.approver.id,
note: step.note,
),
)
.toList(growable: false);
}
}

View File

@@ -0,0 +1,33 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 승인하는 유즈케이스.
///
/// - 승인자는 [ApprovalDecisionInput]을 통해 필요한 정보를 전달한다.
class ApproveApprovalUseCase {
ApproveApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 승인하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
await _ensureCanProceed(input.approvalId);
final approval = await _repository.approve(input);
return ApprovalFlow.fromApproval(approval);
}
/// 결재 단계 진행 권한을 사전 확인한다.
Future<void> _ensureCanProceed(int approvalId) async {
final status = await _repository.canProceed(approvalId);
if (status.canProceed) {
return;
}
final reason = status.reason?.trim();
if (reason != null && reason.isNotEmpty) {
throw StateError(reason);
}
throw StateError('결재를 진행할 권한이 없습니다.');
}
}

View File

@@ -0,0 +1,13 @@
import '../repositories/approval_draft_repository.dart';
/// 결재 초안을 삭제하는 유즈케이스.
class DeleteApprovalDraftUseCase {
DeleteApprovalDraftUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<void> call({required int id, required int requesterId}) {
return _repository.delete(id: id, requesterId: requesterId);
}
}

View File

@@ -0,0 +1,17 @@
import '../entities/approval_draft.dart';
import '../repositories/approval_draft_repository.dart';
/// 결재 초안 상세를 조회하는 유즈케이스.
class GetApprovalDraftUseCase {
GetApprovalDraftUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<ApprovalDraftDetail?> call({
required int id,
required int requesterId,
}) {
return _repository.fetch(id: id, requesterId: requesterId);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_draft.dart';
import '../repositories/approval_draft_repository.dart';
/// 결재 초안 목록을 조회하는 유즈케이스.
class ListApprovalDraftsUseCase {
ListApprovalDraftsUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<PaginatedResult<ApprovalDraftSummary>> call(
ApprovalDraftListFilter filter,
) {
return _repository.list(filter);
}
}

View File

@@ -0,0 +1,19 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 회수(recall)하는 유즈케이스.
///
/// - 회수 가능 여부는 별도의 선행 검증으로 확인해야 한다.
class RecallApprovalUseCase {
RecallApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 회수하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalRecallInput input) async {
final approval = await _repository.recall(input);
return ApprovalFlow.fromApproval(approval);
}
}

View File

@@ -0,0 +1,33 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 반려하는 유즈케이스.
///
/// - 반려 사유 및 코멘트는 [ApprovalDecisionInput.note]로 전달한다.
class RejectApprovalUseCase {
RejectApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 반려하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
await _ensureCanProceed(input.approvalId);
final approval = await _repository.reject(input);
return ApprovalFlow.fromApproval(approval);
}
/// 결재 단계 진행 권한을 사전 확인한다.
Future<void> _ensureCanProceed(int approvalId) async {
final status = await _repository.canProceed(approvalId);
if (status.canProceed) {
return;
}
final reason = status.reason?.trim();
if (reason != null && reason.isNotEmpty) {
throw StateError(reason);
}
throw StateError('결재를 진행할 권한이 없습니다.');
}
}

View File

@@ -0,0 +1,19 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 재상신(resubmit)하는 유즈케이스.
///
/// - 재상신 시 수정된 단계 정보와 메모를 함께 전달한다.
class ResubmitApprovalUseCase {
ResubmitApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 재상신하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalResubmissionInput input) async {
final approval = await _repository.resubmit(input);
return ApprovalFlow.fromApproval(approval);
}
}

View File

@@ -0,0 +1,14 @@
import '../entities/approval_draft.dart';
import '../repositories/approval_draft_repository.dart';
/// 결재 초안을 서버에 저장하는 유즈케이스.
class SaveApprovalDraftUseCase {
SaveApprovalDraftUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<ApprovalDraftDetail> call(ApprovalDraftSaveInput input) {
return _repository.save(input);
}
}

View File

@@ -0,0 +1,24 @@
import '../entities/approval_template.dart';
import '../repositories/approval_template_repository.dart';
/// 결재 템플릿을 생성/수정하는 유즈케이스.
///
/// - [templateId]가 null이면 신규 생성, 값이 있으면 수정으로 처리한다.
class SaveApprovalTemplateUseCase {
SaveApprovalTemplateUseCase({required ApprovalTemplateRepository repository})
: _repository = repository;
final ApprovalTemplateRepository _repository;
/// 템플릿을 저장하고 최신 [ApprovalTemplate]을 반환한다.
Future<ApprovalTemplate> call({
int? templateId,
required ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
}) {
if (templateId == null) {
return _repository.create(input, steps: steps ?? const []);
}
return _repository.update(templateId, input, steps: steps);
}
}

View File

@@ -0,0 +1,19 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 상신(submit)하는 유즈케이스.
///
/// - 입력 파라미터는 [ApprovalSubmissionInput]을 사용한다.
class SubmitApprovalUseCase {
SubmitApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 상신하고 갱신된 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalSubmissionInput input) async {
final approval = await _repository.submit(input);
return ApprovalFlow.fromApproval(approval);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/json_utils.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_audit_dto.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_step_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import '../../domain/entities/approval_history_record.dart';

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
@@ -13,7 +14,15 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
final ApiClient _api;
static const _basePath = '/approval-histories';
static const _basePath = '${ApiRoutes.apiV1}/approval-histories';
static const _defaultInclude = <String>[
'approval',
'step',
'approval_action',
'approver',
'from_status',
'to_status',
];
/// 결재 이력 목록을 조회한다.
@override
@@ -21,20 +30,24 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
int page = 1,
int pageSize = 20,
String? query,
String? action,
int? approvalActionId,
DateTime? from,
DateTime? to,
}) async {
final resolvedQuery = ApiClient.buildQuery(
page: page,
pageSize: pageSize,
q: query,
include: _defaultInclude,
filters: {
if (from != null) 'action_from': from,
if (to != null) 'action_to': to,
if (approvalActionId != null) 'approval_action_id': approvalActionId,
},
);
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (action != null && action.isNotEmpty) 'action': action,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
},
query: resolvedQuery.isEmpty ? null : resolvedQuery,
options: Options(responseType: ResponseType.json),
);

View File

@@ -9,7 +9,7 @@ abstract class ApprovalHistoryRepository {
int page = 1,
int pageSize = 20,
String? query,
String? action,
int? approvalActionId,
DateTime? from,
DateTime? to,
});

View File

@@ -1,29 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../domain/entities/approval.dart';
import '../../../domain/entities/approval_flow.dart';
import '../../../domain/repositories/approval_repository.dart';
import '../../../domain/usecases/recall_approval_use_case.dart';
import '../../../domain/usecases/resubmit_approval_use_case.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
/// 결재 이력에서 필터링 가능한 행위 타입.
enum ApprovalHistoryActionFilter { all, approve, reject, comment }
/// 결재 이력 화면 탭 종류.
enum ApprovalHistoryTab { flow, audit }
/// 결재 이력 화면의 목록/필터 상태를 관리하는 컨트롤러.
///
/// 기간, 검색어, 행위 타입에 따라 목록을 조회하고 페이지 사이즈를 조절한다.
class ApprovalHistoryController extends ChangeNotifier {
ApprovalHistoryController({required ApprovalHistoryRepository repository})
: _repository = repository;
ApprovalHistoryController({
required ApprovalHistoryRepository repository,
ApprovalRepository? approvalRepository,
RecallApprovalUseCase? recallUseCase,
ResubmitApprovalUseCase? resubmitUseCase,
}) : _repository = repository,
_approvalRepository = approvalRepository,
_recallUseCase = recallUseCase,
_resubmitUseCase = resubmitUseCase;
final ApprovalHistoryRepository _repository;
final ApprovalRepository? _approvalRepository;
final RecallApprovalUseCase? _recallUseCase;
final ResubmitApprovalUseCase? _resubmitUseCase;
final Map<int, ApprovalFlow> _flowCache = <int, ApprovalFlow>{};
PaginatedResult<ApprovalHistoryRecord>? _result;
PaginatedResult<ApprovalHistory>? _auditResult;
bool _isLoading = false;
bool _isLoadingAudit = false;
bool _isPerformingAction = false;
bool _isLoadingFlow = false;
ApprovalHistoryTab _activeTab = ApprovalHistoryTab.flow;
String _query = '';
ApprovalHistoryActionFilter _actionFilter = ApprovalHistoryActionFilter.all;
DateTime? _from;
DateTime? _to;
String? _errorMessage;
int _pageSize = 20;
int _auditPageSize = 20;
int? _selectedApprovalId;
ApprovalFlow? _selectedFlow;
int? _auditActorId;
String? _auditActionCode;
DateTime? _auditFrom;
DateTime? _auditTo;
final Map<String, ApprovalAction> _auditActions = <String, ApprovalAction>{};
final Map<String, int> _actionIdsByCode = <String, int>{};
bool _hasLoadedActionCatalog = false;
bool _isSelectionForbidden = false;
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
bool get isLoading => _isLoading;
@@ -33,6 +69,30 @@ class ApprovalHistoryController extends ChangeNotifier {
DateTime? get to => _to;
String? get errorMessage => _errorMessage;
int get pageSize => _result?.pageSize ?? _pageSize;
PaginatedResult<ApprovalHistory>? get auditResult => _auditResult;
bool get isLoadingAudit => _isLoadingAudit;
bool get isPerformingAction => _isPerformingAction;
ApprovalHistoryTab get activeTab => _activeTab;
int get auditPageSize => _auditResult?.pageSize ?? _auditPageSize;
int? get selectedApprovalId => _selectedApprovalId;
bool get hasAuditSelection => _selectedApprovalId != null;
bool get hasAuditResults => _auditResult?.items.isNotEmpty ?? false;
bool get isLoadingFlow => _isLoadingFlow;
ApprovalFlow? get selectedFlow => _selectedFlow;
int? get auditActorId => _auditActorId;
String? get auditActionCode => _auditActionCode;
DateTime? get auditFrom => _auditFrom;
DateTime? get auditTo => _auditTo;
List<ApprovalAction> get auditActions {
if (_auditActions.isEmpty) {
return const <ApprovalAction>[];
}
final items = _auditActions.values.toList(growable: false);
items.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return items;
}
bool get isSelectionForbidden => _isSelectionForbidden;
/// 현재 필터 조건에 맞춰 결재 이력 목록을 불러온다.
///
@@ -42,31 +102,152 @@ class ApprovalHistoryController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final action = switch (_actionFilter) {
ApprovalHistoryActionFilter.all => null,
ApprovalHistoryActionFilter.approve => 'approve',
ApprovalHistoryActionFilter.reject => 'reject',
ApprovalHistoryActionFilter.comment => 'comment',
};
final resolvedPage = _resolvePage(page, _result);
final approvalActionId = await _resolveActionIdForFilter(_actionFilter);
final response = await _repository.list(
page: page,
page: resolvedPage,
pageSize: _pageSize,
query: _query.trim().isEmpty ? null : _query.trim(),
action: action,
approvalActionId: approvalActionId,
from: _from,
to: _to,
);
_result = response;
_pageSize = response.pageSize;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 지정한 결재의 감사 로그를 조회한다.
Future<void> fetchAuditLogs({required int approvalId, int page = 1}) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
_isLoadingAudit = true;
_errorMessage = null;
_selectedApprovalId = approvalId;
notifyListeners();
try {
final resolvedPage = _resolvePage(page, _auditResult);
final response = await approvalRepository.listHistory(
approvalId: approvalId,
page: resolvedPage,
pageSize: auditPageSize,
from: _auditFrom ?? _from,
to: _auditTo ?? _to,
actorId: _auditActorId,
approvalActionId: _resolveAuditActionId(),
);
_auditResult = response;
_auditPageSize = response.pageSize;
if (response.items.isNotEmpty) {
final actionMap = <String, ApprovalAction>{};
for (final log in response.items) {
final code = log.action.code?.trim();
if (code == null || code.isEmpty) {
continue;
}
actionMap.putIfAbsent(code, () => log.action);
_actionIdsByCode[code] = log.action.id;
}
if (actionMap.isNotEmpty) {
_auditActions
..clear()
..addAll(actionMap);
}
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_auditResult = null;
}
} finally {
_isLoadingAudit = false;
notifyListeners();
}
}
/// 결재 상세와 타임라인 정보를 조회해 선택 상태를 갱신한다.
Future<void> loadApprovalFlow(int approvalId, {bool force = false}) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
if (!force && _flowCache.containsKey(approvalId)) {
_selectedApprovalId = approvalId;
_selectedFlow = _flowCache[approvalId];
notifyListeners();
return;
}
_isLoadingFlow = true;
_errorMessage = null;
_selectedApprovalId = approvalId;
_isSelectionForbidden = false;
_selectedFlow = null;
notifyListeners();
try {
final detail = await approvalRepository.fetchDetail(
approvalId,
includeSteps: true,
includeHistories: true,
);
final flow = ApprovalFlow.fromApproval(detail);
_flowCache[approvalId] = flow;
_selectedFlow = flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.statusCode == 403
? failure.describe()
: '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.';
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_selectedFlow = null;
_auditResult = null;
}
} finally {
_isLoadingFlow = false;
notifyListeners();
}
}
/// 선택된 결재 흐름을 최신 상태로 갱신한다.
Future<ApprovalFlow?> refreshFlow(int approvalId) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
try {
final detail = await approvalRepository.fetchDetail(
approvalId,
includeSteps: true,
includeHistories: true,
);
final flow = ApprovalFlow.fromApproval(detail);
_flowCache[approvalId] = flow;
if (_selectedApprovalId == approvalId) {
_selectedFlow = flow;
notifyListeners();
}
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.statusCode == 403
? failure.describe()
: '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.';
notifyListeners();
return null;
}
}
/// 검색어를 업데이트해 다음 조회 시 적용될 수 있도록 한다.
void updateQuery(String value) {
_query = value;
@@ -86,6 +267,82 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 활성 탭을 변경한다.
void updateActiveTab(ApprovalHistoryTab tab) {
if (_activeTab == tab) {
return;
}
_activeTab = tab;
notifyListeners();
}
/// 감사 로그 페이지 사이즈를 변경한다.
void updateAuditPageSize(int value) {
if (value <= 0) {
return;
}
_auditPageSize = value;
notifyListeners();
}
/// 감사 로그 행위자를 필터링한다.
void updateAuditActor(int? actorId) {
final normalized = actorId != null && actorId > 0 ? actorId : null;
if (_auditActorId == normalized) {
return;
}
_auditActorId = normalized;
notifyListeners();
}
/// 감사 로그 행위 타입을 필터링한다.
void updateAuditAction(String? actionCode) {
final normalized = actionCode?.trim();
if (normalized != null && normalized.isEmpty) {
_auditActionCode = null;
} else {
if (_auditActionCode == normalized) {
return;
}
_auditActionCode = normalized;
}
notifyListeners();
}
/// 감사 로그 기간 필터를 설정한다.
void updateAuditDateRange(DateTime? from, DateTime? to) {
DateTime? normalizedFrom = from;
DateTime? normalizedTo = to;
if (normalizedFrom != null && normalizedTo != null) {
if (normalizedFrom.isAfter(normalizedTo)) {
final temp = normalizedFrom;
normalizedFrom = normalizedTo;
normalizedTo = temp;
}
}
if (_auditFrom == normalizedFrom && _auditTo == normalizedTo) {
return;
}
_auditFrom = normalizedFrom;
_auditTo = normalizedTo;
notifyListeners();
}
/// 감사 로그 필터를 초기화한다.
void clearAuditFilters() {
if (_auditActorId == null &&
(_auditActionCode == null || _auditActionCode!.isEmpty) &&
_auditFrom == null &&
_auditTo == null) {
return;
}
_auditActorId = null;
_auditActionCode = null;
_auditFrom = null;
_auditTo = null;
notifyListeners();
}
/// 검색어/행위/기간 필터를 초기화한다.
void clearFilters() {
_query = '';
@@ -95,6 +352,20 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 감사 로그 선택 상태를 초기화한다.
void clearAuditSelection() {
clearSelection();
}
/// 선택된 결재의 감사 로그를 새로고침한다.
Future<void> refreshAudit() async {
final approvalId = _selectedApprovalId;
if (approvalId == null) {
return;
}
await fetchAuditLogs(approvalId: approvalId, page: _auditResult?.page ?? 1);
}
/// 축적된 오류 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
@@ -110,9 +381,178 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 결재를 회수한다.
Future<ApprovalFlow?> recallApproval(ApprovalRecallInput input) async {
final useCase = _recallUseCase;
if (useCase == null) {
throw StateError('RecallApprovalUseCase가 주입되지 않았습니다.');
}
_isPerformingAction = true;
_errorMessage = null;
notifyListeners();
try {
final flow = await useCase.call(input);
final targetId = flow.approval.id ?? input.approvalId;
await _refreshAfterAction(targetId, flow: flow);
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isPerformingAction = false;
notifyListeners();
}
}
/// 결재를 재상신한다.
Future<ApprovalFlow?> resubmitApproval(
ApprovalResubmissionInput input,
) async {
final useCase = _resubmitUseCase;
if (useCase == null) {
throw StateError('ResubmitApprovalUseCase가 주입되지 않았습니다.');
}
_isPerformingAction = true;
_errorMessage = null;
notifyListeners();
try {
final flow = await useCase.call(input);
final targetId = flow.approval.id ?? input.approvalId;
await _refreshAfterAction(targetId, flow: flow);
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isPerformingAction = false;
notifyListeners();
}
}
bool get hasActiveFilters =>
_query.trim().isNotEmpty ||
_actionFilter != ApprovalHistoryActionFilter.all ||
_from != null ||
_to != null;
bool get hasActiveAuditFilters =>
(_auditActorId ?? 0) > 0 ||
(_auditActionCode != null && _auditActionCode!.trim().isNotEmpty) ||
_auditFrom != null ||
_auditTo != null;
Future<int?> _resolveActionIdForFilter(
ApprovalHistoryActionFilter filter,
) async {
final code = _codeForFilter(filter);
if (code == null) {
return null;
}
final cached = _actionIdsByCode[code];
if (cached != null) {
return cached;
}
final auditAction = _auditActions[code];
if (auditAction != null) {
final id = auditAction.id;
_actionIdsByCode[code] = id;
return id;
}
await _ensureActionCatalogLoaded();
return _actionIdsByCode[code];
}
String? _codeForFilter(ApprovalHistoryActionFilter filter) {
return switch (filter) {
ApprovalHistoryActionFilter.all => null,
ApprovalHistoryActionFilter.approve => 'approve',
ApprovalHistoryActionFilter.reject => 'reject',
ApprovalHistoryActionFilter.comment => 'comment',
};
}
Future<void> _ensureActionCatalogLoaded() async {
if (_hasLoadedActionCatalog) {
return;
}
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
_hasLoadedActionCatalog = true;
return;
}
try {
final actions = await approvalRepository.listActions();
for (final action in actions) {
final code = action.code?.trim();
if (code == null || code.isEmpty) {
continue;
}
_actionIdsByCode.putIfAbsent(code, () => action.id);
}
_hasLoadedActionCatalog = true;
} catch (_) {
// 재시도를 위해 로드 여부 플래그를 유지한다.
}
}
int? _resolveAuditActionId() {
final code = _auditActionCode?.trim();
if (code == null || code.isEmpty) {
return null;
}
final action = _auditActions[code];
return action?.id;
}
/// 현재 선택 상태와 캐시를 초기화한다.
void clearSelection() {
if (_selectedApprovalId == null &&
_auditResult == null &&
_selectedFlow == null) {
return;
}
_selectedApprovalId = null;
_selectedFlow = null;
_auditResult = null;
_isSelectionForbidden = false;
notifyListeners();
}
int _resolvePage(int requested, PaginatedResult<dynamic>? current) {
if (requested < 1) {
return 1;
}
if (current != null && current.pageSize > 0) {
final calculated = (current.total / current.pageSize).ceil();
final maxPage = calculated < 1 ? 1 : calculated;
return requested > maxPage ? maxPage : requested;
}
return requested;
}
Future<void> _refreshAfterAction(int approvalId, {ApprovalFlow? flow}) async {
await fetch(page: _result?.page ?? 1);
if (flow != null) {
_flowCache[approvalId] = flow;
if (_selectedApprovalId == approvalId) {
_selectedFlow = flow;
notifyListeners();
}
}
if (_selectedApprovalId == approvalId) {
if (flow == null && _approvalRepository != null) {
await loadApprovalFlow(approvalId, force: true);
}
if (_approvalRepository != null) {
await fetchAuditLogs(
approvalId: approvalId,
page: _auditResult?.page ?? 1,
);
}
}
}
}

View File

@@ -0,0 +1,770 @@
import 'dart:async';
import 'package:flutter/material.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 '../../../../../widgets/components/feedback.dart';
import '../../../../../widgets/components/superport_detail_dialog.dart';
import '../../../../../widgets/components/superport_dialog.dart';
import '../../../../../widgets/components/superport_table.dart';
import '../../../../auth/domain/entities/authenticated_user.dart';
import '../../../domain/entities/approval.dart';
import '../../domain/entities/approval_history_record.dart';
import '../controllers/approval_history_controller.dart';
import '../widgets/approval_audit_log_table.dart';
import '../widgets/approval_flow_timeline.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
import '../../../domain/entities/approval_flow.dart';
/// 결재 이력 상세 다이얼로그를 표시한다.
Future<void> showApprovalHistoryDetailDialog({
required BuildContext context,
required ApprovalHistoryController controller,
required ApprovalHistoryRecord record,
required intl.DateFormat dateFormat,
required AuthenticatedUser? currentUser,
}) {
return showSuperportDialog<void>(
context: context,
title: '결재 이력 상세',
description: '결재번호 ${record.approvalNo}',
body: _ApprovalHistoryDetailDialogBody(
controller: controller,
record: record,
dateFormat: dateFormat,
currentUser: currentUser,
),
constraints: const BoxConstraints(maxWidth: 920),
barrierDismissible: true,
scrollable: true,
);
}
class _ApprovalHistoryDetailDialogBody extends StatefulWidget {
const _ApprovalHistoryDetailDialogBody({
required this.controller,
required this.record,
required this.dateFormat,
required this.currentUser,
});
final ApprovalHistoryController controller;
final ApprovalHistoryRecord record;
final intl.DateFormat dateFormat;
final AuthenticatedUser? currentUser;
@override
State<_ApprovalHistoryDetailDialogBody> createState() =>
_ApprovalHistoryDetailDialogBodyState();
}
class _ApprovalHistoryDetailDialogBodyState
extends State<_ApprovalHistoryDetailDialogBody> {
late ApprovalHistoryRecord _record;
final TextEditingController _auditActorIdController = TextEditingController();
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
static const _auditActionAll = '__all__';
ApprovalHistoryController get _controller => widget.controller;
@override
void initState() {
super.initState();
_record = widget.record;
WidgetsBinding.instance.addPostFrameCallback((_) {
_initialize();
});
}
@override
void didUpdateWidget(covariant _ApprovalHistoryDetailDialogBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.record.id != widget.record.id) {
_record = widget.record;
_initialize();
}
}
Future<void> _initialize() async {
try {
_controller.updateActiveTab(ApprovalHistoryTab.flow);
await _controller.loadApprovalFlow(_record.approvalId, force: true);
if (!_controller.isSelectionForbidden) {
await _controller.fetchAuditLogs(approvalId: _record.approvalId);
}
} catch (_) {
// 오류 메시지는 컨트롤러 리스너에서 처리된다.
}
}
@override
void dispose() {
_auditActorIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final flow = _controller.selectedFlow;
final auditResult = _controller.auditResult;
final auditLogs = auditResult?.items ?? const <ApprovalHistory>[];
final pagination = auditResult == null
? null
: SuperportTablePagination(
currentPage: auditResult.page,
totalPages: auditResult.pageSize == 0
? 1
: (auditResult.total / auditResult.pageSize).ceil().clamp(
1,
9999,
),
totalItems: auditResult.total,
pageSize: auditResult.pageSize,
);
final metadata = _buildMetadata();
final badges = _buildBadges(flow);
final sections = <SuperportDetailDialogSection>[
SuperportDetailDialogSection(
id: 'timeline',
label: '상태 타임라인',
icon: lucide.LucideIcons.listTree,
builder: (_) => _buildTimelineSection(flow),
),
SuperportDetailDialogSection(
id: 'audit',
label: '감사 로그',
icon: lucide.LucideIcons.listChecks,
builder: (_) =>
_buildAuditSection(logs: auditLogs, pagination: pagination),
),
];
return SuperportDetailDialog(
sections: sections,
summary: _buildSummary(flow),
summaryBadges: badges,
metadata: metadata,
initialSectionId: 'timeline',
);
},
);
}
Widget _buildSummary(ApprovalFlow? flow) {
final theme = ShadTheme.of(context);
final requester = flow?.requester;
final currentStep = flow?.statusSummary.currentStepOrder;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ApprovalStatusBadge(
label: _record.toStatus.name,
colorHex: _record.toStatus.color,
),
const SizedBox(height: 12),
Text('결재번호 ${_record.approvalNo}', style: theme.textTheme.small),
const SizedBox(height: 4),
Text(
_record.stepOrder == null
? '단계 정보 없음 · ${_record.approver.name}'
: '${_record.stepOrder}단계 · ${_record.approver.name}',
style: theme.textTheme.small,
),
const SizedBox(height: 4),
Text(_statusLabel(_record), style: theme.textTheme.muted),
if (requester != null) ...[
const SizedBox(height: 8),
Text(
'상신자 ${requester.name} (${requester.employeeNo})',
style: theme.textTheme.small,
),
],
if (flow != null && currentStep != null) ...[
const SizedBox(height: 4),
Text(
'현재 진행 단계: $currentStep / ${flow.statusSummary.totalSteps}',
style: theme.textTheme.small,
),
],
const SizedBox(height: 16),
_buildActionButtons(flow),
],
);
}
List<Widget> _buildBadges(ApprovalFlow? flow) {
final badges = <Widget>[
ShadBadge.outline(child: Text(_record.action.name)),
ShadBadge(child: Text(_record.toStatus.name)),
];
final isForbidden = _controller.isSelectionForbidden;
if (isForbidden) {
badges.add(const ShadBadge.destructive(child: Text('열람 제한')));
} else if (flow?.approval.updatedAt != null) {
badges.add(
ShadBadge.outline(
child: Text(
'변경 ${_dateTimeFormat.format(flow!.approval.updatedAt!.toLocal())}',
),
),
);
}
return badges;
}
List<SuperportDetailMetadata> _buildMetadata() {
return [
SuperportDetailMetadata.text(
label: '결재 ID',
value: '${_record.approvalId}',
),
SuperportDetailMetadata.text(
label: '승인자 사번',
value: _record.approver.employeeNo,
),
SuperportDetailMetadata.text(
label: '행위 시간',
value: _dateTimeFormat.format(_record.actionAt.toLocal()),
),
SuperportDetailMetadata(
label: '메모',
value: ApprovalNoteTooltip(note: _record.note),
),
];
}
Widget _buildTimelineSection(ApprovalFlow? flow) {
final theme = ShadTheme.of(context);
if (_controller.isLoadingFlow) {
return const Center(child: CircularProgressIndicator());
}
if (_controller.isSelectionForbidden) {
return _buildForbiddenNotice(theme);
}
if (flow == null) {
return _buildPlaceholder(theme, '결재 정보를 불러오는 중입니다.');
}
return ApprovalFlowTimeline(flow: flow, dateFormat: _dateTimeFormat);
}
Widget _buildAuditSection({
required List<ApprovalHistory> logs,
required SuperportTablePagination? pagination,
}) {
final theme = ShadTheme.of(context);
if (_controller.isSelectionForbidden) {
return _buildForbiddenNotice(theme);
}
if (_controller.isLoadingAudit) {
return const Center(child: CircularProgressIndicator());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAuditFilters(theme),
const SizedBox(height: 16),
ApprovalAuditLogTable(
logs: logs,
dateFormat: _dateTimeFormat,
pagination: pagination,
onPageChange: (page) {
_controller.fetchAuditLogs(
approvalId: _record.approvalId,
page: page,
);
},
onPageSizeChange: (value) {
_controller.updateAuditPageSize(value);
_controller.fetchAuditLogs(approvalId: _record.approvalId, page: 1);
},
isLoading: _controller.isLoadingAudit,
),
],
);
}
Widget _buildAuditFilters(ShadThemeData theme) {
final actorId = _controller.auditActorId;
final actorText = actorId?.toString() ?? '';
if (_auditActorIdController.text.trim() != actorText) {
_auditActorIdController.value = TextEditingValue(text: actorText);
}
final actionOptions = _controller.auditActions;
final currentAction = _controller.auditActionCode ?? _auditActionAll;
final isLoading = _controller.isLoadingAudit;
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 240,
child: ApprovalApproverAutocompleteField(
key: ValueKey(actorId ?? 'all'),
idController: _auditActorIdController,
hintText: '행위자 검색',
onSelected: (candidate) {
final selected =
candidate?.id ??
int.tryParse(_auditActorIdController.text.trim());
_controller.updateAuditActor(selected);
_controller.fetchAuditLogs(
approvalId: _record.approvalId,
page: 1,
);
},
),
),
SizedBox(
width: 200,
child: ShadSelect<String>(
key: ValueKey(currentAction),
initialValue: currentAction,
selectedOptionBuilder: (context, value) =>
Text(_auditActionLabel(value, actionOptions)),
onChanged: isLoading
? null
: (value) {
if (value == null || value == _auditActionAll) {
_controller.updateAuditAction(null);
} else {
_controller.updateAuditAction(value);
}
_controller.fetchAuditLogs(
approvalId: _record.approvalId,
page: 1,
);
},
options: [
const ShadOption(value: _auditActionAll, child: Text('전체 행위')),
...actionOptions
.where(
(action) => action.code != null && action.code!.isNotEmpty,
)
.map(
(action) => ShadOption(
value: action.code!,
child: Text(action.name),
),
),
],
),
),
if (_controller.hasActiveAuditFilters)
ShadButton.ghost(
onPressed: isLoading ? null : _clearAuditFilters,
child: const Text('필터 초기화'),
),
],
);
}
Widget _buildActionButtons(ApprovalFlow? flow) {
final theme = ShadTheme.of(context);
final canRecall = flow != null && _canRecall(flow);
final canResubmit = flow != null && _canResubmit(flow);
final recallReason = flow == null
? '결재 정보를 불러오는 중입니다.'
: _recallDisabledReason(flow);
final resubmitReason = flow == null
? '결재 정보를 불러오는 중입니다.'
: _resubmitDisabledReason(flow);
final recallNotice = _buildRecallConditionNotice(
theme: theme,
flow: flow,
canRecall: canRecall,
reason: recallReason,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ShadButton(
onPressed:
flow == null || !canRecall || _controller.isPerformingAction
? null
: () => _handleRecall(flow),
child: const Text('회수'),
),
ShadButton.outline(
onPressed:
flow == null || !canResubmit || _controller.isPerformingAction
? null
: () => _handleResubmit(flow),
child: const Text('재상신'),
),
],
),
if (_controller.isPerformingAction) ...[
const SizedBox(height: 8),
Text(
'결재 작업을 처리하는 중입니다...',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
] else ...[
if (recallNotice != null) ...[
const SizedBox(height: 8),
recallNotice,
],
if (!canResubmit && resubmitReason != null) ...[
SizedBox(height: recallNotice == null ? 8 : 4),
Text(
'재상신 불가: $resubmitReason',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
],
],
);
}
Widget? _buildRecallConditionNotice({
required ShadThemeData theme,
required ApprovalFlow? flow,
required bool canRecall,
required String? reason,
}) {
if (flow == null) {
return Row(
children: [
Icon(
lucide.LucideIcons.info,
size: 16,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'회수 조건을 확인하는 중입니다.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
],
);
}
final icon = canRecall
? lucide.LucideIcons.badgeCheck
: lucide.LucideIcons.shieldAlert;
final color = canRecall
? theme.colorScheme.primary
: theme.colorScheme.destructive;
final message = canRecall
? '첫 승인자가 아직 결정을 내리지 않아 회수할 수 있습니다.'
: (reason ?? '회수 조건을 확인할 수 없습니다.');
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: theme.textTheme.small.copyWith(color: color),
),
),
],
);
}
Widget _buildForbiddenNotice(ShadThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'열람 권한이 없습니다',
style: theme.textTheme.p.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.destructive,
),
),
const SizedBox(height: 8),
Text(
'상신자 또는 기결재자만 감사 로그와 상세 내역을 확인할 수 있습니다.',
style: theme.textTheme.small,
),
const SizedBox(height: 8),
Text(
'필요 시 담당자에게 접근 권한을 요청하거나 다른 결재를 선택하세요.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
);
}
Widget _buildPlaceholder(ShadThemeData theme, String message) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Text(message, style: theme.textTheme.muted),
);
}
void _clearAuditFilters() {
_auditActorIdController.clear();
_controller.clearAuditFilters();
_controller.fetchAuditLogs(approvalId: _record.approvalId, page: 1);
}
String _statusLabel(ApprovalHistoryRecord record) {
final from = record.fromStatus?.name;
if (from == null || from.isEmpty) {
return record.toStatus.name;
}
return '$from${record.toStatus.name}';
}
String _auditActionLabel(String value, List<ApprovalAction> actions) {
if (value == _auditActionAll) {
return '전체 행위';
}
for (final action in actions) {
if (action.code == value) {
return action.name;
}
}
return '전체 행위';
}
bool _canRecall(ApprovalFlow flow) {
if (flow.status.isTerminal) {
return false;
}
if (flow.steps.isEmpty) {
return false;
}
final first = flow.steps.first;
return first.decidedAt == null;
}
bool _canResubmit(ApprovalFlow flow) {
if (!flow.status.isTerminal) {
return false;
}
final statusName = flow.status.name.toLowerCase();
return statusName.contains('반려') || statusName.contains('reject');
}
String? _recallDisabledReason(ApprovalFlow flow) {
if (flow.status.isTerminal) {
return '결재가 종료되었습니다.';
}
if (flow.steps.isEmpty) {
return '결재 단계 정보가 없습니다.';
}
if (flow.steps.first.decidedAt != null) {
return '첫 승인자가 이미 결정을 내려 회수할 수 없습니다.';
}
return null;
}
String? _resubmitDisabledReason(ApprovalFlow flow) {
if (!flow.status.isTerminal) {
return '결재가 아직 진행 중입니다.';
}
final statusName = flow.status.name.toLowerCase();
if (!(statusName.contains('반려') || statusName.contains('reject'))) {
return '반려 상태에서만 재상신할 수 있습니다.';
}
return null;
}
Future<void> _handleRecall(ApprovalFlow flow) async {
final user = widget.currentUser;
if (user == null) {
SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.');
return;
}
final approvalId = flow.id;
if (approvalId == null) {
SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.');
return;
}
final note = await _promptActionNote(
title: '결재 회수',
confirmLabel: '회수',
description: '회수 사유를 입력하세요. 입력하지 않아도 회수를 진행할 수 있습니다.',
);
final refreshed = await _controller.refreshFlow(approvalId);
if (!mounted) {
return;
}
if (refreshed == null) {
SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.');
return;
}
final latestFlow = refreshed;
final sanitizedNote = note?.isEmpty == true ? null : note;
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
if (transactionUpdatedAt == null) {
SuperportToast.error(
context,
'연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.',
);
return;
}
final input = ApprovalRecallInput(
approvalId: approvalId,
actorId: user.id,
note: sanitizedNote,
expectedUpdatedAt: latestFlow.approval.updatedAt,
transactionExpectedUpdatedAt: transactionUpdatedAt,
);
final result = await _controller.recallApproval(input);
if (!mounted) {
return;
}
if (result != null) {
SuperportToast.success(
context,
'결재(${latestFlow.approvalNo}) 회수를 완료했습니다.',
);
await _controller.loadApprovalFlow(approvalId, force: true);
await _controller.fetchAuditLogs(approvalId: approvalId);
}
}
Future<void> _handleResubmit(ApprovalFlow flow) async {
final user = widget.currentUser;
if (user == null) {
SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.');
return;
}
final approvalId = flow.id;
if (approvalId == null) {
SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.');
return;
}
final note = await _promptActionNote(
title: '결재 재상신',
confirmLabel: '재상신',
description: '재상신 시 전달할 메시지를 입력하세요. 입력하지 않아도 재상신됩니다.',
);
final refreshed = await _controller.refreshFlow(approvalId);
if (!mounted) {
return;
}
if (refreshed == null) {
SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.');
return;
}
final latestFlow = refreshed;
final sanitizedNote = note?.isEmpty == true ? null : note;
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
if (transactionUpdatedAt == null) {
SuperportToast.error(
context,
'연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.',
);
return;
}
final steps = latestFlow.steps
.map(
(step) => ApprovalStepAssignmentItem(
stepOrder: step.stepOrder,
approverId: step.approver.id,
note: step.note,
),
)
.toList(growable: false);
final submission = ApprovalSubmissionInput(
transactionId: latestFlow.transactionId,
statusId: latestFlow.status.id,
requesterId: latestFlow.requester.id,
finalApproverId: latestFlow.finalApprover?.id,
note: latestFlow.note,
steps: steps,
);
final input = ApprovalResubmissionInput(
approvalId: approvalId,
actorId: user.id,
submission: submission,
note: sanitizedNote,
expectedUpdatedAt: latestFlow.approval.updatedAt,
transactionExpectedUpdatedAt: transactionUpdatedAt,
);
final result = await _controller.resubmitApproval(input);
if (!mounted) {
return;
}
if (result != null) {
SuperportToast.success(
context,
'결재(${latestFlow.approvalNo}) 재상신을 완료했습니다.',
);
await _controller.loadApprovalFlow(approvalId, force: true);
await _controller.fetchAuditLogs(approvalId: approvalId);
}
}
Future<String?> _promptActionNote({
required String title,
required String confirmLabel,
required String description,
}) async {
final theme = ShadTheme.of(context);
final controller = TextEditingController();
String? result;
await showSuperportDialog<void>(
context: context,
title: title,
description: description,
constraints: const BoxConstraints(maxWidth: 420),
actions: [
ShadButton.ghost(
onPressed: () =>
Navigator.of(context, rootNavigator: true).maybePop(),
child: const Text('취소'),
),
ShadButton(
onPressed: () {
result = controller.text.trim();
Navigator.of(context, rootNavigator: true).maybePop();
},
child: Text(confirmLabel),
),
],
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('사유는 선택 입력입니다. 비워두면 전달되지 않습니다.', style: theme.textTheme.muted),
const SizedBox(height: 12),
ShadTextarea(controller: controller, minHeight: 120, maxHeight: 200),
],
),
);
controller.dispose();
return result;
}
}

View File

@@ -5,15 +5,22 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/constants/app_sections.dart';
import '../../../../../core/navigation/route_paths.dart';
import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/components/superport_date_picker.dart';
import '../../../../../widgets/components/superport_table.dart';
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
import '../../../../auth/application/auth_service.dart';
import '../../../../auth/domain/entities/authenticated_user.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
import '../../../domain/repositories/approval_repository.dart';
import '../../../domain/usecases/recall_approval_use_case.dart';
import '../../../domain/usecases/resubmit_approval_use_case.dart';
import '../../../shared/widgets/widgets.dart';
import '../controllers/approval_history_controller.dart';
import '../dialogs/approval_history_detail_dialog.dart';
class ApprovalHistoryPage extends StatelessWidget {
const ApprovalHistoryPage({super.key});
@@ -64,20 +71,26 @@ class _ApprovalHistoryEnabledPageState
late final ApprovalHistoryController _controller;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
DateTimeRange? _dateRange;
String? _lastError;
static const _pageSizeOptions = [10, 20, 50];
int? _sortColumnIndex;
bool _sortAscending = true;
static const _sortableColumns = {0, 1, 2, 3, 4, 5, 6, 7};
static const _sortableColumns = {0, 1, 2, 3, 4, 5};
AuthenticatedUser? _currentUser;
@override
void initState() {
super.initState();
final sl = GetIt.I;
_controller = ApprovalHistoryController(
repository: GetIt.I<ApprovalHistoryRepository>(),
repository: sl<ApprovalHistoryRepository>(),
approvalRepository: sl<ApprovalRepository>(),
recallUseCase: sl<RecallApprovalUseCase>(),
resubmitUseCase: sl<ResubmitApprovalUseCase>(),
)..addListener(_handleUpdate);
_currentUser = sl<AuthService>().session?.user;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.fetch();
});
@@ -87,9 +100,10 @@ class _ApprovalHistoryEnabledPageState
final error = _controller.errorMessage;
if (error != null && error != _lastError && mounted) {
_lastError = error;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger != null) {
messenger.showSnackBar(SnackBar(content: Text(error)));
}
_controller.clearError();
}
}
@@ -212,53 +226,14 @@ class _ApprovalHistoryEnabledPageState
),
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('이력 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
),
child: _controller.isLoading
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
)
: histories.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Text(
'조건에 맞는 결재 이력이 없습니다.',
style: theme.textTheme.muted,
),
)
: _ApprovalHistoryTable(
histories: sortedHistories,
dateFormat: _dateTimeFormat,
query: _controller.query,
pagination: SuperportTablePagination(
currentPage: currentPage,
totalPages: totalPages,
totalItems: totalCount,
pageSize: _controller.pageSize,
pageSizeOptions: _pageSizeOptions,
),
onPageChange: (page) => _controller.fetch(page: page),
onPageSizeChange: (size) {
_controller.updatePageSize(size);
_controller.fetch(page: 1);
},
isLoading: _controller.isLoading,
sortableColumns: _sortableColumns,
sortState: _sortColumnIndex == null
? null
: SuperportTableSortState(
columnIndex: _sortColumnIndex!,
ascending: _sortAscending,
),
onSortChanged: _handleSortChange,
),
child: _buildHistoryTableCard(
context,
theme,
sortedHistories,
totalCount,
currentPage,
totalPages,
result?.pageSize ?? _controller.pageSize,
),
);
},
@@ -304,31 +279,31 @@ class _ApprovalHistoryEnabledPageState
int compare;
switch (columnIndex) {
case 0:
compare = a.id.compareTo(b.id);
compare = a.approvalNo.toLowerCase().compareTo(
b.approvalNo.toLowerCase(),
);
break;
case 1:
compare = a.approvalNo.compareTo(b.approvalNo);
break;
case 2:
final left = a.stepOrder ?? 0;
final right = b.stepOrder ?? 0;
compare = left.compareTo(right);
break;
case 3:
compare = a.approver.name.compareTo(b.approver.name);
break;
case 4:
compare = a.action.name.compareTo(b.action.name);
break;
case 5:
compare = (a.fromStatus?.name ?? '').compareTo(
b.fromStatus?.name ?? '',
case 2:
compare = a.approver.name.toLowerCase().compareTo(
b.approver.name.toLowerCase(),
);
break;
case 6:
compare = a.toStatus.name.compareTo(b.toStatus.name);
case 3:
compare = a.action.name.toLowerCase().compareTo(
b.action.name.toLowerCase(),
);
break;
case 7:
case 4:
compare = a.toStatus.name.toLowerCase().compareTo(
b.toStatus.name.toLowerCase(),
);
break;
case 5:
compare = a.actionAt.compareTo(b.actionAt);
break;
default:
@@ -351,113 +326,208 @@ class _ApprovalHistoryEnabledPageState
return '코멘트';
}
}
Widget _buildHistoryTableCard(
BuildContext context,
ShadThemeData theme,
List<ApprovalHistoryRecord> histories,
int totalCount,
int currentPage,
int totalPages,
int pageSize,
) {
final normalizedQuery = _controller.query.trim().toLowerCase();
final rows = <List<ShadTableCell>>[];
for (final record in histories) {
final approvalNo = record.approvalNo;
final highlight =
normalizedQuery.isNotEmpty &&
approvalNo.toLowerCase().contains(normalizedQuery);
final approvalStyle = highlight
? theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.foreground,
)
: theme.textTheme.small;
rows.add([
_buildTableCell(theme, Text(approvalNo, style: approvalStyle)),
_buildTableCell(
theme,
_buildStepBadge(theme, record),
alignment: Alignment.centerLeft,
),
_buildTableCell(
theme,
ApprovalApproverCell(
name: record.approver.name,
employeeNo: record.approver.employeeNo,
),
),
_buildTableCell(
theme,
ShadBadge.outline(child: Text(record.action.name)),
),
_buildTableCell(
theme,
Text(_statusLabel(record), style: theme.textTheme.small),
),
_buildTableCell(
theme,
Text(_dateTimeFormat.format(record.actionAt.toLocal())),
),
_buildTableCell(theme, ApprovalNoteTooltip(note: record.note)),
]);
}
/// 결재 이력 데이터를 표 형태로 렌더링하는 위젯.
class _ApprovalHistoryTable extends StatelessWidget {
const _ApprovalHistoryTable({
required this.histories,
required this.dateFormat,
required this.query,
required this.pagination,
required this.onPageChange,
required this.onPageSizeChange,
required this.isLoading,
required this.sortableColumns,
required this.sortState,
required this.onSortChanged,
});
final List<ApprovalHistoryRecord> histories;
final DateFormat dateFormat;
final String query;
final SuperportTablePagination pagination;
final ValueChanged<int> onPageChange;
final ValueChanged<int> onPageSizeChange;
final bool isLoading;
final Set<int> sortableColumns;
final SuperportTableSortState? sortState;
final void Function(int columnIndex, bool ascending) onSortChanged;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final normalizedQuery = query.trim().toLowerCase();
final columns = const [
Text('ID'),
Text('결재번호'),
Text('단계순서'),
Text('승인자'),
Text('행위'),
Text('변경전 상태'),
Text('변경후 상태'),
Text('작업일시'),
Text('비고'),
final 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('일시')),
ShadTableCell.header(child: Text('메모')),
];
final rows = histories.map((history) {
final isHighlighted =
normalizedQuery.isNotEmpty &&
history.approvalNo.toLowerCase().contains(normalizedQuery);
final highlightStyle = theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
);
final noteText = history.note?.trim();
final noteContent = noteText?.isNotEmpty == true ? noteText : null;
final subLabelStyle = theme.textTheme.muted.copyWith(
fontSize: (theme.textTheme.muted.fontSize ?? 14) - 1,
final pagination = SuperportTablePagination(
currentPage: currentPage,
totalPages: totalPages,
totalItems: totalCount,
pageSize: pageSize,
pageSizeOptions: _pageSizeOptions,
);
return <Widget>[
Text(history.id.toString()),
Text(history.approvalNo, style: isHighlighted ? highlightStyle : null),
Text(history.stepOrder == null ? '-' : history.stepOrder.toString()),
Text(history.approver.name),
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(history.action.name),
if (noteContent != null) Text(noteContent, style: subLabelStyle),
Text('결재 이력', style: theme.textTheme.h3),
const SizedBox(height: 4),
Text('$totalCount건', style: theme.textTheme.muted),
],
),
Text(history.fromStatus?.name ?? '-'),
Text(history.toStatus.name),
Text(dateFormat.format(history.actionAt.toLocal())),
Text(noteContent ?? '-'),
];
}).toList();
return SuperportTable(
columns: columns,
],
),
const SizedBox(height: 16),
if (_controller.isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
)
else if (histories.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 32,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Center(
child: Text(
'조건에 맞는 결재 이력이 없습니다.',
style: theme.textTheme.muted,
),
),
)
else
SuperportTable.fromCells(
header: header,
rows: rows,
rowHeight: 64,
rowHeight: 74,
maxHeight: 520,
columnSpanExtent: (index) {
switch (index) {
case 1:
return const FixedTableSpanExtent(180);
case 2:
case 4:
return const FixedTableSpanExtent(120);
case 5:
case 6:
case 0:
return const FixedTableSpanExtent(150);
case 7:
return const FixedTableSpanExtent(180);
case 1:
return const FixedTableSpanExtent(80);
case 2:
return const FixedTableSpanExtent(220);
case 3:
case 4:
return const FixedTableSpanExtent(150);
case 5:
return const FixedTableSpanExtent(160);
default:
return const FixedTableSpanExtent(110);
return const FixedTableSpanExtent(200);
}
},
onRowTap: _controller.isLoading
? null
: (index) => _openHistoryDetail(histories[index]),
sortableColumns: _sortableColumns,
sortState: _sortColumnIndex == null
? null
: SuperportTableSortState(
columnIndex: _sortColumnIndex!,
ascending: _sortAscending,
),
onSortChanged: _handleSortChange,
pagination: pagination,
onPageChange: onPageChange,
onPageSizeChange: onPageSizeChange,
isLoading: isLoading,
sortableColumns: sortableColumns,
sortState: sortState,
onSortChanged: onSortChanged,
onPageChange: (page) => _controller.fetch(page: page),
onPageSizeChange: (size) {
_controller.updatePageSize(size);
_controller.fetch(page: 1);
},
isLoading: _controller.isLoading,
),
],
),
),
);
}
Widget _buildStepBadge(ShadThemeData theme, ApprovalHistoryRecord record) {
if (record.stepOrder == null) {
return Text('-', style: theme.textTheme.muted);
}
return ShadBadge(child: Text('${record.stepOrder}단계'));
}
String _statusLabel(ApprovalHistoryRecord record) {
final from = record.fromStatus?.name;
if (from == null || from.isEmpty) {
return record.toStatus.name;
}
return '$from${record.toStatus.name}';
}
Future<void> _openHistoryDetail(ApprovalHistoryRecord record) async {
await showApprovalHistoryDetailDialog(
context: context,
controller: _controller,
record: record,
dateFormat: _dateTimeFormat,
currentUser: _currentUser,
);
if (!mounted) {
return;
}
_controller.clearSelection();
}
ShadTableCell _buildTableCell(
ShadThemeData theme,
Widget child, {
Alignment alignment = Alignment.centerLeft,
}) {
return ShadTableCell(
alignment: alignment,
child: DefaultTextStyle(style: theme.textTheme.small, child: child),
);
}
}
/// 결재 이력 데이터를 표 형태로 렌더링하는 위젯.

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
import '../../../../../widgets/components/superport_table.dart';
/// 결재 감사 로그를 표 형태로 렌더링하는 위젯.
class ApprovalAuditLogTable extends StatelessWidget {
const ApprovalAuditLogTable({
super.key,
required this.logs,
required this.dateFormat,
this.pagination,
this.onPageChange,
this.onPageSizeChange,
this.isLoading = false,
});
/// 감사 로그 목록.
final List<ApprovalHistory> logs;
/// 날짜 포맷터.
final DateFormat dateFormat;
/// 페이지네이션 상태.
final SuperportTablePagination? pagination;
/// 페이지 변경 콜백.
final ValueChanged<int>? onPageChange;
/// 페이지 크기 변경 콜백.
final ValueChanged<int>? onPageSizeChange;
/// 로딩 여부.
final bool isLoading;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
if (logs.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Center(
child: Text('선택한 결재의 감사 로그가 없습니다.', style: theme.textTheme.muted),
),
);
}
return SuperportTable(
columns: const [
Text('행위'),
Text('변경 상태'),
Text('승인자'),
Text('메모'),
Text('일시'),
],
rows: logs.map((log) {
final statusLabel = _buildStatusLabel(log);
final timestamp = dateFormat.format(log.actionAt.toLocal());
return [
ShadBadge.outline(child: Text(log.action.name)),
ApprovalStatusBadge(label: statusLabel, colorHex: log.toStatus.color),
ApprovalApproverCell(
name: log.approver.name,
employeeNo: log.approver.employeeNo,
),
ApprovalNoteTooltip(note: log.note),
Text(timestamp),
];
}).toList(),
rowHeight: 68,
maxHeight: 420,
columnSpanExtent: (index) {
switch (index) {
case 0:
return const FixedTableSpanExtent(120);
case 2:
return const FixedTableSpanExtent(220);
case 3:
return const FixedTableSpanExtent(220);
case 4:
return const FixedTableSpanExtent(160);
default:
return const FixedTableSpanExtent(140);
}
},
pagination: pagination,
onPageChange: onPageChange,
onPageSizeChange: onPageSizeChange,
isLoading: isLoading,
);
}
String _buildStatusLabel(ApprovalHistory log) {
final from = log.fromStatus?.name ?? '시작';
final to = log.toStatus.name;
return '$from$to';
}
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../../approvals/domain/entities/approval_flow.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
/// 결재 흐름의 상태 변화를 타임라인으로 표현하는 위젯.
class ApprovalFlowTimeline extends StatelessWidget {
const ApprovalFlowTimeline({
super.key,
required this.flow,
required this.dateFormat,
});
/// 표시할 결재 흐름.
final ApprovalFlow flow;
/// 일시 포맷터.
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final histories = List<ApprovalHistory>.from(flow.histories)
..sort((a, b) => a.actionAt.compareTo(b.actionAt));
final summary = flow.statusSummary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummary(theme, summary),
const SizedBox(height: 16),
if (histories.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
),
child: Text('결재 상태 변경 이력이 없습니다.', style: theme.textTheme.muted),
)
else
Column(
children: [
for (var index = 0; index < histories.length; index++)
_TimelineEntry(
history: histories[index],
isFirst: index == 0,
isLast: index == histories.length - 1,
dateFormat: dateFormat,
),
],
),
],
);
}
Widget _buildSummary(ShadThemeData theme, ApprovalFlowStatusSummary summary) {
final requester = flow.requester;
final finalApprover = flow.finalApprover;
final status = flow.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ApprovalStatusBadge(label: status.name, colorHex: status.color),
const SizedBox(width: 12),
Text(
'${summary.totalSteps}단계 · 완료 ${summary.completedSteps} · 대기 ${summary.pendingSteps}',
style: theme.textTheme.small,
),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'상신자',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(
'${requester.name} (${requester.employeeNo})',
style: theme.textTheme.p,
),
if (finalApprover != null) ...[
const SizedBox(height: 12),
Text(
'최종 승인자',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(
'${finalApprover.name} (${finalApprover.employeeNo})',
style: theme.textTheme.p,
),
],
const SizedBox(height: 12),
Text(
'결재번호 ${flow.approvalNo}',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
],
);
}
}
class _TimelineEntry extends StatelessWidget {
const _TimelineEntry({
required this.history,
required this.isFirst,
required this.isLast,
required this.dateFormat,
});
final ApprovalHistory history;
final bool isFirst;
final bool isLast;
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final fromStatus = history.fromStatus?.name ?? '시작';
final toStatus = history.toStatus.name;
final timestamp = dateFormat.format(history.actionAt.toLocal());
return Padding(
padding: EdgeInsets.only(top: isFirst ? 0 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTimelineIndicator(theme),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
history.action.name,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(timestamp, style: theme.textTheme.muted),
],
),
const SizedBox(height: 8),
Text('$fromStatus$toStatus', style: theme.textTheme.small),
const SizedBox(height: 12),
ApprovalApproverCell(
name: history.approver.name,
employeeNo: history.approver.employeeNo,
),
if (history.note?.trim().isNotEmpty == true) ...[
const SizedBox(height: 12),
ApprovalNoteTooltip(note: history.note),
],
],
),
),
),
],
),
);
}
Widget _buildTimelineIndicator(ShadThemeData theme) {
final primary = theme.colorScheme.primary;
return SizedBox(
width: 20,
child: Column(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: primary,
borderRadius: BorderRadius.circular(12),
),
),
if (!isLast)
Container(
width: 2,
height: 40,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(color: primary.withValues(alpha: 0.4)),
),
],
),
);
}
}

View File

@@ -1,13 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
import '../../../inventory/lookups/domain/entities/lookup_item.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_draft.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/errors/approval_access_denied_exception.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../../domain/usecases/get_approval_draft_use_case.dart';
import '../../domain/usecases/list_approval_drafts_use_case.dart';
import '../../domain/usecases/save_approval_draft_use_case.dart';
import '../../../inventory/transactions/domain/entities/stock_transaction.dart';
import '../../../inventory/transactions/domain/repositories/stock_transaction_repository.dart';
enum ApprovalStatusFilter {
all,
draft,
pending,
inProgress,
onHold,
@@ -23,6 +38,21 @@ const Map<ApprovalStepActionType, List<String>> _actionAliases = {
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
};
const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
ApprovalStatusFilter.draft: 'draft',
ApprovalStatusFilter.pending: 'pending',
ApprovalStatusFilter.inProgress: 'in_progress',
ApprovalStatusFilter.onHold: 'on_hold',
ApprovalStatusFilter.approved: 'approved',
ApprovalStatusFilter.rejected: 'rejected',
};
const List<String> _pendingFallbackStatusCodes = [
'draft',
'submitted',
'in_progress',
];
/// 결재 목록 및 상세 화면 상태 컨트롤러
///
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
@@ -31,48 +61,135 @@ class ApprovalController extends ChangeNotifier {
ApprovalController({
required ApprovalRepository approvalRepository,
required ApprovalTemplateRepository templateRepository,
required StockTransactionRepository transactionRepository,
InventoryLookupRepository? lookupRepository,
SaveApprovalDraftUseCase? saveDraftUseCase,
GetApprovalDraftUseCase? getDraftUseCase,
ListApprovalDraftsUseCase? listDraftsUseCase,
}) : _repository = approvalRepository,
_templateRepository = templateRepository;
_templateRepository = templateRepository,
_transactionRepository = transactionRepository,
_lookupRepository = lookupRepository,
_saveDraftUseCase = saveDraftUseCase,
_getDraftUseCase = getDraftUseCase,
_listDraftsUseCase = listDraftsUseCase;
final ApprovalRepository _repository;
final ApprovalTemplateRepository _templateRepository;
final StockTransactionRepository _transactionRepository;
final InventoryLookupRepository? _lookupRepository;
final SaveApprovalDraftUseCase? _saveDraftUseCase;
final GetApprovalDraftUseCase? _getDraftUseCase;
final ListApprovalDraftsUseCase? _listDraftsUseCase;
PaginatedResult<Approval>? _result;
Approval? _selected;
StockTransaction? _selectedTransaction;
bool _isLoadingList = false;
bool _isLoadingDetail = false;
bool _isLoadingTransactionDetail = false;
bool _isLoadingActions = false;
bool _isSubmitting = false;
bool _isPerformingAction = false;
int? _processingStepId;
bool _isLoadingTemplates = false;
bool _isApplyingTemplate = false;
int? _applyingTemplateId;
ApprovalProceedStatus? _proceedStatus;
ApprovalSubmissionInput? _submissionDraft;
String? _errorMessage;
String _query = '';
String? _transactionError;
bool _accessDenied = false;
String? _accessDeniedMessage;
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
DateTime? _fromDate;
DateTime? _toDate;
int? _transactionIdFilter;
int? _requestedById;
String? _requestedByName;
String? _requestedByEmployeeNo;
List<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {};
List<LookupItem> _statusOptions = const [];
final Map<String, String> _statusCodeAliases = Map.fromEntries(
_defaultStatusCodes.entries.map(
(entry) => MapEntry(entry.value, entry.value),
),
);
PaginatedResult<Approval>? get result => _result;
Approval? get selected => _selected;
StockTransaction? get selectedTransaction => _selectedTransaction;
bool get isLoadingList => _isLoadingList;
bool get isLoadingDetail => _isLoadingDetail;
bool get isLoadingTransactionDetail => _isLoadingTransactionDetail;
bool get isLoadingActions => _isLoadingActions;
bool get isSubmitting => _isSubmitting;
bool get isPerformingAction => _isPerformingAction;
int? get processingStepId => _processingStepId;
String? get errorMessage => _errorMessage;
String get query => _query;
String? get transactionError => _transactionError;
bool get isAccessDenied => _accessDenied;
String? get accessDeniedMessage => _accessDeniedMessage;
ApprovalStatusFilter get statusFilter => _statusFilter;
DateTime? get fromDate => _fromDate;
DateTime? get toDate => _toDate;
int? get transactionIdFilter => _transactionIdFilter;
int? get requestedById => _requestedById;
String? get requestedByName => _requestedByName;
String? get requestedByEmployeeNo => _requestedByEmployeeNo;
List<ApprovalAction> get actionOptions => _actions;
bool get hasActionOptions => _actions.isNotEmpty;
List<ApprovalTemplate> get templates => _templates;
bool get isLoadingTemplates => _isLoadingTemplates;
bool get isApplyingTemplate => _isApplyingTemplate;
int? get applyingTemplateId => _applyingTemplateId;
ApprovalProceedStatus? get proceedStatus => _proceedStatus;
bool get canProceedSelected => _proceedStatus?.canProceed ?? true;
String? get cannotProceedReason {
final reason = _proceedStatus?.reason?.trim();
if (reason == null || reason.isEmpty) {
return null;
}
return reason;
}
ApprovalSubmissionInput? get submissionDraft => _submissionDraft;
bool get hasSubmissionDraft => _submissionDraft != null;
List<LookupItem> get approvalStatusOptions => _statusOptions;
int? get defaultApprovalStatusId {
if (_statusOptions.isEmpty) {
return null;
}
final defaultItem = _statusOptions.firstWhere(
(item) => item.isDefault,
orElse: () => _statusOptions.first,
);
return defaultItem.id;
}
LookupItem? approvalStatusById(int? id) {
if (id == null) {
return null;
}
final lookup = _statusLookup[id.toString()];
if (lookup != null) {
return lookup;
}
for (final item in _statusOptions) {
if (item.id == id) {
return item;
}
}
return null;
}
Map<String, LookupItem> get statusLookup => _statusLookup;
/// 결재 열람 제한 플래그를 초기화한다.
void acknowledgeAccessDenied() {
_accessDenied = false;
_accessDeniedMessage = null;
}
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
///
@@ -83,21 +200,27 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final statusParam = switch (_statusFilter) {
ApprovalStatusFilter.all => null,
ApprovalStatusFilter.pending => 'pending',
ApprovalStatusFilter.inProgress => 'in_progress',
ApprovalStatusFilter.onHold => 'on_hold',
ApprovalStatusFilter.approved => 'approved',
ApprovalStatusFilter.rejected => 'rejected',
};
final previous = _result;
final int resolvedPage;
if (page < 1) {
resolvedPage = 1;
} else if (previous != null && previous.pageSize > 0) {
final calculated = (previous.total / previous.pageSize).ceil();
final maxPage = calculated < 1 ? 1 : calculated;
resolvedPage = page > maxPage ? maxPage : page;
} else {
resolvedPage = page;
}
final statusId = _statusIdFor(_statusFilter);
final statusCodes = _statusCodesFor(_statusFilter);
final response = await _repository.list(
page: page,
page: resolvedPage,
pageSize: _result?.pageSize ?? 20,
query: _query.isEmpty ? null : _query,
status: statusParam,
from: _fromDate,
to: _toDate,
transactionId: _transactionIdFilter,
approvalStatusId: statusId,
requestedById: _requestedById,
statusCodes: statusCodes.isEmpty ? null : statusCodes,
includePending: _statusFilter == ApprovalStatusFilter.all,
includeSteps: false,
includeHistories: false,
);
@@ -106,10 +229,20 @@ class ApprovalController extends ChangeNotifier {
final exists = response.items.any((item) => item.id == _selected?.id);
if (!exists) {
_selected = null;
_proceedStatus = null;
}
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
if (error is ApprovalAccessDeniedException) {
_accessDenied = true;
_accessDeniedMessage = error.message;
_result = null;
_selected = null;
_proceedStatus = null;
} else {
final failure = Failure.from(error);
_errorMessage = failure.describe();
}
} finally {
_isLoadingList = false;
notifyListeners();
@@ -129,14 +262,116 @@ class ApprovalController extends ChangeNotifier {
try {
final items = await _repository.listActions(activeOnly: true);
_actions = items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingActions = false;
notifyListeners();
}
}
Future<void> loadStatusLookups() async {
final repository = _lookupRepository;
if (repository == null) {
return;
}
try {
final items = await repository.fetchApprovalStatuses();
_statusOptions = List.unmodifiable(items);
_statusLookup
..clear()
..addEntries(
items.expand((item) {
final keys = <String>{};
final code = item.code?.trim();
if (code != null && code.isNotEmpty) {
keys.add(code.toLowerCase());
}
final name = item.name.trim();
if (name.isNotEmpty) {
keys.add(name.toLowerCase());
}
keys.add(item.id.toString());
return keys.map((key) => MapEntry(key, item));
}),
);
for (final entry in _defaultStatusCodes.entries) {
final defaultCode = entry.value;
final normalized = defaultCode.toLowerCase();
final lookup = _statusLookup[normalized];
if (lookup != null) {
final alias = lookup.code?.toLowerCase() ?? normalized;
_statusCodeAliases[defaultCode] = alias;
} else {
_statusCodeAliases[defaultCode] = defaultCode;
}
}
notifyListeners();
} catch (_) {
// 실패 시 기본 라벨 사용
}
}
String statusLabel(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return '전체 상태 (임시저장·진행 포함)';
}
final code = _statusCodeFor(filter);
if (code != null) {
final normalized = code.toLowerCase();
final lookup = _statusLookup[normalized];
if (lookup != null && lookup.name.isNotEmpty) {
return lookup.name;
}
}
return switch (filter) {
ApprovalStatusFilter.pending => '승인대기',
ApprovalStatusFilter.inProgress => '진행중',
ApprovalStatusFilter.onHold => '보류',
ApprovalStatusFilter.approved => '승인완료',
ApprovalStatusFilter.rejected => '반려',
ApprovalStatusFilter.draft => '임시저장',
ApprovalStatusFilter.all => '전체 상태',
};
}
String? _statusCodeFor(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return null;
}
final defaultCode = _defaultStatusCodes[filter];
if (defaultCode == null) {
return null;
}
return _statusCodeAliases[defaultCode] ?? defaultCode;
}
int? _statusIdFor(ApprovalStatusFilter filter) {
final code = _statusCodeFor(filter);
if (code == null) {
return null;
}
final lookup = _statusLookup[code.toLowerCase()];
return lookup?.id;
}
List<String> _statusCodesFor(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return const <String>[];
}
final code = _statusCodeFor(filter);
if (filter == ApprovalStatusFilter.pending) {
if (code == null || code.toLowerCase() == 'pending') {
return List<String>.unmodifiable(_pendingFallbackStatusCodes);
}
}
if (code == null || code.isEmpty) {
return const <String>[];
}
return List<String>.unmodifiable(<String>[code]);
}
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
///
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
@@ -148,14 +383,18 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final result = await _templateRepository.list(
page: 1,
pageSize: 100,
final templates = await fetchAllPaginatedItems<ApprovalTemplate>(
pageSize: 200,
request: (page, pageSize) => _templateRepository.list(
page: page,
pageSize: pageSize,
isActive: true,
),
);
_templates = result.items;
} catch (e) {
_errorMessage = e.toString();
_templates = templates;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingTemplates = false;
notifyListeners();
@@ -169,6 +408,10 @@ class ApprovalController extends ChangeNotifier {
Future<void> selectApproval(int id) async {
_isLoadingDetail = true;
_errorMessage = null;
_proceedStatus = null;
_selectedTransaction = null;
_transactionError = null;
_isLoadingTransactionDetail = false;
notifyListeners();
try {
final detail = await _repository.fetchDetail(
@@ -177,20 +420,369 @@ class ApprovalController extends ChangeNotifier {
includeHistories: true,
);
_selected = detail;
} catch (e) {
_errorMessage = e.toString();
if (detail.id != null) {
await _loadProceedStatus(detail.id!);
}
final transactionId = detail.transactionId;
if (transactionId != null) {
unawaited(_loadTransactionDetail(transactionId));
}
} catch (error) {
if (error is ApprovalAccessDeniedException) {
_accessDenied = true;
_accessDeniedMessage = error.message;
_selected = null;
_proceedStatus = null;
_selectedTransaction = null;
_transactionError = null;
} else {
final failure = Failure.from(error);
debugPrint(
'[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}',
); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다.
_errorMessage = failure.describe();
}
} finally {
_isLoadingDetail = false;
notifyListeners();
}
}
Future<void> _loadTransactionDetail(int transactionId) async {
_isLoadingTransactionDetail = true;
_transactionError = null;
notifyListeners();
try {
final transaction = await _transactionRepository.fetchDetail(
transactionId,
include: const ['lines', 'customers', 'approval'],
);
if (_selected?.transactionId == transactionId) {
_selectedTransaction = transaction;
}
} catch (error) {
if (_selected?.transactionId != transactionId) {
return;
}
final failure = Failure.from(error);
_transactionError = failure.describe();
_selectedTransaction = null;
} finally {
if (_selected?.transactionId == transactionId) {
_isLoadingTransactionDetail = false;
}
notifyListeners();
}
}
/// 선택된 결재 상세를 비우고 화면을 초기화한다.
void clearSelection() {
_selected = null;
_proceedStatus = null;
_selectedTransaction = null;
_transactionError = null;
_isLoadingTransactionDetail = false;
notifyListeners();
}
/// 결재 상신 초안을 보관한다.
void cacheSubmissionDraft(ApprovalSubmissionInput draft) {
_submissionDraft = draft;
notifyListeners();
_persistSubmissionDraft(draft);
}
/// 저장된 결재 상신 초안을 반환하고 초기화한다.
ApprovalSubmissionInput? consumeSubmissionDraft() {
final draft = _submissionDraft;
if (draft == null) {
return null;
}
_submissionDraft = null;
notifyListeners();
return draft;
}
/// 결재 상신 초안을 초기화한다.
void clearSubmissionDraft() {
if (_submissionDraft == null) {
return;
}
_submissionDraft = null;
notifyListeners();
}
Future<void> _syncTransactionStatusAfterFinalApproval({
required Approval approval,
required ApprovalStepActionType actionType,
String? note,
}) async {
if (actionType != ApprovalStepActionType.approve) {
return;
}
final transactionId = approval.transactionId;
if (transactionId == null) {
return;
}
final syncAction = _resolveTransactionSyncAction(approval);
if (syncAction == null) {
return;
}
final sanitized = note?.trim();
final payloadNote = sanitized != null && sanitized.isNotEmpty
? sanitized
: '결재 최종 승인 자동 처리';
try {
switch (syncAction) {
case _TransactionSyncAction.approve:
await _transactionRepository.approve(
transactionId,
note: payloadNote,
);
break;
case _TransactionSyncAction.complete:
await _transactionRepository.complete(
transactionId,
note: payloadNote,
);
break;
}
unawaited(_loadTransactionDetail(transactionId));
} catch (error, stackTrace) {
debugPrint(
'[ApprovalController] 전표 상태 연동 실패(tx=$transactionId, action=$syncAction): $error',
);
debugPrintStack(stackTrace: stackTrace);
}
}
_TransactionSyncAction? _resolveTransactionSyncAction(Approval approval) {
final normalizedName = approval.status.name.toLowerCase();
final statusCode = _statusCodeForId(approval.status.id);
if (_isApprovedStatus(statusCode, normalizedName)) {
return _TransactionSyncAction.approve;
}
if (_isCompletedStatus(statusCode, normalizedName)) {
return _TransactionSyncAction.complete;
}
return null;
}
String? _statusCodeForId(int id) {
final lookup = _statusLookup[id.toString()];
final code = lookup?.code?.trim();
if (code != null && code.isNotEmpty) {
return code.toLowerCase();
}
final name = lookup?.name.trim();
if (name != null && name.isNotEmpty) {
return name.toLowerCase();
}
return null;
}
bool _isApprovedStatus(String? code, String normalizedName) {
if (code != null) {
final normalizedCode = code.toLowerCase();
if (normalizedCode == 'approved' || normalizedCode == 'approve') {
return true;
}
}
if (_containsAny(normalizedName, const ['반려', '취소'])) {
return false;
}
if (normalizedName.contains('승인')) {
if (_containsAny(normalizedName, const ['대기', '요청', '진행'])) {
return false;
}
return true;
}
if (normalizedName.contains('approved')) {
return true;
}
return false;
}
bool _isCompletedStatus(String? code, String normalizedName) {
if (code != null) {
final normalizedCode = code.toLowerCase();
if (normalizedCode == 'completed' || normalizedCode == 'complete') {
return true;
}
}
if (normalizedName.contains('완료') && !normalizedName.contains('승인')) {
return true;
}
if (normalizedName.contains('completed')) {
return true;
}
return false;
}
bool _containsAny(String source, List<String> keywords) {
for (final keyword in keywords) {
if (source.contains(keyword)) {
return true;
}
}
return false;
}
Future<ApprovalSubmissionInput?> restoreSubmissionDraft({
required int requesterId,
int? transactionId,
}) async {
final listUseCase = _listDraftsUseCase;
final getUseCase = _getDraftUseCase;
if (listUseCase == null || getUseCase == null) {
return null;
}
try {
final filter = ApprovalDraftListFilter(
requesterId: requesterId,
transactionId: transactionId,
pageSize: 10,
);
final result = await listUseCase.call(filter);
if (result.items.isEmpty) {
return null;
}
final sessionKey = _submissionSessionKey(requesterId);
final summary = result.items.firstWhere(
(item) => item.sessionKey == sessionKey,
orElse: () => result.items.first,
);
final detail = await getUseCase.call(
id: summary.id,
requesterId: requesterId,
);
if (detail == null) {
return null;
}
final submission = detail.toSubmissionInput(
defaultStatusId: _defaultSubmissionStatusId(),
transactionIdOverride: transactionId ?? detail.transactionId,
);
_submissionDraft = submission;
notifyListeners();
return submission;
} catch (error, stackTrace) {
debugPrint('[ApprovalController] 초안 복구 실패: $error\n$stackTrace');
return null;
}
}
void _persistSubmissionDraft(ApprovalSubmissionInput draft) {
final useCase = _saveDraftUseCase;
if (useCase == null) {
return;
}
if (draft.steps.isEmpty) {
return;
}
final input = _buildSubmissionDraftInput(draft);
if (!input.hasSteps) {
return;
}
unawaited(
Future<void>(() async {
try {
await useCase.call(input);
} catch (error, stackTrace) {
debugPrint('[ApprovalController] 초안 저장 실패: $error\n$stackTrace');
}
}),
);
}
ApprovalDraftSaveInput _buildSubmissionDraftInput(
ApprovalSubmissionInput draft,
) {
final steps = draft.steps
.map(
(step) => ApprovalDraftStep(
stepOrder: step.stepOrder,
approverId: step.approverId,
note: step.note,
),
)
.toList(growable: false);
return ApprovalDraftSaveInput(
requesterId: draft.requesterId,
transactionId: draft.transactionId,
templateId: draft.templateId,
title: draft.title,
summary: draft.summary,
note: draft.note,
metadata: draft.metadata,
sessionKey: _submissionSessionKey(draft.requesterId),
statusId: draft.statusId,
steps: steps,
);
}
int? _defaultSubmissionStatusId() {
final pendingId = _statusIdFor(ApprovalStatusFilter.pending);
if (pendingId != null && pendingId > 0) {
return pendingId;
}
final draftId = _statusIdFor(ApprovalStatusFilter.draft);
if (draftId != null && draftId > 0) {
return draftId;
}
return null;
}
String _submissionSessionKey(int requesterId) =>
'approval_submission_$requesterId';
/// 결재를 생성하고 목록/상세 상태를 최신화한다.
Future<Approval?> createApproval(ApprovalCreateInput input) async {
_setSubmitting(true);
_errorMessage = null;
try {
final created = await _repository.create(input);
await fetch(page: 1);
_selected = created;
if (created.id != null) {
await _loadProceedStatus(created.id!);
}
notifyListeners();
return created;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
/// 결재 기본 정보를 수정하고 현재 페이지를 유지한다.
Future<Approval?> updateApproval(ApprovalUpdateInput input) async {
_setSubmitting(true);
_errorMessage = null;
try {
final updated = await _repository.update(input);
final currentPage = _result?.page ?? 1;
await fetch(page: currentPage);
_selected = updated;
if (updated.id != null) {
await _loadProceedStatus(updated.id!);
}
notifyListeners();
return updated;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
/// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다.
///
/// - 유효한 단계 ID와 액션이 존재해야 하며, 실행 중에는 중복 호출을 방지한다.
@@ -200,6 +792,12 @@ class ApprovalController extends ChangeNotifier {
required ApprovalStepActionType type,
String? note,
}) async {
final approvalId = _selected?.id;
if (approvalId == null) {
_errorMessage = '선택한 결재 정보가 없어 단계를 처리할 수 없습니다.';
notifyListeners();
return false;
}
if (step.id == null) {
_errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.';
notifyListeners();
@@ -217,6 +815,15 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final proceedStatus = await _repository.canProceed(approvalId);
_proceedStatus = proceedStatus;
final actingOnCurrentStep = _isCurrentStep(step.id);
if (!proceedStatus.canProceed && !actingOnCurrentStep) {
_errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.';
notifyListeners();
return false;
}
final sanitizedNote = note?.trim();
final updated = await _repository.performStepAction(
ApprovalStepActionInput(
@@ -232,9 +839,20 @@ class ApprovalController extends ChangeNotifier {
.toList();
_result = _result!.copyWith(items: items);
}
if (updated.id != null) {
await _loadProceedStatus(updated.id!);
} else {
await _loadProceedStatus(approvalId);
}
await _syncTransactionStatusAfterFinalApproval(
approval: updated,
actionType: type,
note: sanitizedNote,
);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isPerformingAction = false;
@@ -291,8 +909,9 @@ class ApprovalController extends ChangeNotifier {
_result = _result!.copyWith(items: items);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isApplyingTemplate = false;
@@ -301,31 +920,33 @@ class ApprovalController extends ChangeNotifier {
}
}
/// 검색 키워드를 변경하고 UI 갱신을 유도한다.
void updateQuery(String value) {
_query = value;
notifyListeners();
}
/// 상태 필터 값을 변경한다.
void updateStatusFilter(ApprovalStatusFilter filter) {
_statusFilter = filter;
notifyListeners();
}
/// 조회 기간을 설정한다. 두 값 모두 `null`이면 기간 조건을 제한다.
void updateDateRange(DateTime? from, DateTime? to) {
_fromDate = from;
_toDate = to;
/// 트랜잭션 ID 필터를 갱신한다. null이면 조건을 제한다.
void updateTransactionFilter(int? transactionId) {
_transactionIdFilter = transactionId;
notifyListeners();
}
/// 검색어/상태/기간 등의 필터 조건을 초기화한다.
/// 상신자(요청자) 필터를 갱신한다. null 값을 전달하면 조건을 제거한다.
void updateRequestedByFilter({int? id, String? name, String? employeeNo}) {
_requestedById = id;
_requestedByName = name;
_requestedByEmployeeNo = employeeNo;
notifyListeners();
}
/// 상태/트랜잭션/상신자 필터를 초기값으로 되돌린다.
void clearFilters() {
_query = '';
_statusFilter = ApprovalStatusFilter.all;
_fromDate = null;
_toDate = null;
_transactionIdFilter = null;
_requestedById = null;
_requestedByName = null;
_requestedByEmployeeNo = null;
notifyListeners();
}
@@ -335,6 +956,14 @@ class ApprovalController extends ChangeNotifier {
notifyListeners();
}
void _setSubmitting(bool value) {
if (_isSubmitting == value) {
return;
}
_isSubmitting = value;
notifyListeners();
}
/// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다.
ApprovalAction? _findActionByType(ApprovalStepActionType type) {
final aliases = _actionAliases[type] ?? [type.code];
@@ -348,4 +977,35 @@ class ApprovalController extends ChangeNotifier {
}
return null;
}
Future<void> _loadProceedStatus(int approvalId) async {
try {
final status = await _repository.canProceed(approvalId);
_proceedStatus = status;
} catch (error) {
_proceedStatus = null;
final failure = Failure.from(error);
_errorMessage ??= failure.describe();
}
}
bool _isCurrentStep(int? stepId) {
final current = _selected?.currentStep;
if (current == null) {
return false;
}
if (stepId != null && current.id != null) {
return current.id == stepId;
}
if (stepId != null) {
for (final step in _selected?.steps ?? const <ApprovalStep>[]) {
if (step.id == stepId) {
return step.stepOrder == current.stepOrder;
}
}
}
return false;
}
}
enum _TransactionSyncAction { approve, complete }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../inventory/transactions/domain/entities/stock_transaction.dart';
/// 결재 상세 팝업 상단에서 입고/출고/대여 전표의 핵심 정보를 즉시 확인할 수 있는 카드.
class ApprovalTransactionHighlightCard extends StatelessWidget {
const ApprovalTransactionHighlightCard({
super.key,
required this.transaction,
required this.isLoading,
required this.errorMessage,
required this.currencyFormatter,
required this.dateFormat,
});
final StockTransaction? transaction;
final bool isLoading;
final String? errorMessage;
final intl.NumberFormat currencyFormatter;
final intl.DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final content = _buildContent(theme);
if (content == null) {
return const SizedBox.shrink();
}
return ShadCard(padding: const EdgeInsets.all(16), child: content);
}
Widget? _buildContent(ShadThemeData theme) {
if (isLoading) {
return Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('전표 정보를 불러오는 중입니다.', style: theme.textTheme.p),
],
);
}
if (errorMessage != null) {
return _buildStatusText(
theme,
errorMessage!,
color: theme.colorScheme.destructiveForeground,
);
}
final txn = transaction;
if (txn == null) {
return _buildStatusText(theme, '연결된 전표 정보가 없습니다.');
}
final flow = _resolveTransactionFlow(txn.type.name);
final routeInfo = _RouteInfo.from(flow, txn);
final stats = _buildStats(flow, txn);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(spacing: 8, runSpacing: 8, children: _buildBadges(flow, txn)),
const SizedBox(height: 12),
_RouteSummary(route: routeInfo),
if (stats.isNotEmpty) ...[
const SizedBox(height: 12),
_RouteStats(stats: stats),
],
const SizedBox(height: 16),
_LineItemTable(lines: txn.lines, currencyFormatter: currencyFormatter),
],
);
}
Widget _buildStatusText(ShadThemeData theme, String message, {Color? color}) {
return Row(
children: [
Icon(
Icons.info_outline,
size: 18,
color: color ?? theme.colorScheme.mutedForeground,
),
const SizedBox(width: 8),
Expanded(
child: Text(message, style: theme.textTheme.p.copyWith(color: color)),
),
],
);
}
List<_RouteStat> _buildStats(
_TransactionFlow flow,
StockTransaction transaction,
) {
final stats = <_RouteStat>[
_RouteStat(label: '품목 수', value: '${transaction.itemCount}'),
_RouteStat(
label: '총 수량',
value: _formatQuantity(transaction.totalQuantity),
),
];
if (flow == _TransactionFlow.inbound) {
stats.add(
_RouteStat(
label: '총 금액',
value: currencyFormatter.format(transaction.lines.totalAmount),
),
);
}
final expectedReturn = transaction.expectedReturnDate;
if (flow == _TransactionFlow.rental && expectedReturn != null) {
stats.add(
_RouteStat(
label: '예상 반납일',
value: dateFormat.format(expectedReturn.toLocal()),
),
);
}
return stats;
}
List<Widget> _buildBadges(
_TransactionFlow flow,
StockTransaction transaction,
) {
final typeLabel = () {
switch (flow) {
case _TransactionFlow.inbound:
return '입고';
case _TransactionFlow.outbound:
return '출고';
case _TransactionFlow.rental:
return '대여';
case _TransactionFlow.unknown:
return transaction.type.name;
}
}();
return [
ShadBadge(child: Text(typeLabel)),
ShadBadge.outline(child: Text(transaction.status.name)),
];
}
}
class _RouteSummary extends StatelessWidget {
const _RouteSummary({required this.route});
final _RouteInfo route;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final children = [
_RouteTile(label: '출발지', value: route.sourceValue),
_RouteTile(label: '도착지', value: route.destinationValue),
];
return LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 520;
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < children.length; index++) ...[
if (index > 0) const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.muted.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: children[index],
),
],
],
);
}
return Row(
children: [
for (var index = 0; index < children.length; index++) ...[
if (index > 0) const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.muted.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: children[index],
),
),
],
],
);
},
);
}
}
class _RouteTile extends StatelessWidget {
const _RouteTile({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.p),
],
);
}
}
class _RouteStats extends StatelessWidget {
const _RouteStats({required this.stats});
final List<_RouteStat> stats;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width;
final columns = maxWidth < 520 ? 1 : 3;
final spacing = columns == 1 ? 0.0 : 12.0;
final tileWidth = columns == 1
? maxWidth
: (maxWidth - spacing * (columns - 1)) / columns;
return Wrap(
spacing: 12,
runSpacing: 12,
children: stats
.map(
(stat) => SizedBox(
width: columns == 1 ? maxWidth : tileWidth,
child: _RouteTile(label: stat.label, value: stat.value),
),
)
.toList(growable: false),
);
},
);
}
}
class _LineItemTable extends StatelessWidget {
const _LineItemTable({required this.lines, required this.currencyFormatter});
final List<StockTransactionLine> lines;
final intl.NumberFormat currencyFormatter;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
if (lines.isEmpty) {
return Text('등록된 라인 품목이 없습니다.', style: theme.textTheme.muted);
}
final header = <ShadTableCell>[
const ShadTableCell.header(child: Text('라인')),
const ShadTableCell.header(child: Text('품목')),
const ShadTableCell.header(child: Text('단위')),
const ShadTableCell.header(child: Text('수량')),
const ShadTableCell.header(child: Text('단가')),
const ShadTableCell.header(child: Text('금액')),
const ShadTableCell.header(child: Text('비고')),
];
final quantityFormatter = intl.NumberFormat.decimalPattern('ko_KR');
final rows = lines
.map(
(line) => <ShadTableCell>[
ShadTableCell(child: Text('${line.lineNo}')),
ShadTableCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_productLabel(line.product.code, line.product.name)),
if (line.product.vendor?.name != null)
Text(
line.product.vendor!.name,
style: theme.textTheme.small,
),
],
),
),
ShadTableCell(child: Text(line.product.uom?.name ?? '-')),
ShadTableCell(
child: Text('${quantityFormatter.format(line.quantity)}'),
),
ShadTableCell(
child: Text(currencyFormatter.format(line.unitPrice)),
),
ShadTableCell(
child: Text(
currencyFormatter.format(line.unitPrice * line.quantity),
),
),
ShadTableCell(child: Text(_formatOptional(line.note))),
],
)
.toList(growable: false);
final tableHeight = _lineTableHeight(rows.length);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'라인 품목',
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SizedBox(
height: tableHeight,
child: ShadTable.list(
header: header,
children: rows,
columnSpanExtent: (index) =>
FixedTableSpanExtent(_lineColumnWidth(index)),
),
),
],
);
}
String _formatOptional(String? value) {
if (value == null || value.trim().isEmpty) {
return '-';
}
return value.trim();
}
double _lineColumnWidth(int index) {
const widths = [60.0, 260.0, 80.0, 80.0, 120.0, 140.0, 160.0];
if (index < widths.length) {
return widths[index];
}
return widths.last;
}
double _lineTableHeight(int rowCount) {
const rowHeight = 52.0;
return (rowCount + 1) * rowHeight;
}
}
class _RouteInfo {
_RouteInfo({required this.sourceValue, required this.destinationValue});
final String sourceValue;
final String destinationValue;
factory _RouteInfo.from(_TransactionFlow flow, StockTransaction transaction) {
switch (flow) {
case _TransactionFlow.inbound:
return _RouteInfo(
sourceValue: _sourceVendors(transaction.lines),
destinationValue: _warehouseLabel(transaction.warehouse),
);
case _TransactionFlow.outbound:
case _TransactionFlow.rental:
return _RouteInfo(
sourceValue: _warehouseLabel(transaction.warehouse),
destinationValue: _customerSummary(transaction.customers),
);
case _TransactionFlow.unknown:
return _RouteInfo(
sourceValue: _warehouseLabel(transaction.warehouse),
destinationValue: '-',
);
}
}
}
class _RouteStat {
const _RouteStat({required this.label, required this.value});
final String label;
final String value;
}
enum _TransactionFlow { inbound, outbound, rental, unknown }
_TransactionFlow _resolveTransactionFlow(String rawTypeName) {
final normalized = rawTypeName.toLowerCase();
if (normalized.contains('입고') || normalized.contains('inbound')) {
return _TransactionFlow.inbound;
}
if (normalized.contains('출고') || normalized.contains('outbound')) {
return _TransactionFlow.outbound;
}
if (normalized.contains('대여') || normalized.contains('rental')) {
return _TransactionFlow.rental;
}
return _TransactionFlow.unknown;
}
String _formatQuantity(int value) {
final formatter = intl.NumberFormat.decimalPattern('ko_KR');
return '${formatter.format(value)}';
}
String _productLabel(String code, String name) {
final trimmedCode = code.trim();
final trimmedName = name.trim();
if (trimmedCode.isEmpty) {
return trimmedName.isEmpty ? '-' : trimmedName;
}
if (trimmedName.isEmpty) {
return trimmedCode;
}
return '$trimmedCode · $trimmedName';
}
String _warehouseLabel(StockTransactionWarehouse warehouse) {
final code = warehouse.code.trim();
final name = warehouse.name.trim();
if (code.isEmpty) {
return name.isEmpty ? '창고 정보 없음' : name;
}
if (name.isEmpty) {
return code;
}
return '$name ($code)';
}
String _sourceVendors(List<StockTransactionLine> lines) {
final names = lines
.map((line) => line.product.vendor?.name ?? '')
.map((name) => name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (names.isEmpty) {
return '-';
}
return names.join(', ');
}
String _customerSummary(List<StockTransactionCustomer> customers) {
if (customers.isEmpty) {
return '-';
}
final names = customers
.map((customer) => customer.customer.name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (names.isEmpty) {
return '-';
}
return names.join(', ');
}

View File

@@ -0,0 +1,538 @@
import 'package:flutter/foundation.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../../approvals/domain/entities/approval_flow.dart';
import '../../../../approvals/domain/entities/approval_template.dart';
import '../../../../approvals/domain/repositories/approval_template_repository.dart';
import '../../../../approvals/domain/usecases/apply_approval_template_use_case.dart';
import '../../../../approvals/domain/usecases/save_approval_template_use_case.dart';
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
/// 결재 요청 화면에서 사용하는 참가자 요약 정보.
///
/// - 상신자(requester)와 승인자(approver)에 공통으로 적용한다.
class ApprovalRequestParticipant {
const ApprovalRequestParticipant({
required this.id,
required this.name,
required this.employeeNo,
});
final int id;
final String name;
final String employeeNo;
/// [ApprovalRequester]로 변환한다.
ApprovalRequester toRequester() {
return ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
}
/// [ApprovalApprover]로 변환한다.
ApprovalApprover toApprover() {
return ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
}
}
/// 결재 요청 단계 상태를 표현한다.
class ApprovalRequestStep {
const ApprovalRequestStep({
required this.stepOrder,
required this.approver,
this.note,
});
final int stepOrder;
final ApprovalRequestParticipant approver;
final String? note;
int get approverId => approver.id;
ApprovalRequestStep copyWith({
int? stepOrder,
ApprovalRequestParticipant? approver,
String? note,
}) {
return ApprovalRequestStep(
stepOrder: stepOrder ?? this.stepOrder,
approver: approver ?? this.approver,
note: note ?? this.note,
);
}
/// 도메인 계층에서 사용하는 [ApprovalStepAssignmentItem]으로 변환한다.
ApprovalStepAssignmentItem toAssignmentItem() {
return ApprovalStepAssignmentItem(
stepOrder: stepOrder,
approverId: approver.id,
note: note,
);
}
}
/// 결재 템플릿 버전 정보를 보관한다.
class ApprovalTemplateSnapshot {
const ApprovalTemplateSnapshot({
required this.templateId,
required this.updatedAt,
});
final int templateId;
final DateTime? updatedAt;
ApprovalTemplateSnapshot copyWith({int? templateId, DateTime? updatedAt}) {
return ApprovalTemplateSnapshot(
templateId: templateId ?? this.templateId,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
/// 결재 요청 상태를 관리하고 검증/전송 모델로 변환하는 컨트롤러.
///
/// - 최대 98단계까지 결재 단계를 추가할 수 있으며, 승인자 중복을 방지한다.
/// - 마지막 단계 승인자가 최종 승인자로 자동 바인딩된다.
/// - 템플릿을 적용/변경할 때 버전 정보를 기록해 추후 비교에 활용한다.
class ApprovalRequestController extends ChangeNotifier {
ApprovalRequestController({
int maxSteps = 98,
ApprovalTemplateRepository? templateRepository,
SaveApprovalTemplateUseCase? saveTemplateUseCase,
ApplyApprovalTemplateUseCase? applyTemplateUseCase,
}) : assert(maxSteps > 0, 'maxSteps는 1 이상이어야 합니다.'),
_maxSteps = maxSteps,
_templateRepository = templateRepository,
_saveTemplateUseCase = saveTemplateUseCase,
_applyTemplateUseCase = applyTemplateUseCase;
static const int defaultMaxSteps = 98;
final int _maxSteps;
final ApprovalTemplateRepository? _templateRepository;
final SaveApprovalTemplateUseCase? _saveTemplateUseCase;
final ApplyApprovalTemplateUseCase? _applyTemplateUseCase;
ApprovalRequestParticipant? _requester;
final List<ApprovalRequestStep> _steps = <ApprovalRequestStep>[];
ApprovalTemplateSnapshot? _templateSnapshot;
bool _isDirty = false;
String? _errorMessage;
bool _isApplyingTemplate = false;
ApprovalRequestParticipant? get requester => _requester;
List<ApprovalRequestStep> get steps => List.unmodifiable(_steps);
int get maxSteps => _maxSteps;
bool get hasReachedStepLimit => _steps.length >= _maxSteps;
bool get hasDuplicateApprover =>
_steps.map((step) => step.approverId).toSet().length != _steps.length;
bool get hasRequesterConflict {
final requester = _requester;
if (requester == null) {
return false;
}
return _steps.any((step) => step.approverId == requester.id);
}
int get totalSteps => _steps.length;
String? get errorMessage => _errorMessage;
bool get isDirty => _isDirty;
bool get isApplyingTemplate => _isApplyingTemplate;
ApprovalTemplateSnapshot? get templateSnapshot => _templateSnapshot;
ApprovalRequestParticipant? get finalApprover {
if (_steps.isEmpty) {
return null;
}
return _steps.last.approver;
}
int? get finalApproverId => finalApprover?.id;
/// 상신자를 설정한다.
void setRequester(ApprovalRequestParticipant? participant) {
if (_requester == participant) {
return;
}
_requester = participant;
if (participant != null &&
_steps.any((step) => step.approverId == participant.id)) {
_markDirty();
_setError('상신자는 승인자로 지정할 수 없습니다.');
return;
}
_isDirty = true;
_clearError();
notifyListeners();
}
/// 결재 단계를 추가한다.
///
/// - 최대 단계 수를 초과하거나 중복 승인자를 추가하면 false를 반환한다.
bool addStep({required ApprovalRequestParticipant approver, String? note}) {
if (hasReachedStepLimit) {
_setError('결재 단계는 최대 $_maxSteps개까지 추가할 수 있습니다.');
return false;
}
final duplicated = _steps.any((step) => step.approverId == approver.id);
if (_conflictsWithRequester(approver)) {
_setError('상신자는 승인자로 지정할 수 없습니다.');
return false;
}
if (duplicated) {
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
return false;
}
_clearError();
final step = ApprovalRequestStep(
stepOrder: _steps.length + 1,
approver: approver,
note: note,
);
_steps.add(step);
_markDirty();
notifyListeners();
return true;
}
/// 지정된 위치의 결재 단계를 제거한다.
void removeStepAt(int index) {
if (index < 0 || index >= _steps.length) {
return;
}
_clearError();
_steps.removeAt(index);
_reassignStepOrders();
_markDirty();
notifyListeners();
}
/// 결재 단계의 순서를 이동한다.
void moveStep(int oldIndex, int newIndex) {
if (oldIndex < 0 ||
oldIndex >= _steps.length ||
newIndex < 0 ||
newIndex >= _steps.length ||
oldIndex == newIndex) {
return;
}
_clearError();
final step = _steps.removeAt(oldIndex);
_steps.insert(newIndex, step);
_reassignStepOrders();
_markDirty();
notifyListeners();
}
/// 결재 단계를 수정한다.
///
/// - 승인자를 변경할 경우 중복 여부를 검사한다.
bool updateStep(
int index, {
ApprovalRequestParticipant? approver,
String? note,
}) {
if (index < 0 || index >= _steps.length) {
return false;
}
final current = _steps[index];
final nextApprover = approver ?? current.approver;
final duplicated =
approver != null &&
_steps.asMap().entries.any(
(entry) =>
entry.key != index && entry.value.approverId == nextApprover.id,
);
if (_conflictsWithRequester(nextApprover)) {
_setError('상신자는 승인자로 지정할 수 없습니다.');
return false;
}
if (duplicated) {
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
return false;
}
_clearError();
_steps[index] = current.copyWith(approver: approver, note: note);
_markDirty();
notifyListeners();
return true;
}
/// 최종 승인자를 지정한다.
///
/// - 단계가 없으면 새로운 마지막 단계를 추가한다.
/// - 이미 존재하는 경우 마지막 단계만 해당 승인자로 교체한다.
bool setFinalApprover(ApprovalRequestParticipant approver, {String? note}) {
if (_conflictsWithRequester(approver)) {
_setError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
return false;
}
if (_steps.isEmpty) {
return addStep(approver: approver, note: note);
}
final duplicateOtherIndex = _steps
.sublist(0, _steps.length - 1)
.any((step) => step.approverId == approver.id);
if (duplicateOtherIndex) {
_setError('최종 승인자는 다른 단계와 중복될 수 없습니다.');
return false;
}
final lastIndex = _steps.length - 1;
final last = _steps[lastIndex];
_steps[lastIndex] = last.copyWith(
approver: approver,
note: note ?? last.note,
);
_clearError();
_markDirty();
notifyListeners();
return true;
}
/// 템플릿 단계를 그대로 적용한다.
void applyTemplateSteps(List<ApprovalRequestStep> steps) {
if (_requester != null &&
steps.any((step) => step.approverId == _requester!.id)) {
_markDirty();
_setError('상신자는 승인자로 지정할 수 없습니다.');
return;
}
_steps
..clear()
..addAll(steps);
_reassignStepOrders();
_clearError();
_markDirty();
notifyListeners();
}
/// 템플릿 스냅샷을 기록한다.
void setTemplateSnapshot(ApprovalTemplateSnapshot? snapshot) {
_templateSnapshot = snapshot;
_markDirty();
notifyListeners();
}
/// 템플릿 버전이 최신인지 간단히 확인한다.
bool isTemplateUpToDate(DateTime? serverUpdatedAt) {
final snapshot = _templateSnapshot;
if (snapshot == null) {
return true;
}
if (snapshot.updatedAt == null || serverUpdatedAt == null) {
return true;
}
return !snapshot.updatedAt!.isBefore(serverUpdatedAt);
}
/// 현재 상태로부터 결재 상신 입력 모델을 생성한다.
ApprovalSubmissionInput buildSubmissionInput({
int? transactionId,
int? templateId,
required int statusId,
DateTime? requestedAt,
DateTime? decidedAt,
DateTime? cancelledAt,
DateTime? lastActionAt,
String? title,
String? summary,
String? note,
Map<String, dynamic>? metadata,
}) {
final requester = _ensureRequester();
final steps = _ensureSteps();
return ApprovalSubmissionInput(
transactionId: transactionId,
templateId: templateId ?? _templateSnapshot?.templateId,
statusId: statusId,
requesterId: requester.id,
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
requestedAt: requestedAt,
decidedAt: decidedAt,
cancelledAt: cancelledAt,
lastActionAt: lastActionAt,
title: title,
summary: summary,
note: note,
metadata: metadata,
steps: steps.map((step) => step.toAssignmentItem()).toList(),
);
}
/// 재고 전표 결재 입력 모델로 변환한다.
StockTransactionApprovalInput buildTransactionApprovalInput({
int? approvalStatusId,
int? templateId,
DateTime? requestedAt,
DateTime? decidedAt,
DateTime? cancelledAt,
DateTime? lastActionAt,
String? title,
String? summary,
String? note,
Map<String, dynamic>? metadata,
}) {
final requester = _ensureRequester();
final steps = _ensureSteps();
return StockTransactionApprovalInput(
requestedById: requester.id,
approvalStatusId: approvalStatusId,
templateId: templateId ?? _templateSnapshot?.templateId,
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
requestedAt: requestedAt,
decidedAt: decidedAt,
cancelledAt: cancelledAt,
lastActionAt: lastActionAt,
title: title,
summary: summary,
note: note,
metadata: metadata,
steps: steps.map((step) => step.toAssignmentItem()).toList(),
);
}
/// 현재 상태를 초기화한다.
void clear() {
_requester = null;
_steps.clear();
_templateSnapshot = null;
_errorMessage = null;
_isDirty = false;
notifyListeners();
}
/// 템플릿을 저장 후 상태를 갱신한다.
///
/// 외부에서 저장 유즈케이스를 주입한 경우에만 동작한다.
Future<ApprovalTemplate?> saveTemplate({
int? templateId,
required ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
}) async {
final useCase = _saveTemplateUseCase;
if (useCase == null) {
throw StateError('SaveApprovalTemplateUseCase가 주입되지 않았습니다.');
}
final template = await useCase.call(
templateId: templateId,
input: input,
steps: steps,
);
_templateSnapshot = ApprovalTemplateSnapshot(
templateId: template.id,
updatedAt: template.updatedAt,
);
_clearError();
_markDirty();
notifyListeners();
return template;
}
/// 템플릿을 적용해 결재 단계를 갱신한다.
///
/// - 템플릿 저장소와 Apply 유즈케이스가 모두 주입된 경우에만 지원한다.
Future<ApprovalFlow?> applyTemplate({
required int approvalId,
required int templateId,
}) async {
final repository = _templateRepository;
final useCase = _applyTemplateUseCase;
if (repository == null || useCase == null) {
throw StateError('템플릿 적용을 위한 의존성이 주입되지 않았습니다.');
}
_isApplyingTemplate = true;
notifyListeners();
try {
final template = await repository.fetchDetail(
templateId,
includeSteps: true,
);
if (template.steps.isEmpty) {
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
}
final flow = await useCase.call(
approvalId: approvalId,
templateId: templateId,
);
_templateSnapshot = ApprovalTemplateSnapshot(
templateId: template.id,
updatedAt: template.updatedAt,
);
final steps = template.steps
.map(
(step) => ApprovalRequestStep(
stepOrder: step.stepOrder,
approver: ApprovalRequestParticipant(
id: step.approver.id,
name: step.approver.name,
employeeNo: step.approver.employeeNo,
),
note: step.note,
),
)
.toList(growable: false);
applyTemplateSteps(steps);
return flow;
} finally {
_isApplyingTemplate = false;
notifyListeners();
}
}
void _setError(String message) {
_errorMessage = message;
notifyListeners();
}
void _clearError() {
_errorMessage = null;
}
void _reassignStepOrders() {
for (var index = 0; index < _steps.length; index++) {
final current = _steps[index];
_steps[index] = current.copyWith(stepOrder: index + 1);
}
}
List<ApprovalRequestStep> _ensureSteps() {
if (_steps.isEmpty) {
throw StateError('최소 한 개 이상의 결재 단계를 추가해야 합니다.');
}
if (hasDuplicateApprover) {
throw StateError('동일한 승인자가 중복되어 있습니다.');
}
final requester = _requester;
if (requester != null) {
for (var index = 0; index < _steps.length; index++) {
final step = _steps[index];
if (step.approverId != requester.id) {
continue;
}
if (index == _steps.length - 1) {
throw StateError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
}
throw StateError('상신자는 승인자로 지정할 수 없습니다.');
}
}
return List<ApprovalRequestStep>.unmodifiable(_steps);
}
ApprovalRequestParticipant _ensureRequester() {
final requester = _requester;
if (requester == null) {
throw StateError('상신자를 선택해야 합니다.');
}
return requester;
}
void _markDirty() {
_isDirty = true;
}
bool _conflictsWithRequester(ApprovalRequestParticipant participant) {
final requester = _requester;
if (requester == null) {
return false;
}
return requester.id == participant.id;
}
}

Some files were not shown because too many files have changed in this diff Show More