API v4 계약 반영하고 보고서·입출고 화면 실연동 강화
This commit is contained in:
@@ -1,56 +1,52 @@
|
|||||||
# 백엔드 수정 요청서 (Master/Transaction API 확장)
|
# 백엔드 수정 요청서 (2024-08-XX 갱신)
|
||||||
|
|
||||||
## 1. 배경
|
## 1. 배경
|
||||||
- 프론트엔드에서 인벤토리/승인 플로우를 실데이터에 맞춰 구현하기 위해서는 백엔드가 스펙(`stock_approval_system_api_v4.md`)상의 모든 마스터와 재고 트랜잭션 API를 제공해야 한다.
|
- Flutter 프론트엔드(`superport_v2`)가 최신 백엔드(`superport_api_v2`)와 실연동을 준비하면서, 일부 엔드포인트가 미구현이거나 응답 스키마가 불완전해 화면 기능을 마무리하기 어렵다.
|
||||||
- 현 시점에는 `vendors`, `uoms`, `transaction_types`, `transaction_statuses`, `approval_statuses`, `approval_actions`, `warehouses` 엔드포인트까지만 구현되어 있으며, Flutter 화면은 아직 mock 데이터를 사용 중이다.
|
- 프론트는 Clean Architecture 기반으로 이미 도메인/레포지토리 계층을 구성했으며, UI 스펙에 맞춰 API 계약을 준수해야 한다.
|
||||||
- 백엔드 코드를 직접 수정할 수 없는 상황이므로, 필요한 변경 사항을 명확히 정리해 전담 팀/담당자에게 전달한다.
|
- 본 문서는 백엔드 측 변경·추가 개발이 필요한 항목을 정리해 담당자에게 전달하기 위한 요청서이다.
|
||||||
|
|
||||||
## 2. 요청 범위
|
## 2. 주요 이슈 요약
|
||||||
### 2.1 기본 경로 정렬
|
- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 존재하지 않는다.
|
||||||
- 모든 REST 엔드포인트는 `/api/v1` prefix 하위로 노출되어야 하며, 기존 구현(vendors/uoms/transaction-types/transaction-statuses/approval-statuses/approval-actions/warehouses)도 동일한 경로를 유지해야 한다.
|
- 결재 단계(`approval-steps`) 관련 API가 단계 CRUD 조회/생성 응답을 돌려주지 않아 프론트 단계 관리 화면을 구현할 수 없다.
|
||||||
- OpenAPI/스펙 문서에는 버전 프리픽스가 명확히 표기되어야 하며, 변경 시 프론트엔드가 사용하는 베이스 URL(`Environment.baseUrl`)과 일치하도록 공지한다.
|
- 결재 단계 액션(`POST /approval-steps/{id}/actions`)과 승인 단계 일괄 등록/수정(`POST/PATCH /approvals/{id}/steps`)이 204만 반환해 프론트가 최신 결재 정보를 다시 받지 못한다.
|
||||||
|
- 그룹-메뉴 권한 목록이 메뉴 `route_path` 정보를 제공하지 않아 권한 매니저가 라우트별 권한을 구성할 수 없다.
|
||||||
|
|
||||||
### 2.2 마스터 데이터 API 확대
|
## 3. 상세 요청
|
||||||
- 대상 테이블: `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) 도메인 확장
|
### 3.1 보고서 Export API 구현
|
||||||
- 리소스: `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates`
|
- 엔드포인트:
|
||||||
- 요구 사항
|
- `GET /api/v1/reports/transactions/export`
|
||||||
- 리스트/상세 API는 `include=steps,histories` 등 프론트가 사용하는 확장 파라미터를 지원해야 한다.
|
- `GET /api/v1/reports/approvals/export`
|
||||||
- 단계 배정(`POST /approvals/{id}/steps`), 단계 재배치(`PATCH /approvals/{id}/steps`), 단계 액션 수행(`POST /approval-steps/{id}/actions`)을 스펙대로 구현
|
- 요구 사항:
|
||||||
- 승인 가능 여부 조회(`GET /approvals/{id}/can-proceed`) 및 복구(`/approvals/{id}/restore`) 포함
|
- 쿼리 파라미터: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `type_id`, `status_id`, `warehouse_id` 등 프론트에서 사용하는 필터 수용.
|
||||||
- 응답에는 Domain DTO(`ApprovalDto`, `ApprovalActionDto` 등)에서 필요로 하는 필드가 누락되지 않도록 검증
|
- 응답:
|
||||||
|
- 파일 다운로드(바이트 스트림) 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at`을 포함한 JSON.
|
||||||
|
- 인증/권한 정책 확정 후 문서화.
|
||||||
|
|
||||||
### 2.4 재고 트랜잭션 API 설계 및 구현
|
### 3.2 결재 단계/행위 API 정합성 보강
|
||||||
- 리소스: `stock_transactions` (입고/출고/대여), `transaction_lines`, `transaction_approvals` 등 스펙 정의 테이블
|
- `GET /api/v1/approval-steps` 및 단건/생성/수정/삭제/복구 API 구현이 필요하다. (프론트 `ApprovalStepRepository`가 CRUD 전체를 호출)
|
||||||
- 요구 사항
|
- `POST /api/v1/approval-steps/{id}/actions`는 204 대신 갱신된 결재 본문 혹은 최소한 단계와 상태 변화를 반환해야 한다.
|
||||||
- 목록 필터: 상태, 창고, 고객/거래처, 기간(처리일/반납예정일 등), 포함(include=lines, approval_history 등)
|
- `POST|PATCH /api/v1/approvals/{id}/steps` 역시 204가 아닌
|
||||||
- 상세 응답: 헤더 정보 + 라인아이템 + 승인 이력/로그 전달
|
- 변경된 `approval` 요약, 혹은
|
||||||
- 상태 전이/승인 플로우 API (`submit`, `approve`, `reject`, `cancel`)와 재고 처리 결과 반영
|
- 새로 구성된 단계 리스트
|
||||||
- soft-delete 및 복구 정책 정의 (필요 시 논의)
|
를 포함한 JSON 응답을 제공해 프론트가 재조회 없이 상태를 갱신할 수 있도록 해 달라.
|
||||||
- SeaORM 트랜잭션을 이용해 헤더/라인/로그 동시 저장
|
- 액션/단계 요청 본문은 `stock_approval_system_api_v4.md` 스펙과 동일하게 유지.
|
||||||
|
|
||||||
### 2.5 공통 고려사항
|
### 3.3 그룹-메뉴 권한 응답 확장
|
||||||
- DTO/응답 구조는 `stock_approval_system_api_v4.md`와 동기화하고, 변경 시 문서도 업데이트
|
- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 다음 필드를 추가:
|
||||||
- `script/run_api_tests.sh`에 각 리소스의 CRUD 및 상태 전이 스텝을 추가해 회귀 테스트 가능하도록 보완
|
- `route_path` (launched 메뉴일 경우 실제 라우트 경로)
|
||||||
- 샘플 데이터(`migration/002_sample_data.sql`)는 필수 참조 데이터만 유지하고, 대량 더미는 옵션 플래그로 분리
|
- 필요 시 `menu_code` 그대로 유지.
|
||||||
|
- 선택적으로 `include=group` 파라미터를 지원해 그룹 요약을 함께 반환하면 Front 권한 동기화 시 재조회가 줄어든다.
|
||||||
|
|
||||||
## 3. 선행 작업 및 의존성
|
### 3.4 응답/에러 문서화
|
||||||
- 데이터 모델 검증: 스펙과 현재 DB 스키마 일치 여부 확인, 필요한 경우 추가 마이그레이션 작성
|
- 위 변경 사항이 반영되면 `stock_approval_system_api_v4.md`를 업데이트하고, 각 엔드포인트 예제 응답을 최신 상태로 반영한다.
|
||||||
- 인증/권한: 그룹-메뉴 권한 매핑을 API 보호 미들웨어에 적용 (추후 프론트엔드 권한 제어와 연동)
|
- 회귀 테스트(`cargo test` + 통합 시나리오 스크립트)가 변경된 계약을 검증하도록 보강한다.
|
||||||
- 로깅/관측성: 주요 재고 트랜잭션 이벤트를 tracing 로그로 남겨 운영 대응
|
|
||||||
|
|
||||||
## 4. 수용 기준 (Acceptance Criteria)
|
## 4. 수용 기준
|
||||||
- 모든 신규/확장된 엔드포인트에 대해 Actix 라우트, 도메인 DTO, SeaORM 리포지토리, 에러 매핑이 완비되어야 한다.
|
- 상기 엔드포인트가 모두 구현되고, 요청/응답이 문서와 일치해야 한다.
|
||||||
- `cargo check`, `cargo test`, `script/run_api_tests.sh`가 통과해야 하며, 샘플 DB로 기본 CRUD 시나리오가 동작할 것.
|
- 레거시 응답(204)에서 JSON 반환으로 변경될 경우, 클라이언트가 기대하는 키(`data.approval`, `data.steps` 등)를 포함해야 한다.
|
||||||
- README `Next Steps` 섹션 업데이트와 변경된 API 스펙 커밋이 포함되어야 한다.
|
- `cargo fmt`, `cargo check`, `cargo test` 및 기존 CI 파이프라인이 통과한다.
|
||||||
|
|
||||||
## 5. 후속 조치
|
## 5. 후속 조치
|
||||||
- 본 문서 확인 후 백엔드 담당자가 작업 범위/일정을 산출
|
- 백엔드 담당자가 일정/우선순위를 산출해 프론트 팀과 공유.
|
||||||
- 작업 완료 시 프론트엔드 팀에 API mock 제거 및 실연동 착수 일정 공유
|
- 구현 완료 후 샌드박스 환경에서 API 계약 검증 → 프론트엔드 실연동 착수.
|
||||||
- 필요 시 추가 논의 사항을 본 문서 하단에 코멘트 형태로 기록
|
|
||||||
|
|||||||
@@ -114,3 +114,18 @@
|
|||||||
- [x] 그룹-메뉴 권한 복구 미구현(4건)은 `/group-menu-permissions/{id}/restore` 엔드포인트 공개 후 프론트 통합 테스트에 포함시킨다. (2025-10-19) 동일 문서 2.2절에 복구 API 요구사항을 명시하고 테스트 시나리오를 정리했다.
|
- [x] 그룹-메뉴 권한 복구 미구현(4건)은 `/group-menu-permissions/{id}/restore` 엔드포인트 공개 후 프론트 통합 테스트에 포함시킨다. (2025-10-19) 동일 문서 2.2절에 복구 API 요구사항을 명시하고 테스트 시나리오를 정리했다.
|
||||||
- [x] 프론트단에서는 `ApiErrorMapper`와 `Failure` 파서를 보강해 403/409/422 응답 메시지를 토스트·다이얼로그에 그대로 노출하고, 재시도 시 가이드 문구를 제공한다.
|
- [x] 프론트단에서는 `ApiErrorMapper`와 `Failure` 파서를 보강해 403/409/422 응답 메시지를 토스트·다이얼로그에 그대로 노출하고, 재시도 시 가이드 문구를 제공한다.
|
||||||
- [x] 백엔드 수정 전까지 승인/취소 버튼에는 기능 플래그를 적용해 운영 환경에서 잘못된 전이 요청이 발생하지 않도록 보호한다. (2025-10-19) `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그를 추가하고 입·출·대여 화면에서 버튼을 비활성화하며 안내 배지를 노출하도록 조정했다.
|
- [x] 백엔드 수정 전까지 승인/취소 버튼에는 기능 플래그를 적용해 운영 환경에서 잘못된 전이 요청이 발생하지 않도록 보호한다. (2025-10-19) `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그를 추가하고 입·출·대여 화면에서 버튼을 비활성화하며 안내 배지를 노출하도록 조정했다.
|
||||||
|
|
||||||
|
## 8. 재고 생성 결재 정보 수집 계획 (2024-08-XX 업데이트)
|
||||||
|
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 문서에도 신규 체크리스트를 반영한다.
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. 구현 현황 요약 (2025-09-18 기준)
|
||||||
|
- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes`
|
||||||
|
- 각 자원은 `/api/v1/<resource>` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다.
|
||||||
|
- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`가 포함되며, FE는 이 값을 사용해 라우트별 권한 매핑을 완성해야 한다. `include=group` 쿼리를 추가하면 그룹 요약이 함께 반환돼 권한 매트릭스를 단일 호출로 구축할 수 있다.
|
||||||
|
- 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 공통 규칙
|
## 1. 공통 규칙
|
||||||
- **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`.
|
- **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`.
|
||||||
- **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`.
|
- **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`.
|
||||||
@@ -206,6 +214,7 @@
|
|||||||
"id": 301,
|
"id": 301,
|
||||||
"customer_code": "C001",
|
"customer_code": "C001",
|
||||||
"customer_name": "ABC물류",
|
"customer_name": "ABC물류",
|
||||||
|
"contact_name": "박담당",
|
||||||
"is_partner": true,
|
"is_partner": true,
|
||||||
"is_general": false,
|
"is_general": false,
|
||||||
"email": "contact@abc.com",
|
"email": "contact@abc.com",
|
||||||
@@ -229,6 +238,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `contact_name`은 고객사 담당자 실명. 선택 입력이며 미입력 시 `null`.
|
||||||
|
|
||||||
`GET /employees?page=1`
|
`GET /employees?page=1`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -270,7 +281,8 @@
|
|||||||
"menu": {
|
"menu": {
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"menu_code": "STOCK_MGMT",
|
"menu_code": "STOCK_MGMT",
|
||||||
"menu_name": "입출고 관리"
|
"menu_name": "입출고 관리",
|
||||||
|
"route_path": "/inventory/transactions"
|
||||||
},
|
},
|
||||||
"can_create": true,
|
"can_create": true,
|
||||||
"can_read": true,
|
"can_read": true,
|
||||||
@@ -418,10 +430,16 @@
|
|||||||
"unit_price": 0
|
"unit_price": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customers": []
|
"customers": [],
|
||||||
|
"approval": {
|
||||||
|
"approval_no": "APP-2025-0001",
|
||||||
|
"requested_by_id": 7,
|
||||||
|
"note": "입고 결재"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다.
|
응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. `approval`
|
||||||
|
블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다.
|
||||||
|
|
||||||
### 4.2 목록 조회
|
### 4.2 목록 조회
|
||||||
`GET /stock-transactions?include=lines,customers,approval`
|
`GET /stock-transactions?include=lines,customers,approval`
|
||||||
@@ -476,8 +494,53 @@
|
|||||||
"note": null
|
"note": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customers": [],
|
"customers": [
|
||||||
"approval": null
|
{
|
||||||
|
"id": 301,
|
||||||
|
"customer_code": "C001",
|
||||||
|
"customer_name": "ABC물류",
|
||||||
|
"contact_name": "박담당"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_no": "APP-2025-0001",
|
||||||
|
"approval_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"current_step": {
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": null
|
||||||
|
},
|
||||||
|
"requested_by": {
|
||||||
|
"id": 7,
|
||||||
|
"employee_no": "E2025001",
|
||||||
|
"employee_name": "김승인"
|
||||||
|
},
|
||||||
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "입고 결재",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-09-18T06:00:00Z",
|
||||||
|
"updated_at": "2025-09-18T06:05:00Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"page": 1,
|
"page": 1,
|
||||||
@@ -542,8 +605,53 @@
|
|||||||
"note": null
|
"note": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customers": [],
|
"customers": [
|
||||||
"approval": null
|
{
|
||||||
|
"id": 301,
|
||||||
|
"customer_code": "C001",
|
||||||
|
"customer_name": "ABC물류",
|
||||||
|
"contact_name": "박담당"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_no": "APP-2025-0001",
|
||||||
|
"approval_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"current_step": {
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": null
|
||||||
|
},
|
||||||
|
"requested_by": {
|
||||||
|
"id": 7,
|
||||||
|
"employee_no": "E2025001",
|
||||||
|
"employee_name": "김승인"
|
||||||
|
},
|
||||||
|
"requested_at": "2025-09-18T06:00:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "입고 결재",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-09-18T06:00:00Z",
|
||||||
|
"updated_at": "2025-09-18T06:05:00Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -632,15 +740,56 @@
|
|||||||
|
|
||||||
### 4.7 상태 전이 권장 API
|
### 4.7 상태 전이 권장 API
|
||||||
- `POST /stock-transactions/9001/submit`
|
- `POST /stock-transactions/9001/submit`
|
||||||
- `POST /stock-transactions/9001/complete`
|
```json
|
||||||
|
{
|
||||||
|
"id": 9001,
|
||||||
|
"note": "승인 요청"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
응답은 `{ "data": { "id": 9001, "transaction_status": { ... }, "updated_at": "..." } }` 형태.
|
- `POST /stock-transactions/9001/complete`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9001,
|
||||||
|
"note": "처리 완료"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `POST /stock-transactions/9001/approve`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9001,
|
||||||
|
"note": "최종 승인"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `POST /stock-transactions/9001/reject`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9001,
|
||||||
|
"note": "재작업 필요"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `POST /stock-transactions/9001/cancel`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9001,
|
||||||
|
"note": "상신 취소"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 액션은 `{ "data": { "id": 9001, "transaction_status": { ... }, "updated_at": "..." } }` 구조를 반환한다. `submit`은 초안 상태의 트랜잭션을 상신 상태로, 결재 현재 단계를 진행중으로 전환한다. `approve`는 결재 상태가 이미 승인(`approval_status_id = 승인`)으로 확정된 건을 재고 상태 `승인`으로 승격한다. `reject`는 상신/승인 상태의 건을 `반려` 상태로 내리고 결재 레코드도 반려로 남긴다. `cancel`은 상신된 건을 다시 초안 상태(또는 `취소` 상태가 존재할 경우 해당 상태)로 되돌리며, 결재 단계와 상태를 초기화한다. `complete` 는 결재 상태가 승인된 건에 한해 완료 상태로 변경한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 결재 API
|
## 5. 결재 API
|
||||||
리소스: `/approvals`, 보조 리소스: `/approval-steps`, `/approval-histories`
|
리소스: `/approvals`, 보조 리소스: `/approval-steps`, `/approval-histories`
|
||||||
|
|
||||||
|
- 단계 상태가 바뀔 때마다 `approvals.current_step_id`는 차기 단계의 ID로 갱신되고, 전체 결재 상태(`approval_status_id`) 역시 해당 단계 상태로 업데이트된다.
|
||||||
|
- 템플릿에서 복제된 단계는 모두 `대기` 상태로 저장되며 템플릿이 이후 수정돼도 기존 결재에는 반영되지 않는다.
|
||||||
|
- `GET /approvals/{id}/can-proceed`는 현재 단계의 상태에 매핑된 `is_blocking_next` 값이 `false`일 때 `true`를 반환한다.
|
||||||
|
|
||||||
### 5.1 결재 생성
|
### 5.1 결재 생성
|
||||||
`POST /approvals`
|
`POST /approvals`
|
||||||
```json
|
```json
|
||||||
@@ -671,7 +820,24 @@
|
|||||||
"status_name": "대기",
|
"status_name": "대기",
|
||||||
"is_blocking_next": true
|
"is_blocking_next": true
|
||||||
},
|
},
|
||||||
"current_step": null,
|
"current_step": {
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": null
|
||||||
|
},
|
||||||
"requested_by": {
|
"requested_by": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_no": "E2025001",
|
||||||
@@ -683,7 +849,26 @@
|
|||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": "2025-09-18T06:00:00Z",
|
"created_at": "2025-09-18T06:00:00Z",
|
||||||
"updated_at": "2025-09-18T06:00:00Z",
|
"updated_at": "2025-09-18T06:00:00Z",
|
||||||
"steps": [],
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": null
|
||||||
|
}
|
||||||
|
],
|
||||||
"histories": []
|
"histories": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -710,7 +895,24 @@
|
|||||||
"is_blocking_next": true,
|
"is_blocking_next": true,
|
||||||
"is_terminal": false
|
"is_terminal": false
|
||||||
},
|
},
|
||||||
"current_step": null,
|
"current_step": {
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": null
|
||||||
|
},
|
||||||
"requested_by": {
|
"requested_by": {
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"employee_no": "E2025001",
|
"employee_no": "E2025001",
|
||||||
@@ -764,6 +966,52 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"approval_id": 5001,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": 7001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver_id": 21,
|
||||||
|
"step_status_id": 1,
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": null,
|
||||||
|
"is_active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7002,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 2,
|
||||||
|
"approver_id": 34,
|
||||||
|
"step_status_id": 1,
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "재무 확인",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"current_step": {
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1
|
||||||
|
},
|
||||||
|
"updated_at": "2025-09-18T06:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 5.5 단계 일괄 수정/재배치
|
### 5.5 단계 일괄 수정/재배치
|
||||||
`PATCH /approvals/5001/steps`
|
`PATCH /approvals/5001/steps`
|
||||||
@@ -784,6 +1032,52 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"approval_id": 5001,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": 7001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver_id": 21,
|
||||||
|
"step_status_id": 1,
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "서류 확인 중",
|
||||||
|
"is_active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7002,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 2,
|
||||||
|
"approver_id": 35,
|
||||||
|
"step_status_id": 1,
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "재무 확인",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"current_step": {
|
||||||
|
"id": 7001,
|
||||||
|
"step_order": 1
|
||||||
|
},
|
||||||
|
"updated_at": "2025-09-18T06:10:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 5.6 단계 행위
|
### 5.6 단계 행위
|
||||||
`POST /approval-steps/7001/actions`
|
`POST /approval-steps/7001/actions`
|
||||||
@@ -794,7 +1088,104 @@
|
|||||||
"note": "승인합니다."
|
"note": "승인합니다."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"current_step": {
|
||||||
|
"id": 7002,
|
||||||
|
"step_order": 2,
|
||||||
|
"approver": {
|
||||||
|
"id": 34,
|
||||||
|
"employee_no": "E2025003",
|
||||||
|
"employee_name": "최검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 3,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T08:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "재무 확인"
|
||||||
|
},
|
||||||
|
"updated_at": "2025-09-18T08:05:00Z",
|
||||||
|
"histories": [
|
||||||
|
{
|
||||||
|
"id": 91001,
|
||||||
|
"approval_action_id": 1,
|
||||||
|
"action_at": "2025-09-18T08:05:00Z",
|
||||||
|
"note": "승인합니다.",
|
||||||
|
"from_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"to_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"id": 7001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver_id": 21,
|
||||||
|
"step_status_id": 2,
|
||||||
|
"assigned_at": "2025-09-18T06:05:00Z",
|
||||||
|
"decided_at": "2025-09-18T08:05:00Z",
|
||||||
|
"note": "승인합니다.",
|
||||||
|
"step_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"next_step": {
|
||||||
|
"id": 7002,
|
||||||
|
"step_order": 2,
|
||||||
|
"approver": {
|
||||||
|
"id": 34,
|
||||||
|
"employee_no": "E2025003",
|
||||||
|
"employee_name": "최검토"
|
||||||
|
},
|
||||||
|
"step_status": {
|
||||||
|
"id": 3,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"assigned_at": "2025-09-18T08:05:00Z",
|
||||||
|
"decided_at": null,
|
||||||
|
"note": "재무 확인"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"id": 91001,
|
||||||
|
"approval_step_id": 7001,
|
||||||
|
"approval_action_id": 1,
|
||||||
|
"note": "승인합니다.",
|
||||||
|
"action_at": "2025-09-18T08:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
응답에는 전후 상태(`from_status`, `to_status`), 차기 단계 정보가 포함되며, `approval_histories`에 기록된다.
|
응답에는 전후 상태(`from_status`, `to_status`), 차기 단계 정보가 포함되며, `approval_histories`에 기록된다.
|
||||||
|
프론트엔드는 204 응답이 아닌 위의 `{ "data": { ... } }` 본문을 소비해 화면 상태를 즉시 갱신해야 한다.
|
||||||
|
|
||||||
### 5.7 결재 상태 확인
|
### 5.7 결재 상태 확인
|
||||||
`GET /approvals/5001/can-proceed`
|
`GET /approvals/5001/can-proceed`
|
||||||
@@ -820,6 +1211,126 @@
|
|||||||
- `DELETE /approvals/5001`
|
- `DELETE /approvals/5001`
|
||||||
- `POST /approvals/5001/restore`
|
- `POST /approvals/5001/restore`
|
||||||
|
|
||||||
|
### 5.9 결재 이력 조회
|
||||||
|
`GET /approval-histories?approval_id=5001&include=approval,step,approver`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 91001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"approval_step_id": 7001,
|
||||||
|
"approval_action_id": 3,
|
||||||
|
"action_at": "2025-09-18T08:05:00Z",
|
||||||
|
"note": "보류 코멘트",
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"from_status": {
|
||||||
|
"id": 1,
|
||||||
|
"status_name": "대기",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"to_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_no": "APP-2025-0001",
|
||||||
|
"approval_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"id": 7001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 50,
|
||||||
|
"total": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.10 단계 개별 CRUD
|
||||||
|
- `GET /approval-steps?approval_id=5001&include=approval,approver,step_status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 요청한 `include` 토큰에 따라 관련 결재/결재자/상태 요약을 포함한다.
|
||||||
|
- `GET /approval-steps/7001?include=approval,approver,step_status` → `{ data: { ... } }`.
|
||||||
|
- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { step, approval } }` 형태로 생성된 요약과 결재 상태를 반환한다. `step_status_id`를 생략하면 자동으로 `대기` 상태가 지정된다.
|
||||||
|
- `POST /approval-steps/batch` → 여러 단계를 한 번에 생성·재정렬하며, 응답은 `{ data: { approval, steps, histories } }` 구조로 최신 결재 상태와 정렬된 단계 목록, 신규 이력 요약을 포함한다.
|
||||||
|
- `PATCH /approval-steps/batch` → 다건 단계 수정/비활성화를 처리하고 `{ data: { approval, steps, histories } }` 본문으로 변경 결과를 반환한다.
|
||||||
|
- `PATCH /approval-steps/{id}` → 갱신된 단계 요약과 함께 `{ data: { step, approval } }`를 반환한다.
|
||||||
|
- `DELETE /approval-steps/{id}` → `{ data: { id, deleted_at } }`.
|
||||||
|
- `POST /approval-steps/{id}/restore` → `{ data: { id, restored_at } }`.
|
||||||
|
|
||||||
|
주요 필터 및 확장 파라미터(approval-steps):
|
||||||
|
|
||||||
|
- `approval_id`, `approver_id`, `step_status_id`
|
||||||
|
- `include=approval,approver,step_status`
|
||||||
|
|
||||||
|
`GET /approval-histories/91001?include=approval,step`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 91001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"approval_step_id": 7001,
|
||||||
|
"approval_action_id": 3,
|
||||||
|
"action_at": "2025-09-18T08:05:00Z",
|
||||||
|
"note": "보류 코멘트",
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
},
|
||||||
|
"from_status": null,
|
||||||
|
"to_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"id": 5001,
|
||||||
|
"approval_no": "APP-2025-0001",
|
||||||
|
"approval_status": {
|
||||||
|
"id": 2,
|
||||||
|
"status_name": "진행중",
|
||||||
|
"is_blocking_next": true,
|
||||||
|
"is_terminal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"id": 7001,
|
||||||
|
"approval_id": 5001,
|
||||||
|
"step_order": 1,
|
||||||
|
"approver": {
|
||||||
|
"id": 21,
|
||||||
|
"employee_no": "E2025002",
|
||||||
|
"employee_name": "박검토"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`approval-histories` 엔드포인트는 `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`, `action_from`, `action_to`, `sort=action_at|created_at|updated_at`, `order=asc|desc` 파라미터와 `include=approval,step,approver,from_status,to_status` 확장을 지원한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 결재 템플릿 API
|
## 6. 결재 템플릿 API
|
||||||
@@ -940,11 +1451,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 보고서 API (선택)
|
## 7. 보고서 Export
|
||||||
- `GET /reports/transactions/export?from=2025-09-01&to=2025-09-30&type_id=2&warehouse_id=1&format=xlsx`
|
- `format=xlsx|pdf` 파라미터를 지원한다. 현재는 `format=xlsx`만 성공하며, `format=pdf`를 지정하면 400 Bad Request가 반환된다.
|
||||||
- `GET /reports/approvals/export?status_id=1&format=pdf`
|
- 응답은 즉시 다운로드 스트림으로 전달되며 `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`와 `Content-Disposition: attachment` 헤더가 포함된다. 프론트엔드는 받은 바이트 스트림을 그대로 파일로 저장해야 한다.
|
||||||
|
|
||||||
응답은 파일 다운로드 링크 또는 스트림. 요청 파라미터에는 대상 리소스의 PK를 포함한다.
|
### 7.1 트랜잭션 Export
|
||||||
|
`GET /api/v1/reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&approval_status_id=3&requested_by_id=7&format=xlsx`
|
||||||
|
- 지원 쿼리: `from`, `to`, `transaction_status_id`, `approval_status_id`, `requested_by_id`, `format`.
|
||||||
|
- 열 구성: `Transaction No`, `Transaction Date`, `Transaction Type`, `Status`, `Warehouse`, `Created By`, `Approval No`, `Approval Status`.
|
||||||
|
|
||||||
|
### 7.2 결재 Export
|
||||||
|
`GET /api/v1/reports/approvals/export?from=2025-09-01T00:00:00Z&to=2025-09-30T23:59:59Z&transaction_status_id=1&approval_status_id=1&requested_by_id=7&format=xlsx`
|
||||||
|
- 지원 쿼리: `from`, `to`, `transaction_status_id`, `approval_status_id`, `requested_by_id`, `format`.
|
||||||
|
- 열 구성: `Approval No`, `Approval Status`, `Transaction No`, `Requested By`, `Requested At`, `Decided At`, `Current Step Order`, `Current Step Approver`.
|
||||||
|
- `from`, `to` 파라미터는 `requested_at` 기준 UTC 타임스탬프 범위 필터다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
15
lib/core/services/file_saver.dart
Normal file
15
lib/core/services/file_saver.dart
Normal 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);
|
||||||
|
}
|
||||||
10
lib/core/services/file_saver_stub.dart
Normal file
10
lib/core/services/file_saver_stub.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// 웹 외 플랫폼에서 파일 저장이 호출되면 예외를 발생시킨다.
|
||||||
|
Future<void> saveFileBytesImpl({
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String filename,
|
||||||
|
required String mimeType,
|
||||||
|
}) async {
|
||||||
|
throw UnsupportedError('현재 플랫폼에서는 파일 저장을 지원하지 않습니다.');
|
||||||
|
}
|
||||||
20
lib/core/services/file_saver_web.dart
Normal file
20
lib/core/services/file_saver_web.dart
Normal 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();
|
||||||
|
}
|
||||||
@@ -214,7 +214,9 @@ class ApprovalStepDto {
|
|||||||
(json['approver'] as Map<String, dynamic>? ?? const {}),
|
(json['approver'] as Map<String, dynamic>? ?? const {}),
|
||||||
),
|
),
|
||||||
status: ApprovalStatusDto.fromJson(
|
status: ApprovalStatusDto.fromJson(
|
||||||
(json['status'] as Map<String, dynamic>? ?? const {}),
|
(json['status'] as Map<String, dynamic>? ??
|
||||||
|
json['step_status'] as Map<String, dynamic>? ??
|
||||||
|
const {}),
|
||||||
),
|
),
|
||||||
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
|
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
|
||||||
decidedAt: _parseDate(json['decided_at']),
|
decidedAt: _parseDate(json['decided_at']),
|
||||||
@@ -263,7 +265,12 @@ class ApprovalHistoryDto {
|
|||||||
return ApprovalHistoryDto(
|
return ApprovalHistoryDto(
|
||||||
id: json['id'] as int?,
|
id: json['id'] as int?,
|
||||||
action: ApprovalActionDto.fromJson(
|
action: ApprovalActionDto.fromJson(
|
||||||
(json['action'] as Map<String, dynamic>? ?? const {}),
|
json['action'] is Map<String, dynamic>
|
||||||
|
? json['action'] as Map<String, dynamic>
|
||||||
|
: {
|
||||||
|
'id': json['approval_action_id'],
|
||||||
|
'name': json['approval_action_name'],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
fromStatus: json['from_status'] is Map<String, dynamic>
|
fromStatus: json['from_status'] is Map<String, dynamic>
|
||||||
? ApprovalStatusDto.fromJson(
|
? ApprovalStatusDto.fromJson(
|
||||||
|
|||||||
@@ -175,22 +175,199 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
|||||||
Map<String, dynamic> body,
|
Map<String, dynamic> body,
|
||||||
) {
|
) {
|
||||||
final data = body['data'];
|
final data = body['data'];
|
||||||
if (data is Map<String, dynamic>) {
|
final dataMap = data is Map<String, dynamic> ? data : null;
|
||||||
if (data['approval'] is Map<String, dynamic>) {
|
Map<String, dynamic>? approval = _selectApprovalPayload(dataMap);
|
||||||
return data['approval'] as Map<String, dynamic>;
|
approval ??= _selectApprovalPayload(body);
|
||||||
|
if (approval == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final merged = Map<String, dynamic>.from(approval);
|
||||||
|
if (dataMap != null) {
|
||||||
|
final steps = _mergeStepsPayload(
|
||||||
|
existing: merged['steps'],
|
||||||
|
data: dataMap,
|
||||||
|
);
|
||||||
|
if (steps != null) {
|
||||||
|
merged['steps'] = steps;
|
||||||
}
|
}
|
||||||
if (data['approval_data'] is Map<String, dynamic>) {
|
|
||||||
return data['approval_data'] as Map<String, dynamic>;
|
final histories = _mergeHistoriesPayload(
|
||||||
}
|
existing: merged['histories'],
|
||||||
final hasStatus =
|
data: dataMap,
|
||||||
data.containsKey('status') || data.containsKey('approval_status');
|
);
|
||||||
if (data.containsKey('approval_no') && hasStatus) {
|
if (histories != null) {
|
||||||
return data;
|
merged['histories'] = histories;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body['approval'] is Map<String, dynamic>) {
|
return merged;
|
||||||
return body['approval'] as Map<String, dynamic>;
|
}
|
||||||
|
|
||||||
|
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 =
|
||||||
|
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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,8 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
|
|||||||
query: {
|
query: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'page_size': pageSize,
|
'page_size': pageSize,
|
||||||
if (query != null && query.isNotEmpty) 'q': query,
|
if (from != null) 'action_from': from.toIso8601String(),
|
||||||
if (action != null && action.isNotEmpty) 'action': action,
|
if (to != null) 'action_to': to.toIso8601String(),
|
||||||
if (from != null) 'from': from.toIso8601String(),
|
|
||||||
if (to != null) 'to': to.toIso8601String(),
|
|
||||||
},
|
},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1289,8 +1289,12 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
);
|
);
|
||||||
final remarkController = TextEditingController(text: initial?.remark ?? '');
|
final remarkController = TextEditingController(text: initial?.remark ?? '');
|
||||||
final transactionNumberController = TextEditingController(
|
final transactionNumberController = TextEditingController(
|
||||||
text: initial?.transactionNumber ?? '저장 시 자동 생성',
|
text: initial?.transactionNumber ?? '',
|
||||||
);
|
);
|
||||||
|
final approvalNumberController = TextEditingController(
|
||||||
|
text: initial?.raw?.approval?.approvalNo ?? '',
|
||||||
|
);
|
||||||
|
final approvalNoteController = TextEditingController();
|
||||||
final transactionTypeValue =
|
final transactionTypeValue =
|
||||||
initial?.transactionType ??
|
initial?.transactionType ??
|
||||||
_transactionTypeLookup?.name ??
|
_transactionTypeLookup?.name ??
|
||||||
@@ -1311,6 +1315,8 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? transactionNumberError;
|
||||||
|
String? approvalNumberError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
@@ -1339,12 +1345,18 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
writerController: writerController,
|
writerController: writerController,
|
||||||
writerSelection: writerSelection,
|
writerSelection: writerSelection,
|
||||||
requireWriterSelection: initial == null,
|
requireWriterSelection: initial == null,
|
||||||
|
transactionNumberController: transactionNumberController,
|
||||||
|
transactionNumberRequired: initial == null,
|
||||||
|
approvalNumberController: approvalNumberController,
|
||||||
|
approvalNumberRequired: initial == null,
|
||||||
warehouseSelection: warehouseSelection,
|
warehouseSelection: warehouseSelection,
|
||||||
statusValue: statusValue.value,
|
statusValue: statusValue.value,
|
||||||
drafts: drafts,
|
drafts: drafts,
|
||||||
lineErrors: lineErrors,
|
lineErrors: lineErrors,
|
||||||
);
|
);
|
||||||
writerError = validationResult.writerError;
|
writerError = validationResult.writerError;
|
||||||
|
transactionNumberError = validationResult.transactionNumberError;
|
||||||
|
approvalNumberError = validationResult.approvalNumberError;
|
||||||
warehouseError = validationResult.warehouseError;
|
warehouseError = validationResult.warehouseError;
|
||||||
statusError = validationResult.statusError;
|
statusError = validationResult.statusError;
|
||||||
headerNotice = validationResult.headerNotice;
|
headerNotice = validationResult.headerNotice;
|
||||||
@@ -1388,6 +1400,9 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
|
|
||||||
final remarkText = remarkController.text.trim();
|
final remarkText = remarkController.text.trim();
|
||||||
final remarkValue = remarkText.isEmpty ? null : remarkText;
|
final remarkValue = remarkText.isEmpty ? null : remarkText;
|
||||||
|
final transactionNoValue = transactionNumberController.text.trim();
|
||||||
|
final approvalNoValue = approvalNumberController.text.trim();
|
||||||
|
final approvalNoteValue = approvalNoteController.text.trim();
|
||||||
|
|
||||||
final transactionId = initial?.id;
|
final transactionId = initial?.id;
|
||||||
final initialRecord = initial;
|
final initialRecord = initial;
|
||||||
@@ -1475,6 +1490,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
final created = await controller.createTransaction(
|
final created = await controller.createTransaction(
|
||||||
StockTransactionCreateInput(
|
StockTransactionCreateInput(
|
||||||
|
transactionNo: transactionNoValue,
|
||||||
transactionTypeId: transactionTypeLookup.id,
|
transactionTypeId: transactionTypeLookup.id,
|
||||||
transactionStatusId: statusItem.id,
|
transactionStatusId: statusItem.id,
|
||||||
warehouseId: warehouseId,
|
warehouseId: warehouseId,
|
||||||
@@ -1482,6 +1498,11 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
createdById: createdById,
|
createdById: createdById,
|
||||||
note: remarkValue,
|
note: remarkValue,
|
||||||
lines: createLines,
|
lines: createLines,
|
||||||
|
approval: StockTransactionApprovalInput(
|
||||||
|
approvalNo: approvalNoValue,
|
||||||
|
requestedById: createdById,
|
||||||
|
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
result = created;
|
result = created;
|
||||||
@@ -1635,10 +1656,41 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
width: 240,
|
width: 240,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '트랜잭션번호',
|
label: '트랜잭션번호',
|
||||||
|
required: true,
|
||||||
|
errorText: transactionNumberError,
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: transactionNumberController,
|
controller: transactionNumberController,
|
||||||
readOnly: true,
|
readOnly: initial != null,
|
||||||
enabled: false,
|
enabled: initial == null,
|
||||||
|
placeholder: const Text('예: IN-2024-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (transactionNumberError != null) {
|
||||||
|
setState(() {
|
||||||
|
transactionNumberError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: SuperportFormField(
|
||||||
|
label: '결재번호',
|
||||||
|
required: true,
|
||||||
|
errorText: approvalNumberError,
|
||||||
|
child: ShadInput(
|
||||||
|
controller: approvalNumberController,
|
||||||
|
readOnly: initial != null,
|
||||||
|
enabled: initial == null,
|
||||||
|
placeholder: const Text('예: APP-2024-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (approvalNumberError != null) {
|
||||||
|
setState(() {
|
||||||
|
approvalNumberError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1680,6 +1732,16 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 500,
|
||||||
|
child: SuperportFormField(
|
||||||
|
label: '결재 메모',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: approvalNoteController,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 500,
|
width: 500,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
@@ -1802,6 +1864,8 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
writerController.dispose();
|
writerController.dispose();
|
||||||
remarkController.dispose();
|
remarkController.dispose();
|
||||||
transactionNumberController.dispose();
|
transactionNumberController.dispose();
|
||||||
|
approvalNumberController.dispose();
|
||||||
|
approvalNoteController.dispose();
|
||||||
transactionTypeController.dispose();
|
transactionTypeController.dispose();
|
||||||
processedAt.dispose();
|
processedAt.dispose();
|
||||||
|
|
||||||
@@ -2346,6 +2410,10 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
required TextEditingController writerController,
|
required TextEditingController writerController,
|
||||||
required InventoryEmployeeSuggestion? writerSelection,
|
required InventoryEmployeeSuggestion? writerSelection,
|
||||||
required bool requireWriterSelection,
|
required bool requireWriterSelection,
|
||||||
|
required TextEditingController transactionNumberController,
|
||||||
|
required bool transactionNumberRequired,
|
||||||
|
required TextEditingController approvalNumberController,
|
||||||
|
required bool approvalNumberRequired,
|
||||||
required InventoryWarehouseOption? warehouseSelection,
|
required InventoryWarehouseOption? warehouseSelection,
|
||||||
required String statusValue,
|
required String statusValue,
|
||||||
required List<_LineItemDraft> drafts,
|
required List<_LineItemDraft> drafts,
|
||||||
@@ -2353,6 +2421,8 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
}) {
|
}) {
|
||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? transactionNumberError;
|
||||||
|
String? approvalNumberError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
@@ -2368,6 +2438,18 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final transactionNumber = transactionNumberController.text.trim();
|
||||||
|
if (transactionNumberRequired && transactionNumber.isEmpty) {
|
||||||
|
transactionNumberError = '거래번호를 입력하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final approvalNumber = approvalNumberController.text.trim();
|
||||||
|
if (approvalNumberRequired && approvalNumber.isEmpty) {
|
||||||
|
approvalNumberError = '결재번호를 입력하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (warehouseSelection == null) {
|
if (warehouseSelection == null) {
|
||||||
warehouseError = '창고를 선택하세요.';
|
warehouseError = '창고를 선택하세요.';
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -2426,6 +2508,8 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
return _InboundFormValidation(
|
return _InboundFormValidation(
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
|
transactionNumberError: transactionNumberError,
|
||||||
|
approvalNumberError: approvalNumberError,
|
||||||
warehouseError: warehouseError,
|
warehouseError: warehouseError,
|
||||||
statusError: statusError,
|
statusError: statusError,
|
||||||
headerNotice: headerNotice,
|
headerNotice: headerNotice,
|
||||||
@@ -2441,6 +2525,8 @@ class _InboundFormValidation {
|
|||||||
const _InboundFormValidation({
|
const _InboundFormValidation({
|
||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
|
this.transactionNumberError,
|
||||||
|
this.approvalNumberError,
|
||||||
this.warehouseError,
|
this.warehouseError,
|
||||||
this.statusError,
|
this.statusError,
|
||||||
this.headerNotice,
|
this.headerNotice,
|
||||||
@@ -2448,6 +2534,8 @@ class _InboundFormValidation {
|
|||||||
|
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
|
final String? transactionNumberError;
|
||||||
|
final String? approvalNumberError;
|
||||||
final String? warehouseError;
|
final String? warehouseError;
|
||||||
final String? statusError;
|
final String? statusError;
|
||||||
final String? headerNotice;
|
final String? headerNotice;
|
||||||
|
|||||||
@@ -1435,6 +1435,13 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
final transactionTypeController = TextEditingController(
|
final transactionTypeController = TextEditingController(
|
||||||
text: transactionTypeValue,
|
text: transactionTypeValue,
|
||||||
);
|
);
|
||||||
|
final transactionNumberController = TextEditingController(
|
||||||
|
text: initial?.transactionNumber ?? '',
|
||||||
|
);
|
||||||
|
final approvalNumberController = TextEditingController(
|
||||||
|
text: initial?.raw?.approval?.approvalNo ?? '',
|
||||||
|
);
|
||||||
|
final approvalNoteController = TextEditingController();
|
||||||
|
|
||||||
final drafts =
|
final drafts =
|
||||||
initial?.items
|
initial?.items
|
||||||
@@ -1448,6 +1455,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? transactionNumberError;
|
||||||
|
String? approvalNumberError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
@@ -1477,6 +1486,10 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
writerController: writerController,
|
writerController: writerController,
|
||||||
writerSelection: writerSelection,
|
writerSelection: writerSelection,
|
||||||
requireWriterSelection: initial == null,
|
requireWriterSelection: initial == null,
|
||||||
|
transactionNumberController: transactionNumberController,
|
||||||
|
transactionNumberRequired: initial == null,
|
||||||
|
approvalNumberController: approvalNumberController,
|
||||||
|
approvalNumberRequired: initial == null,
|
||||||
warehouseSelection: warehouseSelection,
|
warehouseSelection: warehouseSelection,
|
||||||
statusValue: statusValue.value,
|
statusValue: statusValue.value,
|
||||||
selectedCustomers: customerSelection
|
selectedCustomers: customerSelection
|
||||||
@@ -1487,6 +1500,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
writerError = validation.writerError;
|
writerError = validation.writerError;
|
||||||
|
transactionNumberError = validation.transactionNumberError;
|
||||||
|
approvalNumberError = validation.approvalNumberError;
|
||||||
customerError = validation.customerError;
|
customerError = validation.customerError;
|
||||||
warehouseError = validation.warehouseError;
|
warehouseError = validation.warehouseError;
|
||||||
statusError = validation.statusError;
|
statusError = validation.statusError;
|
||||||
@@ -1531,6 +1546,9 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
|
|
||||||
final remarkText = remarkController.text.trim();
|
final remarkText = remarkController.text.trim();
|
||||||
final remarkValue = remarkText.isEmpty ? null : remarkText;
|
final remarkValue = remarkText.isEmpty ? null : remarkText;
|
||||||
|
final transactionNoValue = transactionNumberController.text.trim();
|
||||||
|
final approvalNoValue = approvalNumberController.text.trim();
|
||||||
|
final approvalNoteValue = approvalNoteController.text.trim();
|
||||||
final transactionId = initial?.id;
|
final transactionId = initial?.id;
|
||||||
|
|
||||||
final lineDrafts = <TransactionLineDraft>[];
|
final lineDrafts = <TransactionLineDraft>[];
|
||||||
@@ -1656,6 +1674,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
|
|
||||||
final created = await controller.createTransaction(
|
final created = await controller.createTransaction(
|
||||||
StockTransactionCreateInput(
|
StockTransactionCreateInput(
|
||||||
|
transactionNo: transactionNoValue,
|
||||||
transactionTypeId: transactionTypeLookup.id,
|
transactionTypeId: transactionTypeLookup.id,
|
||||||
transactionStatusId: statusItem.id,
|
transactionStatusId: statusItem.id,
|
||||||
warehouseId: warehouseId,
|
warehouseId: warehouseId,
|
||||||
@@ -1664,6 +1683,11 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
note: remarkValue,
|
note: remarkValue,
|
||||||
lines: createLines,
|
lines: createLines,
|
||||||
customers: createCustomers,
|
customers: createCustomers,
|
||||||
|
approval: StockTransactionApprovalInput(
|
||||||
|
approvalNo: approvalNoValue,
|
||||||
|
requestedById: createdById,
|
||||||
|
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
result = created;
|
result = created;
|
||||||
@@ -1808,6 +1832,48 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: SuperportFormField(
|
||||||
|
label: '트랜잭션번호',
|
||||||
|
required: true,
|
||||||
|
errorText: transactionNumberError,
|
||||||
|
child: ShadInput(
|
||||||
|
controller: transactionNumberController,
|
||||||
|
readOnly: initial != null,
|
||||||
|
enabled: initial == null,
|
||||||
|
placeholder: const Text('예: OUT-2024-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (transactionNumberError != null) {
|
||||||
|
setState(() {
|
||||||
|
transactionNumberError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: SuperportFormField(
|
||||||
|
label: '결재번호',
|
||||||
|
required: true,
|
||||||
|
errorText: approvalNumberError,
|
||||||
|
child: ShadInput(
|
||||||
|
controller: approvalNumberController,
|
||||||
|
readOnly: initial != null,
|
||||||
|
enabled: initial == null,
|
||||||
|
placeholder: const Text('예: APP-2024-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (approvalNumberError != null) {
|
||||||
|
setState(() {
|
||||||
|
approvalNumberError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 240,
|
width: 240,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
@@ -1846,6 +1912,16 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 500,
|
||||||
|
child: SuperportFormField(
|
||||||
|
label: '결재 메모',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: approvalNoteController,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 360,
|
width: 360,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
@@ -2012,6 +2088,9 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
writerController.dispose();
|
writerController.dispose();
|
||||||
remarkController.dispose();
|
remarkController.dispose();
|
||||||
transactionTypeController.dispose();
|
transactionTypeController.dispose();
|
||||||
|
transactionNumberController.dispose();
|
||||||
|
approvalNumberController.dispose();
|
||||||
|
approvalNoteController.dispose();
|
||||||
processedAt.dispose();
|
processedAt.dispose();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -2460,6 +2539,10 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
required TextEditingController writerController,
|
required TextEditingController writerController,
|
||||||
required InventoryEmployeeSuggestion? writerSelection,
|
required InventoryEmployeeSuggestion? writerSelection,
|
||||||
required bool requireWriterSelection,
|
required bool requireWriterSelection,
|
||||||
|
required TextEditingController transactionNumberController,
|
||||||
|
required bool transactionNumberRequired,
|
||||||
|
required TextEditingController approvalNumberController,
|
||||||
|
required bool approvalNumberRequired,
|
||||||
required InventoryWarehouseOption? warehouseSelection,
|
required InventoryWarehouseOption? warehouseSelection,
|
||||||
required String statusValue,
|
required String statusValue,
|
||||||
required List<InventoryCustomerOption> selectedCustomers,
|
required List<InventoryCustomerOption> selectedCustomers,
|
||||||
@@ -2468,6 +2551,8 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
}) {
|
}) {
|
||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? transactionNumberError;
|
||||||
|
String? approvalNumberError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
@@ -2484,6 +2569,18 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final transactionNumber = transactionNumberController.text.trim();
|
||||||
|
if (transactionNumberRequired && transactionNumber.isEmpty) {
|
||||||
|
transactionNumberError = '거래번호를 입력하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final approvalNumber = approvalNumberController.text.trim();
|
||||||
|
if (approvalNumberRequired && approvalNumber.isEmpty) {
|
||||||
|
approvalNumberError = '결재번호를 입력하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (warehouseSelection == null) {
|
if (warehouseSelection == null) {
|
||||||
warehouseError = '창고를 선택하세요.';
|
warehouseError = '창고를 선택하세요.';
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -2547,6 +2644,8 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
return _OutboundFormValidation(
|
return _OutboundFormValidation(
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
|
transactionNumberError: transactionNumberError,
|
||||||
|
approvalNumberError: approvalNumberError,
|
||||||
customerError: customerError,
|
customerError: customerError,
|
||||||
warehouseError: warehouseError,
|
warehouseError: warehouseError,
|
||||||
statusError: statusError,
|
statusError: statusError,
|
||||||
@@ -2558,6 +2657,8 @@ class _OutboundFormValidation {
|
|||||||
const _OutboundFormValidation({
|
const _OutboundFormValidation({
|
||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
|
this.transactionNumberError,
|
||||||
|
this.approvalNumberError,
|
||||||
this.customerError,
|
this.customerError,
|
||||||
this.warehouseError,
|
this.warehouseError,
|
||||||
this.statusError,
|
this.statusError,
|
||||||
@@ -2566,6 +2667,8 @@ class _OutboundFormValidation {
|
|||||||
|
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
|
final String? transactionNumberError;
|
||||||
|
final String? approvalNumberError;
|
||||||
final String? customerError;
|
final String? customerError;
|
||||||
final String? warehouseError;
|
final String? warehouseError;
|
||||||
final String? statusError;
|
final String? statusError;
|
||||||
|
|||||||
@@ -1411,6 +1411,13 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
final transactionTypeController = TextEditingController(
|
final transactionTypeController = TextEditingController(
|
||||||
text: _transactionTypeForRental(rentalTypeValue.value),
|
text: _transactionTypeForRental(rentalTypeValue.value),
|
||||||
);
|
);
|
||||||
|
final transactionNumberController = TextEditingController(
|
||||||
|
text: initial?.transactionNumber ?? '',
|
||||||
|
);
|
||||||
|
final approvalNumberController = TextEditingController(
|
||||||
|
text: initial?.raw?.approval?.approvalNo ?? '',
|
||||||
|
);
|
||||||
|
final approvalNoteController = TextEditingController();
|
||||||
|
|
||||||
final drafts =
|
final drafts =
|
||||||
initial?.items
|
initial?.items
|
||||||
@@ -1421,6 +1428,8 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
|
|
||||||
RentalRecord? result;
|
RentalRecord? result;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? transactionNumberError;
|
||||||
|
String? approvalNumberError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
@@ -1452,6 +1461,10 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
writerController: writerController,
|
writerController: writerController,
|
||||||
writerSelection: writerSelection,
|
writerSelection: writerSelection,
|
||||||
requireWriterSelection: initial == null,
|
requireWriterSelection: initial == null,
|
||||||
|
transactionNumberController: transactionNumberController,
|
||||||
|
transactionNumberRequired: initial == null,
|
||||||
|
approvalNumberController: approvalNumberController,
|
||||||
|
approvalNumberRequired: initial == null,
|
||||||
warehouseSelection: warehouseSelection,
|
warehouseSelection: warehouseSelection,
|
||||||
statusValue: statusValue.value,
|
statusValue: statusValue.value,
|
||||||
selectedCustomers: customerSelection
|
selectedCustomers: customerSelection
|
||||||
@@ -1462,6 +1475,8 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
writerError = validation.writerError;
|
writerError = validation.writerError;
|
||||||
|
transactionNumberError = validation.transactionNumberError;
|
||||||
|
approvalNumberError = validation.approvalNumberError;
|
||||||
customerError = validation.customerError;
|
customerError = validation.customerError;
|
||||||
warehouseError = validation.warehouseError;
|
warehouseError = validation.warehouseError;
|
||||||
statusError = validation.statusError;
|
statusError = validation.statusError;
|
||||||
@@ -1507,6 +1522,9 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
|
|
||||||
final remarkText = remarkController.text.trim();
|
final remarkText = remarkController.text.trim();
|
||||||
final remarkValue = remarkText.isEmpty ? null : remarkText;
|
final remarkValue = remarkText.isEmpty ? null : remarkText;
|
||||||
|
final transactionNoValue = transactionNumberController.text.trim();
|
||||||
|
final approvalNoValue = approvalNumberController.text.trim();
|
||||||
|
final approvalNoteValue = approvalNoteController.text.trim();
|
||||||
final transactionId = initial?.id;
|
final transactionId = initial?.id;
|
||||||
final initialRecord = initial;
|
final initialRecord = initial;
|
||||||
|
|
||||||
@@ -1633,6 +1651,7 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
final transactionTypeId = selectedLookup.id;
|
final transactionTypeId = selectedLookup.id;
|
||||||
final created = await controller.createTransaction(
|
final created = await controller.createTransaction(
|
||||||
StockTransactionCreateInput(
|
StockTransactionCreateInput(
|
||||||
|
transactionNo: transactionNoValue,
|
||||||
transactionTypeId: transactionTypeId,
|
transactionTypeId: transactionTypeId,
|
||||||
transactionStatusId: statusItem.id,
|
transactionStatusId: statusItem.id,
|
||||||
warehouseId: warehouseId,
|
warehouseId: warehouseId,
|
||||||
@@ -1642,6 +1661,11 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
expectedReturnDate: returnDue.value,
|
expectedReturnDate: returnDue.value,
|
||||||
lines: createLines,
|
lines: createLines,
|
||||||
customers: createCustomers,
|
customers: createCustomers,
|
||||||
|
approval: StockTransactionApprovalInput(
|
||||||
|
approvalNo: approvalNoValue,
|
||||||
|
requestedById: createdById,
|
||||||
|
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
result = created;
|
result = created;
|
||||||
@@ -1815,6 +1839,74 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_FormFieldLabel(
|
||||||
|
label: '트랜잭션번호',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: transactionNumberController,
|
||||||
|
readOnly: initial != null,
|
||||||
|
enabled: initial == null,
|
||||||
|
placeholder: const Text('예: RENT-2024-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (transactionNumberError != null) {
|
||||||
|
setState(() {
|
||||||
|
transactionNumberError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (transactionNumberError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
transactionNumberError!,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.destructive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_FormFieldLabel(
|
||||||
|
label: '결재번호',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: approvalNumberController,
|
||||||
|
readOnly: initial != null,
|
||||||
|
enabled: initial == null,
|
||||||
|
placeholder: const Text('예: APP-2024-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (approvalNumberError != null) {
|
||||||
|
setState(() {
|
||||||
|
approvalNumberError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (approvalNumberError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
approvalNumberError!,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.destructive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 360,
|
width: 360,
|
||||||
child: _FormFieldLabel(
|
child: _FormFieldLabel(
|
||||||
@@ -1939,6 +2031,16 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 500,
|
||||||
|
child: _FormFieldLabel(
|
||||||
|
label: '결재 메모',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: approvalNoteController,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 500,
|
width: 500,
|
||||||
child: _FormFieldLabel(
|
child: _FormFieldLabel(
|
||||||
@@ -2068,6 +2170,9 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
writerController.dispose();
|
writerController.dispose();
|
||||||
remarkController.dispose();
|
remarkController.dispose();
|
||||||
transactionTypeController.dispose();
|
transactionTypeController.dispose();
|
||||||
|
transactionNumberController.dispose();
|
||||||
|
approvalNumberController.dispose();
|
||||||
|
approvalNoteController.dispose();
|
||||||
processedAt.dispose();
|
processedAt.dispose();
|
||||||
returnDue.dispose();
|
returnDue.dispose();
|
||||||
|
|
||||||
@@ -2543,6 +2648,10 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
required TextEditingController writerController,
|
required TextEditingController writerController,
|
||||||
required InventoryEmployeeSuggestion? writerSelection,
|
required InventoryEmployeeSuggestion? writerSelection,
|
||||||
required bool requireWriterSelection,
|
required bool requireWriterSelection,
|
||||||
|
required TextEditingController transactionNumberController,
|
||||||
|
required bool transactionNumberRequired,
|
||||||
|
required TextEditingController approvalNumberController,
|
||||||
|
required bool approvalNumberRequired,
|
||||||
required InventoryWarehouseOption? warehouseSelection,
|
required InventoryWarehouseOption? warehouseSelection,
|
||||||
required String statusValue,
|
required String statusValue,
|
||||||
required List<InventoryCustomerOption> selectedCustomers,
|
required List<InventoryCustomerOption> selectedCustomers,
|
||||||
@@ -2551,6 +2660,8 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
}) {
|
}) {
|
||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? transactionNumberError;
|
||||||
|
String? approvalNumberError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
@@ -2567,6 +2678,18 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final transactionNumber = transactionNumberController.text.trim();
|
||||||
|
if (transactionNumberRequired && transactionNumber.isEmpty) {
|
||||||
|
transactionNumberError = '거래번호를 입력하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final approvalNumber = approvalNumberController.text.trim();
|
||||||
|
if (approvalNumberRequired && approvalNumber.isEmpty) {
|
||||||
|
approvalNumberError = '결재번호를 입력하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (warehouseSelection == null) {
|
if (warehouseSelection == null) {
|
||||||
warehouseError = '창고를 선택하세요.';
|
warehouseError = '창고를 선택하세요.';
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -2630,6 +2753,8 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
return _RentalFormValidation(
|
return _RentalFormValidation(
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
|
transactionNumberError: transactionNumberError,
|
||||||
|
approvalNumberError: approvalNumberError,
|
||||||
customerError: customerError,
|
customerError: customerError,
|
||||||
warehouseError: warehouseError,
|
warehouseError: warehouseError,
|
||||||
statusError: statusError,
|
statusError: statusError,
|
||||||
@@ -2641,6 +2766,8 @@ class _RentalFormValidation {
|
|||||||
const _RentalFormValidation({
|
const _RentalFormValidation({
|
||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
|
this.transactionNumberError,
|
||||||
|
this.approvalNumberError,
|
||||||
this.customerError,
|
this.customerError,
|
||||||
this.warehouseError,
|
this.warehouseError,
|
||||||
this.statusError,
|
this.statusError,
|
||||||
@@ -2649,6 +2776,8 @@ class _RentalFormValidation {
|
|||||||
|
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
|
final String? transactionNumberError;
|
||||||
|
final String? approvalNumberError;
|
||||||
final String? customerError;
|
final String? customerError;
|
||||||
final String? warehouseError;
|
final String? warehouseError;
|
||||||
final String? statusError;
|
final String? statusError;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class InventoryEmployeeAutocompleteField extends StatefulWidget {
|
|||||||
required this.onSuggestionSelected,
|
required this.onSuggestionSelected,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.placeholder = '작성자 이름 또는 사번 검색',
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
@@ -44,6 +45,7 @@ class InventoryEmployeeAutocompleteField extends StatefulWidget {
|
|||||||
final ValueChanged<InventoryEmployeeSuggestion?> onSuggestionSelected;
|
final ValueChanged<InventoryEmployeeSuggestion?> onSuggestionSelected;
|
||||||
final VoidCallback? onChanged;
|
final VoidCallback? onChanged;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final String placeholder;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InventoryEmployeeAutocompleteField> createState() =>
|
State<InventoryEmployeeAutocompleteField> createState() =>
|
||||||
@@ -193,7 +195,7 @@ class _InventoryEmployeeAutocompleteFieldState
|
|||||||
controller: textController,
|
controller: textController,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
placeholder: const Text('작성자 이름 또는 사번 검색'),
|
placeholder: Text(widget.placeholder),
|
||||||
onChanged: (_) => widget.onChanged?.call(),
|
onChanged: (_) => widget.onChanged?.call(),
|
||||||
onSubmitted: (_) => onFieldSubmitted(),
|
onSubmitted: (_) => onFieldSubmitted(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
|||||||
Future<StockTransaction> submit(int id) async {
|
Future<StockTransaction> submit(int id) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
'$_basePath/$id/submit',
|
'$_basePath/$id/submit',
|
||||||
|
data: {'id': id},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
return _parseSingle(response.data);
|
return _parseSingle(response.data);
|
||||||
@@ -93,6 +94,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
|||||||
Future<StockTransaction> complete(int id) async {
|
Future<StockTransaction> complete(int id) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
'$_basePath/$id/complete',
|
'$_basePath/$id/complete',
|
||||||
|
data: {'id': id},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
return _parseSingle(response.data);
|
return _parseSingle(response.data);
|
||||||
@@ -102,6 +104,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
|||||||
Future<StockTransaction> approve(int id) async {
|
Future<StockTransaction> approve(int id) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
'$_basePath/$id/approve',
|
'$_basePath/$id/approve',
|
||||||
|
data: {'id': id},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
return _parseSingle(response.data);
|
return _parseSingle(response.data);
|
||||||
@@ -111,6 +114,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
|||||||
Future<StockTransaction> reject(int id) async {
|
Future<StockTransaction> reject(int id) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
'$_basePath/$id/reject',
|
'$_basePath/$id/reject',
|
||||||
|
data: {'id': id},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
return _parseSingle(response.data);
|
return _parseSingle(response.data);
|
||||||
@@ -120,6 +124,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
|||||||
Future<StockTransaction> cancel(int id) async {
|
Future<StockTransaction> cancel(int id) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
'$_basePath/$id/cancel',
|
'$_basePath/$id/cancel',
|
||||||
|
data: {'id': id},
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
return _parseSingle(response.data);
|
return _parseSingle(response.data);
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:superport_v2/core/network/api_client.dart';
|
import 'package:superport_v2/core/network/api_client.dart';
|
||||||
import 'package:superport_v2/core/network/api_routes.dart';
|
import 'package:superport_v2/core/network/api_routes.dart';
|
||||||
|
|
||||||
import '../../domain/entities/stock_transaction.dart';
|
|
||||||
import '../../domain/entities/stock_transaction_input.dart';
|
import '../../domain/entities/stock_transaction_input.dart';
|
||||||
import '../../domain/repositories/stock_transaction_repository.dart';
|
import '../../domain/repositories/stock_transaction_repository.dart';
|
||||||
import '../dtos/stock_transaction_dto.dart';
|
|
||||||
|
|
||||||
/// 재고 트랜잭션 고객 연결 API를 호출하는 원격 저장소 구현체.
|
/// 재고 트랜잭션 고객 연결 API를 호출하는 원격 저장소 구현체.
|
||||||
class TransactionCustomerRepositoryRemote
|
class TransactionCustomerRepositoryRemote
|
||||||
@@ -19,11 +16,11 @@ class TransactionCustomerRepositoryRemote
|
|||||||
static const _customerPath = '${ApiRoutes.apiV1}/transaction-customers';
|
static const _customerPath = '${ApiRoutes.apiV1}/transaction-customers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionCustomer>> addCustomers(
|
Future<void> addCustomers(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionCustomerCreateInput> customers,
|
List<TransactionCustomerCreateInput> customers,
|
||||||
) async {
|
) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
await _api.post<void>(
|
||||||
'$_basePath/$transactionId/customers',
|
'$_basePath/$transactionId/customers',
|
||||||
data: {
|
data: {
|
||||||
'id': transactionId,
|
'id': transactionId,
|
||||||
@@ -31,17 +28,15 @@ class TransactionCustomerRepositoryRemote
|
|||||||
.map((customer) => customer.toJson())
|
.map((customer) => customer.toJson())
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
},
|
},
|
||||||
options: Options(responseType: ResponseType.json),
|
|
||||||
);
|
);
|
||||||
return _parseCustomers(response.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionCustomer>> updateCustomers(
|
Future<void> updateCustomers(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionCustomerUpdateInput> customers,
|
List<TransactionCustomerUpdateInput> customers,
|
||||||
) async {
|
) async {
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
await _api.patch<void>(
|
||||||
'$_basePath/$transactionId/customers',
|
'$_basePath/$transactionId/customers',
|
||||||
data: {
|
data: {
|
||||||
'id': transactionId,
|
'id': transactionId,
|
||||||
@@ -49,38 +44,11 @@ class TransactionCustomerRepositoryRemote
|
|||||||
.map((customer) => customer.toJson())
|
.map((customer) => customer.toJson())
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
},
|
},
|
||||||
options: Options(responseType: ResponseType.json),
|
|
||||||
);
|
);
|
||||||
return _parseCustomers(response.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteCustomer(int customerLinkId) async {
|
Future<void> deleteCustomer(int customerLinkId) async {
|
||||||
await _api.delete<void>('$_customerPath/$customerLinkId');
|
await _api.delete<void>('$_customerPath/$customerLinkId');
|
||||||
}
|
}
|
||||||
|
|
||||||
List<StockTransactionCustomer> _parseCustomers(Map<String, dynamic>? body) {
|
|
||||||
final data = _extractData(body);
|
|
||||||
if (data['customers'] is List) {
|
|
||||||
final dto = StockTransactionDto.fromJson(data);
|
|
||||||
return dto.customers;
|
|
||||||
}
|
|
||||||
if (data.containsKey('id')) {
|
|
||||||
final dto = StockTransactionDto.fromJson({
|
|
||||||
'customers': [data],
|
|
||||||
});
|
|
||||||
return dto.customers;
|
|
||||||
}
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
|
|
||||||
if (body == null) {
|
|
||||||
return <String, dynamic>{};
|
|
||||||
}
|
|
||||||
if (body['data'] is Map<String, dynamic>) {
|
|
||||||
return body['data'] as Map<String, dynamic>;
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:superport_v2/core/network/api_client.dart';
|
import 'package:superport_v2/core/network/api_client.dart';
|
||||||
import 'package:superport_v2/core/network/api_routes.dart';
|
import 'package:superport_v2/core/network/api_routes.dart';
|
||||||
|
|
||||||
import '../../domain/entities/stock_transaction.dart';
|
|
||||||
import '../../domain/entities/stock_transaction_input.dart';
|
import '../../domain/entities/stock_transaction_input.dart';
|
||||||
import '../../domain/repositories/stock_transaction_repository.dart';
|
import '../../domain/repositories/stock_transaction_repository.dart';
|
||||||
import '../dtos/stock_transaction_dto.dart';
|
|
||||||
|
|
||||||
/// 재고 트랜잭션 라인 API를 호출하는 원격 저장소 구현체.
|
/// 재고 트랜잭션 라인 API를 호출하는 원격 저장소 구현체.
|
||||||
class TransactionLineRepositoryRemote implements TransactionLineRepository {
|
class TransactionLineRepositoryRemote implements TransactionLineRepository {
|
||||||
@@ -18,35 +15,31 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository {
|
|||||||
static const _linePath = '${ApiRoutes.apiV1}/transaction-lines';
|
static const _linePath = '${ApiRoutes.apiV1}/transaction-lines';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionLine>> addLines(
|
Future<void> addLines(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionLineCreateInput> lines,
|
List<TransactionLineCreateInput> lines,
|
||||||
) async {
|
) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
await _api.post<void>(
|
||||||
'$_basePath/$transactionId/lines',
|
'$_basePath/$transactionId/lines',
|
||||||
data: {
|
data: {
|
||||||
'id': transactionId,
|
'id': transactionId,
|
||||||
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
||||||
},
|
},
|
||||||
options: Options(responseType: ResponseType.json),
|
|
||||||
);
|
);
|
||||||
return _parseLines(response.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionLine>> updateLines(
|
Future<void> updateLines(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionLineUpdateInput> lines,
|
List<TransactionLineUpdateInput> lines,
|
||||||
) async {
|
) async {
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
await _api.patch<void>(
|
||||||
'$_basePath/$transactionId/lines',
|
'$_basePath/$transactionId/lines',
|
||||||
data: {
|
data: {
|
||||||
'id': transactionId,
|
'id': transactionId,
|
||||||
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
||||||
},
|
},
|
||||||
options: Options(responseType: ResponseType.json),
|
|
||||||
);
|
);
|
||||||
return _parseLines(response.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,40 +48,10 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<StockTransactionLine> restoreLine(int lineId) async {
|
Future<void> restoreLine(int lineId) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
await _api.post<void>(
|
||||||
'$_linePath/$lineId/restore',
|
'$_linePath/$lineId/restore',
|
||||||
options: Options(responseType: ResponseType.json),
|
|
||||||
);
|
);
|
||||||
final lines = _parseLines(response.data);
|
|
||||||
if (lines.isEmpty) {
|
|
||||||
throw StateError('복구된 라인 정보를 찾을 수 없습니다.');
|
|
||||||
}
|
|
||||||
return lines.first;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<StockTransactionLine> _parseLines(Map<String, dynamic>? body) {
|
|
||||||
final data = _extractData(body);
|
|
||||||
if (data['lines'] is List) {
|
|
||||||
final dto = StockTransactionDto.fromJson(data);
|
|
||||||
return dto.lines;
|
|
||||||
}
|
|
||||||
if (data.containsKey('id')) {
|
|
||||||
final dto = StockTransactionDto.fromJson({
|
|
||||||
'lines': [data],
|
|
||||||
});
|
|
||||||
return dto.lines;
|
|
||||||
}
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
|
|
||||||
if (body == null) {
|
|
||||||
return <String, dynamic>{};
|
|
||||||
}
|
|
||||||
if (body['data'] is Map<String, dynamic>) {
|
|
||||||
return body['data'] as Map<String, dynamic>;
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class StockTransactionCreateInput {
|
|||||||
this.expectedReturnDate,
|
this.expectedReturnDate,
|
||||||
this.lines = const [],
|
this.lines = const [],
|
||||||
this.customers = const [],
|
this.customers = const [],
|
||||||
|
this.approval,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? transactionNo;
|
final String? transactionNo;
|
||||||
@@ -23,6 +24,7 @@ class StockTransactionCreateInput {
|
|||||||
final DateTime? expectedReturnDate;
|
final DateTime? expectedReturnDate;
|
||||||
final List<TransactionLineCreateInput> lines;
|
final List<TransactionLineCreateInput> lines;
|
||||||
final List<TransactionCustomerCreateInput> customers;
|
final List<TransactionCustomerCreateInput> customers;
|
||||||
|
final StockTransactionApprovalInput? approval;
|
||||||
|
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
return {
|
return {
|
||||||
@@ -42,6 +44,7 @@ class StockTransactionCreateInput {
|
|||||||
'customers': customers
|
'customers': customers
|
||||||
.map((customer) => customer.toJson())
|
.map((customer) => customer.toJson())
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
|
if (approval != null) 'approval': approval!.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,3 +203,27 @@ class StockTransactionListFilter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 재고 트랜잭션 생성 시 결재(Approval) 정보를 담는 입력 모델.
|
||||||
|
class StockTransactionApprovalInput {
|
||||||
|
StockTransactionApprovalInput({
|
||||||
|
required this.approvalNo,
|
||||||
|
required this.requestedById,
|
||||||
|
this.approvalStatusId,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String approvalNo;
|
||||||
|
final int requestedById;
|
||||||
|
final int? approvalStatusId;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'approval_no': approvalNo,
|
||||||
|
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
|
||||||
|
'requested_by_id': requestedById,
|
||||||
|
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ abstract class StockTransactionRepository {
|
|||||||
/// 재고 트랜잭션 라인 저장소 인터페이스.
|
/// 재고 트랜잭션 라인 저장소 인터페이스.
|
||||||
abstract class TransactionLineRepository {
|
abstract class TransactionLineRepository {
|
||||||
/// 라인을 추가한다.
|
/// 라인을 추가한다.
|
||||||
Future<List<StockTransactionLine>> addLines(
|
Future<void> addLines(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionLineCreateInput> lines,
|
List<TransactionLineCreateInput> lines,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 라인 정보를 일괄 수정한다.
|
/// 라인 정보를 일괄 수정한다.
|
||||||
Future<List<StockTransactionLine>> updateLines(
|
Future<void> updateLines(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionLineUpdateInput> lines,
|
List<TransactionLineUpdateInput> lines,
|
||||||
);
|
);
|
||||||
@@ -62,19 +62,19 @@ abstract class TransactionLineRepository {
|
|||||||
Future<void> deleteLine(int lineId);
|
Future<void> deleteLine(int lineId);
|
||||||
|
|
||||||
/// 삭제된 라인을 복구한다.
|
/// 삭제된 라인을 복구한다.
|
||||||
Future<StockTransactionLine> restoreLine(int lineId);
|
Future<void> restoreLine(int lineId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 재고 트랜잭션 고객 연결 저장소 인터페이스.
|
/// 재고 트랜잭션 고객 연결 저장소 인터페이스.
|
||||||
abstract class TransactionCustomerRepository {
|
abstract class TransactionCustomerRepository {
|
||||||
/// 고객 연결을 추가한다.
|
/// 고객 연결을 추가한다.
|
||||||
Future<List<StockTransactionCustomer>> addCustomers(
|
Future<void> addCustomers(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionCustomerCreateInput> customers,
|
List<TransactionCustomerCreateInput> customers,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 고객 연결 정보를 수정한다.
|
/// 고객 연결 정보를 수정한다.
|
||||||
Future<List<StockTransactionCustomer>> updateCustomers(
|
Future<void> updateCustomers(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionCustomerUpdateInput> customers,
|
List<TransactionCustomerUpdateInput> customers,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/app_sections.dart';
|
import '../../../../core/constants/app_sections.dart';
|
||||||
|
import '../../../../core/network/api_error.dart';
|
||||||
import '../../../../core/network/failure.dart';
|
import '../../../../core/network/failure.dart';
|
||||||
import '../../../../core/permissions/permission_manager.dart';
|
import '../../../../core/permissions/permission_manager.dart';
|
||||||
import '../../../../core/permissions/permission_resources.dart';
|
import '../../../../core/permissions/permission_resources.dart';
|
||||||
@@ -73,9 +74,10 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final failure = Failure.from(error);
|
final failure = Failure.from(error);
|
||||||
final description = failure.describe();
|
final description = failure.describe();
|
||||||
final message = description.isEmpty
|
final hasApiDetails = failure.raw is ApiException;
|
||||||
? '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'
|
final message = hasApiDetails && description.isNotEmpty
|
||||||
: description;
|
? description
|
||||||
|
: '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
|
||||||
setState(() {
|
setState(() {
|
||||||
errorMessage = message;
|
errorMessage = message;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class GroupPermissionMenuDto {
|
|||||||
id: json['id'] as int? ?? json['menu_id'] as int,
|
id: json['id'] as int? ?? json['menu_id'] as int,
|
||||||
menuCode: code,
|
menuCode: code,
|
||||||
menuName: fallbackName,
|
menuName: fallbackName,
|
||||||
path: json['path'] as String?,
|
path: (json['path'] ?? json['route_path']) as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,12 @@ class ReportingRepositoryRemote implements ReportingRepository {
|
|||||||
'from': request.from.toIso8601String(),
|
'from': request.from.toIso8601String(),
|
||||||
'to': request.to.toIso8601String(),
|
'to': request.to.toIso8601String(),
|
||||||
'format': request.format.apiValue,
|
'format': request.format.apiValue,
|
||||||
if (request.transactionTypeId != null)
|
if (request.transactionStatusId != null)
|
||||||
'type_id': request.transactionTypeId,
|
'transaction_status_id': request.transactionStatusId,
|
||||||
if (request.statusId != null) 'status_id': request.statusId,
|
if (request.approvalStatusId != null)
|
||||||
if (request.warehouseId != null) 'warehouse_id': request.warehouseId,
|
'approval_status_id': request.approvalStatusId,
|
||||||
|
if (request.requestedById != null)
|
||||||
|
'requested_by_id': request.requestedById,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ class ReportExportRequest {
|
|||||||
required this.from,
|
required this.from,
|
||||||
required this.to,
|
required this.to,
|
||||||
required this.format,
|
required this.format,
|
||||||
this.transactionTypeId,
|
this.transactionStatusId,
|
||||||
this.statusId,
|
this.approvalStatusId,
|
||||||
this.warehouseId,
|
this.requestedById,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 조회 시작 일자.
|
/// 조회 시작 일자.
|
||||||
@@ -20,12 +20,12 @@ class ReportExportRequest {
|
|||||||
/// 내보내기 파일 형식.
|
/// 내보내기 파일 형식.
|
||||||
final ReportExportFormat format;
|
final ReportExportFormat format;
|
||||||
|
|
||||||
/// 재고 트랜잭션 유형 식별자.
|
/// 트랜잭션 상태 식별자.
|
||||||
final int? transactionTypeId;
|
final int? transactionStatusId;
|
||||||
|
|
||||||
/// 결재 상태 식별자.
|
/// 결재 상태 식별자.
|
||||||
final int? statusId;
|
final int? approvalStatusId;
|
||||||
|
|
||||||
/// 창고 식별자.
|
/// 상신자(요청자) 식별자.
|
||||||
final int? warehouseId;
|
final int? requestedById;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
|
import 'package:superport_v2/core/network/failure.dart';
|
||||||
|
import 'package:superport_v2/core/services/file_saver.dart';
|
||||||
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
|
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
|
||||||
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart';
|
||||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||||
import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart';
|
import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart';
|
||||||
@@ -20,7 +23,6 @@ import 'package:superport_v2/widgets/components/empty_state.dart';
|
|||||||
import 'package:superport_v2/widgets/components/feedback.dart';
|
import 'package:superport_v2/widgets/components/feedback.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
|
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
|
||||||
import 'package:superport_v2/core/network/failure.dart';
|
|
||||||
|
|
||||||
/// 보고서 다운로드 화면 루트 위젯.
|
/// 보고서 다운로드 화면 루트 위젯.
|
||||||
class ReportingPage extends StatefulWidget {
|
class ReportingPage extends StatefulWidget {
|
||||||
@@ -36,7 +38,6 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
ReportingRepository? _reportingRepository;
|
ReportingRepository? _reportingRepository;
|
||||||
InventoryLookupRepository? _lookupRepository;
|
InventoryLookupRepository? _lookupRepository;
|
||||||
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
|
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
|
||||||
final Map<ReportTypeFilter, LookupItem> _transactionTypeLookup = {};
|
|
||||||
final Map<ReportStatusFilter, LookupItem> _transactionStatusLookup = {};
|
final Map<ReportStatusFilter, LookupItem> _transactionStatusLookup = {};
|
||||||
final Map<ReportStatusFilter, LookupItem> _approvalStatusLookup = {};
|
final Map<ReportStatusFilter, LookupItem> _approvalStatusLookup = {};
|
||||||
bool _isLoadingLookups = false;
|
bool _isLoadingLookups = false;
|
||||||
@@ -45,12 +46,9 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
String? _exportError;
|
String? _exportError;
|
||||||
ReportDownloadResult? _lastResult;
|
ReportDownloadResult? _lastResult;
|
||||||
ReportExportFormat? _lastFormat;
|
ReportExportFormat? _lastFormat;
|
||||||
|
final TextEditingController _requesterController = TextEditingController();
|
||||||
static const Map<ReportTypeFilter, List<String>> _transactionTypeKeywords = {
|
InventoryEmployeeSuggestion? _appliedRequester;
|
||||||
ReportTypeFilter.inbound: ['입고', 'inbound'],
|
InventoryEmployeeSuggestion? _pendingRequester;
|
||||||
ReportTypeFilter.outbound: ['출고', 'outbound'],
|
|
||||||
ReportTypeFilter.rental: ['대여', 'rent', 'rental'],
|
|
||||||
};
|
|
||||||
|
|
||||||
static const Map<ReportStatusFilter, List<String>>
|
static const Map<ReportStatusFilter, List<String>>
|
||||||
_transactionStatusKeywords = {
|
_transactionStatusKeywords = {
|
||||||
@@ -105,6 +103,12 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
_loadWarehouses();
|
_loadWarehouses();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_requesterController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
|
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
|
||||||
Future<void> _loadWarehouses() async {
|
Future<void> _loadWarehouses() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -173,16 +177,12 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
_lookupError = null;
|
_lookupError = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final transactionTypes = await repository.fetchTransactionTypes();
|
|
||||||
final transactionStatuses = await repository.fetchTransactionStatuses();
|
final transactionStatuses = await repository.fetchTransactionStatuses();
|
||||||
final approvalStatuses = await repository.fetchApprovalStatuses();
|
final approvalStatuses = await repository.fetchApprovalStatuses();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_transactionTypeLookup
|
|
||||||
..clear()
|
|
||||||
..addAll(_mapTransactionTypes(transactionTypes));
|
|
||||||
_transactionStatusLookup
|
_transactionStatusLookup
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(
|
..addAll(
|
||||||
@@ -228,6 +228,9 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
_pendingStatus = ReportStatusFilter.all;
|
_pendingStatus = ReportStatusFilter.all;
|
||||||
_appliedWarehouse = WarehouseFilterOption.all;
|
_appliedWarehouse = WarehouseFilterOption.all;
|
||||||
_pendingWarehouse = WarehouseFilterOption.all;
|
_pendingWarehouse = WarehouseFilterOption.all;
|
||||||
|
_appliedRequester = null;
|
||||||
|
_pendingRequester = null;
|
||||||
|
_requesterController.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +241,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
_appliedType = _pendingType;
|
_appliedType = _pendingType;
|
||||||
_appliedStatus = _pendingStatus;
|
_appliedStatus = _pendingStatus;
|
||||||
_appliedWarehouse = _pendingWarehouse;
|
_appliedWarehouse = _pendingWarehouse;
|
||||||
|
_appliedRequester = _pendingRequester;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +253,8 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
return _appliedDateRange != null ||
|
return _appliedDateRange != null ||
|
||||||
_appliedType != ReportTypeFilter.all ||
|
_appliedType != ReportTypeFilter.all ||
|
||||||
_appliedStatus != ReportStatusFilter.all ||
|
_appliedStatus != ReportStatusFilter.all ||
|
||||||
_appliedWarehouse != WarehouseFilterOption.all;
|
_appliedWarehouse != WarehouseFilterOption.all ||
|
||||||
|
_appliedRequester != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasAppliedFilters => _hasCustomFilters;
|
bool get _hasAppliedFilters => _hasCustomFilters;
|
||||||
@@ -258,7 +263,8 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
!_isSameRange(_pendingDateRange, _appliedDateRange) ||
|
!_isSameRange(_pendingDateRange, _appliedDateRange) ||
|
||||||
_pendingType != _appliedType ||
|
_pendingType != _appliedType ||
|
||||||
_pendingStatus != _appliedStatus ||
|
_pendingStatus != _appliedStatus ||
|
||||||
_pendingWarehouse != _appliedWarehouse;
|
_pendingWarehouse != _appliedWarehouse ||
|
||||||
|
!_isSameRequester(_pendingRequester, _appliedRequester);
|
||||||
|
|
||||||
bool _isSameRange(DateTimeRange? a, DateTimeRange? b) {
|
bool _isSameRange(DateTimeRange? a, DateTimeRange? b) {
|
||||||
if (identical(a, b)) {
|
if (identical(a, b)) {
|
||||||
@@ -270,6 +276,19 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
return a.start == b.start && a.end == b.end;
|
return a.start == b.start && a.end == b.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isSameRequester(
|
||||||
|
InventoryEmployeeSuggestion? a,
|
||||||
|
InventoryEmployeeSuggestion? b,
|
||||||
|
) {
|
||||||
|
if (identical(a, b)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a == null || b == null) {
|
||||||
|
return a == b;
|
||||||
|
}
|
||||||
|
return a.id == b.id;
|
||||||
|
}
|
||||||
|
|
||||||
WarehouseFilterOption _resolveWarehouseOption(
|
WarehouseFilterOption _resolveWarehouseOption(
|
||||||
WarehouseFilterOption target,
|
WarehouseFilterOption target,
|
||||||
List<WarehouseFilterOption> options,
|
List<WarehouseFilterOption> options,
|
||||||
@@ -282,19 +301,6 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
return options.first;
|
return options.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<ReportTypeFilter, LookupItem> _mapTransactionTypes(
|
|
||||||
List<LookupItem> items,
|
|
||||||
) {
|
|
||||||
final result = <ReportTypeFilter, LookupItem>{};
|
|
||||||
for (final entry in _transactionTypeKeywords.entries) {
|
|
||||||
final matched = _matchLookup(items, entry.value);
|
|
||||||
if (matched != null) {
|
|
||||||
result[entry.key] = matched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<ReportStatusFilter, LookupItem> _mapStatusByKeyword(
|
Map<ReportStatusFilter, LookupItem> _mapStatusByKeyword(
|
||||||
List<LookupItem> items,
|
List<LookupItem> items,
|
||||||
Map<ReportStatusFilter, List<String>> keywords,
|
Map<ReportStatusFilter, List<String>> keywords,
|
||||||
@@ -326,25 +332,20 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _resolveTransactionTypeId() {
|
int? _resolveTransactionStatusId() {
|
||||||
if (_appliedType == ReportTypeFilter.all ||
|
|
||||||
_appliedType == ReportTypeFilter.approval) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final lookup = _transactionTypeLookup[_appliedType];
|
|
||||||
return lookup?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
int? _resolveStatusId() {
|
|
||||||
if (_appliedStatus == ReportStatusFilter.all) {
|
if (_appliedStatus == ReportStatusFilter.all) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (_appliedType == ReportTypeFilter.approval) {
|
|
||||||
return _approvalStatusLookup[_appliedStatus]?.id;
|
|
||||||
}
|
|
||||||
return _transactionStatusLookup[_appliedStatus]?.id;
|
return _transactionStatusLookup[_appliedStatus]?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _resolveApprovalStatusId() {
|
||||||
|
if (_appliedStatus == ReportStatusFilter.all) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _approvalStatusLookup[_appliedStatus]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
String _dateRangeLabel(DateTimeRange? range) {
|
String _dateRangeLabel(DateTimeRange? range) {
|
||||||
if (range == null) {
|
if (range == null) {
|
||||||
return '기간 선택';
|
return '기간 선택';
|
||||||
@@ -354,6 +355,13 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
|
|
||||||
String _formatDate(DateTime value) => _dateFormat.format(value);
|
String _formatDate(DateTime value) => _dateFormat.format(value);
|
||||||
|
|
||||||
|
String _requesterLabel(InventoryEmployeeSuggestion? suggestion) {
|
||||||
|
if (suggestion == null) {
|
||||||
|
return '전체 상신자';
|
||||||
|
}
|
||||||
|
return '${suggestion.name} (${suggestion.employeeNo})';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleExport(ReportExportFormat format) async {
|
Future<void> _handleExport(ReportExportFormat format) async {
|
||||||
if (_isExporting) {
|
if (_isExporting) {
|
||||||
return;
|
return;
|
||||||
@@ -376,9 +384,9 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
from: range.start,
|
from: range.start,
|
||||||
to: range.end,
|
to: range.end,
|
||||||
format: format,
|
format: format,
|
||||||
transactionTypeId: _resolveTransactionTypeId(),
|
transactionStatusId: _resolveTransactionStatusId(),
|
||||||
statusId: _resolveStatusId(),
|
approvalStatusId: _resolveApprovalStatusId(),
|
||||||
warehouseId: _appliedWarehouse.id,
|
requestedById: _appliedRequester?.id,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final result = _appliedType == ReportTypeFilter.approval
|
final result = _appliedType == ReportTypeFilter.approval
|
||||||
@@ -394,7 +402,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
if (result.hasDownloadUrl) {
|
if (result.hasDownloadUrl) {
|
||||||
SuperportToast.success(context, '다운로드 링크가 준비되었습니다.');
|
SuperportToast.success(context, '다운로드 링크가 준비되었습니다.');
|
||||||
} else if (result.hasBytes) {
|
} else if (result.hasBytes) {
|
||||||
SuperportToast.success(context, '보고서 파일이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.');
|
await _saveBinaryResult(result, format);
|
||||||
} else {
|
} else {
|
||||||
SuperportToast.info(context, '다운로드 결과를 확인했지만 추가 처리 항목이 없습니다.');
|
SuperportToast.info(context, '다운로드 결과를 확인했지만 추가 처리 항목이 없습니다.');
|
||||||
}
|
}
|
||||||
@@ -419,6 +427,51 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveBinaryResult(
|
||||||
|
ReportDownloadResult result,
|
||||||
|
ReportExportFormat format,
|
||||||
|
) async {
|
||||||
|
final bytes = result.bytes;
|
||||||
|
if (bytes == null || bytes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final filename = result.filename ?? 'report.${format.apiValue}';
|
||||||
|
final mimeType = result.mimeType ?? _mimeTypeForFormat(format);
|
||||||
|
try {
|
||||||
|
await saveFileBytes(bytes: bytes, filename: filename, mimeType: mimeType);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SuperportToast.success(context, '보고서 파일 다운로드가 시작되었습니다.');
|
||||||
|
} on UnsupportedError {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SuperportToast.info(
|
||||||
|
context,
|
||||||
|
'현재 환경에서는 자동 저장을 지원하지 않습니다. 다운로드 링크 요청 기능을 이용하세요.',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SuperportToast.error(context, '파일을 저장하는 중 문제가 발생했습니다. 잠시 후 다시 시도하세요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyPdfUnavailable() {
|
||||||
|
SuperportToast.info(context, 'PDF 다운로드는 현재 지원되지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mimeTypeForFormat(ReportExportFormat format) {
|
||||||
|
switch (format) {
|
||||||
|
case ReportExportFormat.xlsx:
|
||||||
|
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
case ReportExportFormat.pdf:
|
||||||
|
return 'application/pdf';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _launchDownloadUrl(Uri url) async {
|
Future<void> _launchDownloadUrl(Uri url) async {
|
||||||
try {
|
try {
|
||||||
final opened = await launchUrl(url, mode: LaunchMode.externalApplication);
|
final opened = await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
@@ -476,10 +529,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
if (result.downloadUrl != null)
|
if (result.downloadUrl != null)
|
||||||
_SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()),
|
_SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()),
|
||||||
if (result.hasBytes && (result.downloadUrl == null))
|
if (result.hasBytes && (result.downloadUrl == null))
|
||||||
const _SummaryRow(
|
const _SummaryRow(label: '상태', value: '바이너리 응답을 받아 자동 다운로드를 실행했습니다.'),
|
||||||
label: '상태',
|
|
||||||
value: '바이너리 응답이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.',
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -530,9 +580,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
child: const Text('XLSX 다운로드'),
|
child: const Text('XLSX 다운로드'),
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
onPressed: _canExport
|
onPressed: _canExport ? _notifyPdfUnavailable : null,
|
||||||
? () => _handleExport(ReportExportFormat.pdf)
|
|
||||||
: null,
|
|
||||||
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
|
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
|
||||||
child: const Text('PDF 다운로드'),
|
child: const Text('PDF 다운로드'),
|
||||||
),
|
),
|
||||||
@@ -648,6 +696,40 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'상신자',
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
InventoryEmployeeAutocompleteField(
|
||||||
|
key: ValueKey(_pendingRequester?.id ?? 'none'),
|
||||||
|
controller: _requesterController,
|
||||||
|
initialSuggestion: _pendingRequester,
|
||||||
|
placeholder: '상신자 이름 또는 사번 검색',
|
||||||
|
onSuggestionSelected: (suggestion) {
|
||||||
|
setState(() {
|
||||||
|
_pendingRequester = suggestion;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChanged: () {
|
||||||
|
if (_requesterController.text.trim().isEmpty &&
|
||||||
|
_pendingRequester != null) {
|
||||||
|
setState(() {
|
||||||
|
_pendingRequester = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -712,6 +794,10 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
_SummaryRow(label: '유형', value: _appliedType.label),
|
_SummaryRow(label: '유형', value: _appliedType.label),
|
||||||
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
|
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
|
||||||
_SummaryRow(label: '상태', value: _appliedStatus.label),
|
_SummaryRow(label: '상태', value: _appliedStatus.label),
|
||||||
|
_SummaryRow(
|
||||||
|
label: '상신자',
|
||||||
|
value: _requesterLabel(_appliedRequester),
|
||||||
|
),
|
||||||
if (!_canExport)
|
if (!_canExport)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
|||||||
@@ -698,13 +698,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "0.5.1"
|
||||||
webdriver:
|
webdriver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ dependencies:
|
|||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
|
web: ^0.5.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/network/api_client.dart';
|
||||||
|
import 'package:superport_v2/features/approvals/data/repositories/approval_repository_remote.dart';
|
||||||
|
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||||
|
|
||||||
|
class _MockApiClient extends Mock implements ApiClient {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ApiClient apiClient;
|
||||||
|
late ApprovalRepositoryRemote repository;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(Options());
|
||||||
|
registerFallbackValue(CancelToken());
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
apiClient = _MockApiClient();
|
||||||
|
repository = ApprovalRepositoryRemote(apiClient: apiClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> buildStep({
|
||||||
|
required int id,
|
||||||
|
required int order,
|
||||||
|
required int statusId,
|
||||||
|
String statusName = '대기',
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'approval_id': 5001,
|
||||||
|
'step_order': order,
|
||||||
|
'approver': {
|
||||||
|
'id': 20 + order,
|
||||||
|
'employee_no': 'E${order.toString().padLeft(4, '0')}',
|
||||||
|
'employee_name': '승인자$order',
|
||||||
|
},
|
||||||
|
'step_status': {
|
||||||
|
'id': statusId,
|
||||||
|
'status_name': statusName,
|
||||||
|
'is_blocking_next': true,
|
||||||
|
'is_terminal': false,
|
||||||
|
},
|
||||||
|
'assigned_at': '2025-09-18T06:00:00Z',
|
||||||
|
'decided_at': order == 1 ? '2025-09-18T08:05:00Z' : null,
|
||||||
|
'note': order == 1 ? '승인합니다.' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> buildHistory({
|
||||||
|
required int id,
|
||||||
|
required String timestamp,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'approval_action_id': 1,
|
||||||
|
'approval_action_name': '승인',
|
||||||
|
'action_at': timestamp,
|
||||||
|
'note': '이력$id',
|
||||||
|
'from_status': {
|
||||||
|
'id': 1,
|
||||||
|
'status_name': '대기',
|
||||||
|
'is_blocking_next': true,
|
||||||
|
'is_terminal': false,
|
||||||
|
},
|
||||||
|
'to_status': {
|
||||||
|
'id': 2,
|
||||||
|
'status_name': '진행중',
|
||||||
|
'is_blocking_next': true,
|
||||||
|
'is_terminal': false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('performStepAction은 응답의 steps와 histories를 병합한다', () async {
|
||||||
|
const path = '/api/v1/approval-steps/7001/actions';
|
||||||
|
when(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
path,
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => Response<Map<String, dynamic>>(
|
||||||
|
data: {
|
||||||
|
'data': {
|
||||||
|
'approval': {
|
||||||
|
'id': 5001,
|
||||||
|
'approval_no': 'APP-2025-0001',
|
||||||
|
'status': {'id': 2, 'status_name': '진행중'},
|
||||||
|
'current_step': {'id': 7002, 'step_order': 2},
|
||||||
|
'requester': {
|
||||||
|
'id': 7,
|
||||||
|
'employee_no': 'E0007',
|
||||||
|
'employee_name': '김요청',
|
||||||
|
},
|
||||||
|
'requested_at': '2025-09-18T06:00:00Z',
|
||||||
|
'steps': [buildStep(id: 7001, order: 1, statusId: 1)],
|
||||||
|
'histories': [
|
||||||
|
buildHistory(id: 91000, timestamp: '2025-09-18T07:00:00Z'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'steps': [
|
||||||
|
buildStep(id: 7001, order: 1, statusId: 2, statusName: '진행중'),
|
||||||
|
buildStep(id: 7002, order: 2, statusId: 1),
|
||||||
|
],
|
||||||
|
'step': buildStep(
|
||||||
|
id: 7001,
|
||||||
|
order: 1,
|
||||||
|
statusId: 2,
|
||||||
|
statusName: '진행중',
|
||||||
|
),
|
||||||
|
'next_step': buildStep(
|
||||||
|
id: 7002,
|
||||||
|
order: 2,
|
||||||
|
statusId: 3,
|
||||||
|
statusName: '대기',
|
||||||
|
),
|
||||||
|
'history': buildHistory(
|
||||||
|
id: 91001,
|
||||||
|
timestamp: '2025-09-18T08:05:00Z',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusCode: 200,
|
||||||
|
requestOptions: RequestOptions(path: path),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await repository.performStepAction(
|
||||||
|
ApprovalStepActionInput(stepId: 7001, actionId: 1, note: '승인합니다.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.id, 5001);
|
||||||
|
expect(result.steps.length, 2);
|
||||||
|
|
||||||
|
final firstStep = result.steps.firstWhere((step) => step.id == 7001);
|
||||||
|
expect(firstStep.status.id, 2);
|
||||||
|
expect(firstStep.status.name, '진행중');
|
||||||
|
|
||||||
|
final secondStep = result.steps.firstWhere((step) => step.id == 7002);
|
||||||
|
expect(secondStep.status.id, 3);
|
||||||
|
|
||||||
|
expect(result.histories.length, 2);
|
||||||
|
expect(result.histories.last.id, 91001);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ import 'package:superport_v2/core/permissions/permission_manager.dart';
|
|||||||
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
|
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||||
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
|
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/form_field.dart';
|
||||||
|
|
||||||
|
import '../../helpers/inventory_test_stubs.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -16,6 +20,14 @@ void main() {
|
|||||||
await Environment.initialize();
|
await Environment.initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
registerInventoryTestStubs();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await GetIt.I.reset();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('입고 필터 적용 및 초기화가 목록을 갱신한다', (tester) async {
|
testWidgets('입고 필터 적용 및 초기화가 목록을 갱신한다', (tester) async {
|
||||||
final view = tester.view;
|
final view = tester.view;
|
||||||
view.physicalSize = const Size(1280, 800);
|
view.physicalSize = const Size(1280, 800);
|
||||||
@@ -95,6 +107,27 @@ void main() {
|
|||||||
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
|
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final transactionField = find.byWidgetPredicate(
|
||||||
|
(widget) => widget is SuperportFormField && widget.label == '트랜잭션번호',
|
||||||
|
);
|
||||||
|
final approvalField = find.byWidgetPredicate(
|
||||||
|
(widget) => widget is SuperportFormField && widget.label == '결재번호',
|
||||||
|
);
|
||||||
|
expect(transactionField, findsOneWidget);
|
||||||
|
expect(approvalField, findsOneWidget);
|
||||||
|
|
||||||
|
final transactionInput = find.descendant(
|
||||||
|
of: transactionField,
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
);
|
||||||
|
final approvalInput = find.descendant(
|
||||||
|
of: approvalField,
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
);
|
||||||
|
await tester.enterText(transactionInput.first, 'IN-TEST-001');
|
||||||
|
await tester.enterText(approvalInput.first, 'APP-TEST-001');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
final productFields = find.byType(InventoryProductAutocompleteField);
|
final productFields = find.byType(InventoryProductAutocompleteField);
|
||||||
expect(productFields, findsWidgets);
|
expect(productFields, findsWidgets);
|
||||||
|
|
||||||
@@ -105,7 +138,9 @@ void main() {
|
|||||||
await tester.enterText(firstProductInput, 'XR-5000');
|
await tester.enterText(firstProductInput, 'XR-5000');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(ShadButton, '품목 추가'));
|
final addLineButton = find.widgetWithText(ShadButton, '품목 추가');
|
||||||
|
await tester.ensureVisible(addLineButton);
|
||||||
|
await tester.tap(addLineButton);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final updatedProductFields = find.byType(InventoryProductAutocompleteField);
|
final updatedProductFields = find.byType(InventoryProductAutocompleteField);
|
||||||
@@ -118,9 +153,47 @@ void main() {
|
|||||||
await tester.enterText(secondProductInput, 'XR-5000');
|
await tester.enterText(secondProductInput, 'XR-5000');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(ShadButton, '저장'));
|
final saveButton = find.widgetWithText(ShadButton, '저장');
|
||||||
|
await tester.ensureVisible(saveButton);
|
||||||
|
await tester.tap(saveButton);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget);
|
expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('입고 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 900);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ScaffoldMessenger(
|
||||||
|
child: PermissionScope(
|
||||||
|
manager: PermissionManager(),
|
||||||
|
child: ShadTheme(
|
||||||
|
data: SuperportShadTheme.light(),
|
||||||
|
child: Scaffold(
|
||||||
|
body: InboundPage(routeUri: Uri.parse('/inventory/inbound')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '저장'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('거래번호를 입력하세요.'), findsOneWidget);
|
||||||
|
expect(find.text('결재번호를 입력하세요.'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
63
test/features/inventory/outbound_page_test.dart
Normal file
63
test/features/inventory/outbound_page_test.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/outbound/presentation/pages/outbound_page.dart';
|
||||||
|
|
||||||
|
import '../../helpers/inventory_test_stubs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await Environment.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
registerInventoryTestStubs();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await GetIt.I.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('출고 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 900);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ScaffoldMessenger(
|
||||||
|
child: PermissionScope(
|
||||||
|
manager: PermissionManager(),
|
||||||
|
child: ShadTheme(
|
||||||
|
data: SuperportShadTheme.light(),
|
||||||
|
child: Scaffold(
|
||||||
|
body: OutboundPage(routeUri: Uri.parse('/inventory/outbound')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '출고 등록'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '저장'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('거래번호를 입력하세요.'), findsOneWidget);
|
||||||
|
expect(find.text('결재번호를 입력하세요.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
test/features/inventory/rental_page_test.dart
Normal file
63
test/features/inventory/rental_page_test.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/rental/presentation/pages/rental_page.dart';
|
||||||
|
|
||||||
|
import '../../helpers/inventory_test_stubs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await Environment.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
registerInventoryTestStubs();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await GetIt.I.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('대여 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 900);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ScaffoldMessenger(
|
||||||
|
child: PermissionScope(
|
||||||
|
manager: PermissionManager(),
|
||||||
|
child: ShadTheme(
|
||||||
|
data: SuperportShadTheme.light(),
|
||||||
|
child: Scaffold(
|
||||||
|
body: RentalPage(routeUri: Uri.parse('/inventory/rental')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '대여 등록'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '저장'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('거래번호를 입력하세요.'), findsOneWidget);
|
||||||
|
expect(find.text('결재번호를 입력하세요.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,38 +22,19 @@ void main() {
|
|||||||
repository = TransactionCustomerRepositoryRemote(apiClient: apiClient);
|
repository = TransactionCustomerRepositoryRemote(apiClient: apiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> customerResponse() {
|
|
||||||
return {
|
|
||||||
'data': {
|
|
||||||
'customers': [
|
|
||||||
{
|
|
||||||
'id': 301,
|
|
||||||
'customer': {
|
|
||||||
'id': 700,
|
|
||||||
'customer_code': 'C-1',
|
|
||||||
'customer_name': '슈퍼포트',
|
|
||||||
},
|
|
||||||
'note': '테스트',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('addCustomers는 거래 ID를 포함해 POST 요청을 보낸다', () async {
|
test('addCustomers는 거래 ID를 포함해 POST 요청을 보낸다', () async {
|
||||||
const path = '/api/v1/stock-transactions/77/customers';
|
const path = '/api/v1/stock-transactions/77/customers';
|
||||||
when(
|
when(
|
||||||
() => apiClient.post<Map<String, dynamic>>(
|
() => apiClient.post<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
cancelToken: any(named: 'cancelToken'),
|
cancelToken: any(named: 'cancelToken'),
|
||||||
),
|
),
|
||||||
).thenAnswer(
|
).thenAnswer(
|
||||||
(_) async => Response<Map<String, dynamic>>(
|
(_) async => Response<void>(
|
||||||
data: customerResponse(),
|
|
||||||
requestOptions: RequestOptions(path: path),
|
requestOptions: RequestOptions(path: path),
|
||||||
statusCode: 200,
|
statusCode: 204,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,7 +44,7 @@ void main() {
|
|||||||
|
|
||||||
final payload =
|
final payload =
|
||||||
verify(
|
verify(
|
||||||
() => apiClient.post<Map<String, dynamic>>(
|
() => apiClient.post<void>(
|
||||||
captureAny(),
|
captureAny(),
|
||||||
data: captureAny(named: 'data'),
|
data: captureAny(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
@@ -79,17 +60,16 @@ void main() {
|
|||||||
test('updateCustomers는 PATCH 요청을 보낸다', () async {
|
test('updateCustomers는 PATCH 요청을 보낸다', () async {
|
||||||
const path = '/api/v1/stock-transactions/77/customers';
|
const path = '/api/v1/stock-transactions/77/customers';
|
||||||
when(
|
when(
|
||||||
() => apiClient.patch<Map<String, dynamic>>(
|
() => apiClient.patch<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
cancelToken: any(named: 'cancelToken'),
|
cancelToken: any(named: 'cancelToken'),
|
||||||
),
|
),
|
||||||
).thenAnswer(
|
).thenAnswer(
|
||||||
(_) async => Response<Map<String, dynamic>>(
|
(_) async => Response<void>(
|
||||||
data: customerResponse(),
|
|
||||||
requestOptions: RequestOptions(path: path),
|
requestOptions: RequestOptions(path: path),
|
||||||
statusCode: 200,
|
statusCode: 204,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,7 +78,7 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
verify(
|
verify(
|
||||||
() => apiClient.patch<Map<String, dynamic>>(
|
() => apiClient.patch<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
|
|||||||
@@ -22,36 +22,19 @@ void main() {
|
|||||||
repository = TransactionLineRepositoryRemote(apiClient: apiClient);
|
repository = TransactionLineRepositoryRemote(apiClient: apiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> lineResponse() {
|
|
||||||
return {
|
|
||||||
'data': {
|
|
||||||
'lines': [
|
|
||||||
{
|
|
||||||
'id': 101,
|
|
||||||
'line_no': 1,
|
|
||||||
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
|
|
||||||
'quantity': 3,
|
|
||||||
'unit_price': 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('addLines는 거래 ID를 포함한 POST 요청을 보낸다', () async {
|
test('addLines는 거래 ID를 포함한 POST 요청을 보낸다', () async {
|
||||||
const path = '/api/v1/stock-transactions/50/lines';
|
const path = '/api/v1/stock-transactions/50/lines';
|
||||||
when(
|
when(
|
||||||
() => apiClient.post<Map<String, dynamic>>(
|
() => apiClient.post<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
cancelToken: any(named: 'cancelToken'),
|
cancelToken: any(named: 'cancelToken'),
|
||||||
),
|
),
|
||||||
).thenAnswer(
|
).thenAnswer(
|
||||||
(_) async => Response<Map<String, dynamic>>(
|
(_) async => Response<void>(
|
||||||
data: lineResponse(),
|
|
||||||
requestOptions: RequestOptions(path: path),
|
requestOptions: RequestOptions(path: path),
|
||||||
statusCode: 200,
|
statusCode: 204,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,7 +49,7 @@ void main() {
|
|||||||
|
|
||||||
final payload =
|
final payload =
|
||||||
verify(
|
verify(
|
||||||
() => apiClient.post<Map<String, dynamic>>(
|
() => apiClient.post<void>(
|
||||||
captureAny(),
|
captureAny(),
|
||||||
data: captureAny(named: 'data'),
|
data: captureAny(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
@@ -82,17 +65,16 @@ void main() {
|
|||||||
test('updateLines는 PATCH 요청을 사용한다', () async {
|
test('updateLines는 PATCH 요청을 사용한다', () async {
|
||||||
const path = '/api/v1/stock-transactions/50/lines';
|
const path = '/api/v1/stock-transactions/50/lines';
|
||||||
when(
|
when(
|
||||||
() => apiClient.patch<Map<String, dynamic>>(
|
() => apiClient.patch<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
cancelToken: any(named: 'cancelToken'),
|
cancelToken: any(named: 'cancelToken'),
|
||||||
),
|
),
|
||||||
).thenAnswer(
|
).thenAnswer(
|
||||||
(_) async => Response<Map<String, dynamic>>(
|
(_) async => Response<void>(
|
||||||
data: lineResponse(),
|
|
||||||
requestOptions: RequestOptions(path: path),
|
requestOptions: RequestOptions(path: path),
|
||||||
statusCode: 200,
|
statusCode: 204,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,7 +83,7 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
verify(
|
verify(
|
||||||
() => apiClient.patch<Map<String, dynamic>>(
|
() => apiClient.patch<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
@@ -141,31 +123,28 @@ void main() {
|
|||||||
test('restoreLine은 복구 엔드포인트를 호출한다', () async {
|
test('restoreLine은 복구 엔드포인트를 호출한다', () async {
|
||||||
const path = '/api/v1/transaction-lines/101/restore';
|
const path = '/api/v1/transaction-lines/101/restore';
|
||||||
when(
|
when(
|
||||||
() => apiClient.post<Map<String, dynamic>>(
|
() => apiClient.post<void>(
|
||||||
path,
|
path,
|
||||||
data: any(named: 'data'),
|
data: any(named: 'data'),
|
||||||
options: any(named: 'options'),
|
options: any(named: 'options'),
|
||||||
cancelToken: any(named: 'cancelToken'),
|
cancelToken: any(named: 'cancelToken'),
|
||||||
),
|
),
|
||||||
).thenAnswer(
|
).thenAnswer(
|
||||||
(_) async => Response<Map<String, dynamic>>(
|
(_) async => Response<void>(
|
||||||
data: {
|
|
||||||
'data': {
|
|
||||||
'id': 101,
|
|
||||||
'line_no': 1,
|
|
||||||
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
|
|
||||||
'quantity': 3,
|
|
||||||
'unit_price': 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
requestOptions: RequestOptions(path: path),
|
requestOptions: RequestOptions(path: path),
|
||||||
statusCode: 200,
|
statusCode: 204,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final line = await repository.restoreLine(101);
|
await repository.restoreLine(101);
|
||||||
|
|
||||||
expect(line.id, 101);
|
verify(
|
||||||
expect(line.lineNo, 1);
|
() => apiClient.post<void>(
|
||||||
|
path,
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ void main() {
|
|||||||
from: DateTime(2024, 1, 1),
|
from: DateTime(2024, 1, 1),
|
||||||
to: DateTime(2024, 1, 31),
|
to: DateTime(2024, 1, 31),
|
||||||
format: ReportExportFormat.xlsx,
|
format: ReportExportFormat.xlsx,
|
||||||
transactionTypeId: 3,
|
transactionStatusId: 3,
|
||||||
statusId: 1,
|
approvalStatusId: 7,
|
||||||
warehouseId: 9,
|
requestedById: 9,
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await repository.exportTransactions(request);
|
final result = await repository.exportTransactions(request);
|
||||||
@@ -101,9 +101,9 @@ void main() {
|
|||||||
expect(query['from'], request.from.toIso8601String());
|
expect(query['from'], request.from.toIso8601String());
|
||||||
expect(query['to'], request.to.toIso8601String());
|
expect(query['to'], request.to.toIso8601String());
|
||||||
expect(query['format'], 'xlsx');
|
expect(query['format'], 'xlsx');
|
||||||
expect(query['type_id'], 3);
|
expect(query['transaction_status_id'], 3);
|
||||||
expect(query['status_id'], 1);
|
expect(query['approval_status_id'], 7);
|
||||||
expect(query['warehouse_id'], 9);
|
expect(query['requested_by_id'], 9);
|
||||||
|
|
||||||
expect(result.downloadUrl.toString(), 'https://example.com/report.xlsx');
|
expect(result.downloadUrl.toString(), 'https://example.com/report.xlsx');
|
||||||
expect(result.filename, 'report.xlsx');
|
expect(result.filename, 'report.xlsx');
|
||||||
@@ -138,7 +138,7 @@ void main() {
|
|||||||
from: DateTime(2024, 2, 1),
|
from: DateTime(2024, 2, 1),
|
||||||
to: DateTime(2024, 2, 15),
|
to: DateTime(2024, 2, 15),
|
||||||
format: ReportExportFormat.pdf,
|
format: ReportExportFormat.pdf,
|
||||||
statusId: 5,
|
approvalStatusId: 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await repository.exportApprovals(request);
|
final result = await repository.exportApprovals(request);
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import 'package:superport_v2/features/reporting/domain/entities/report_export_fo
|
|||||||
import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart';
|
import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart';
|
||||||
import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart';
|
import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart';
|
||||||
import 'package:superport_v2/features/reporting/presentation/pages/reporting_page.dart';
|
import 'package:superport_v2/features/reporting/presentation/pages/reporting_page.dart';
|
||||||
import 'package:superport_v2/widgets/components/empty_state.dart';
|
|
||||||
|
|
||||||
import '../../helpers/test_app.dart';
|
import '../../helpers/test_app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
@@ -349,27 +349,27 @@ class _StubTransactionLineRepository implements TransactionLineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionLine>> addLines(
|
Future<void> addLines(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionLineCreateInput> lines,
|
List<TransactionLineCreateInput> lines,
|
||||||
) async {
|
) async {
|
||||||
return _linesFor(transactionId);
|
_linesFor(transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteLine(int lineId) async {}
|
Future<void> deleteLine(int lineId) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionLine>> updateLines(
|
Future<void> updateLines(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionLineUpdateInput> lines,
|
List<TransactionLineUpdateInput> lines,
|
||||||
) async {
|
) async {
|
||||||
return _linesFor(transactionId);
|
_linesFor(transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<StockTransactionLine> restoreLine(int lineId) async {
|
Future<void> restoreLine(int lineId) async {
|
||||||
return _findLine(lineId);
|
_findLine(lineId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,22 +390,22 @@ class _StubTransactionCustomerRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionCustomer>> addCustomers(
|
Future<void> addCustomers(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionCustomerCreateInput> customers,
|
List<TransactionCustomerCreateInput> customers,
|
||||||
) async {
|
) async {
|
||||||
return _customersFor(transactionId);
|
_customersFor(transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteCustomer(int customerLinkId) async {}
|
Future<void> deleteCustomer(int customerLinkId) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StockTransactionCustomer>> updateCustomers(
|
Future<void> updateCustomers(
|
||||||
int transactionId,
|
int transactionId,
|
||||||
List<TransactionCustomerUpdateInput> customers,
|
List<TransactionCustomerUpdateInput> customers,
|
||||||
) async {
|
) async {
|
||||||
return _customersFor(transactionId);
|
_customersFor(transactionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user