API v4 계약 반영하고 보고서·입출고 화면 실연동 강화

This commit is contained in:
JiWoong Sul
2025-10-16 14:57:07 +09:00
parent 7e0f7b1c55
commit d5c99627db
34 changed files with 1767 additions and 327 deletions

View File

@@ -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 계약 검증 → 프론트엔드 실연동 착수.
- 필요 시 추가 논의 사항을 본 문서 하단에 코멘트 형태로 기록

View File

@@ -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 문서에도 신규 체크리스트를 반영한다.

View File

@@ -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 타임스탬프 범위 필터다.
--- ---

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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;
} }
if (data['approval_data'] is Map<String, dynamic>) {
return data['approval_data'] as Map<String, dynamic>; final merged = Map<String, dynamic>.from(approval);
if (dataMap != null) {
final steps = _mergeStepsPayload(
existing: merged['steps'],
data: dataMap,
);
if (steps != null) {
merged['steps'] = steps;
}
final histories = _mergeHistoriesPayload(
existing: merged['histories'],
data: dataMap,
);
if (histories != null) {
merged['histories'] = histories;
}
}
return merged;
}
Map<String, dynamic>? _selectApprovalPayload(Map<String, dynamic>? source) {
if (source == null) {
return null;
}
if (source['approval'] is Map<String, dynamic>) {
return Map<String, dynamic>.from(
source['approval'] as Map<String, dynamic>,
);
}
if (source['approval_data'] is Map<String, dynamic>) {
return Map<String, dynamic>.from(
source['approval_data'] as Map<String, dynamic>,
);
} }
final hasStatus = final hasStatus =
data.containsKey('status') || data.containsKey('approval_status'); source.containsKey('status') || source.containsKey('approval_status');
if (data.containsKey('approval_no') && hasStatus) { if (source.containsKey('approval_no') && hasStatus) {
return data; return Map<String, dynamic>.from(source);
} }
} if (source['approval'] == null && source['data'] is Map<String, dynamic>) {
if (body['approval'] is Map<String, dynamic>) { return _selectApprovalPayload(source['data'] as Map<String, dynamic>);
return body['approval'] 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;
}
} }

View File

@@ -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),
); );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(),
); );

View File

@@ -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);

View File

@@ -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;
}
} }

View File

@@ -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;
}
} }

View File

@@ -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,
};
}
}

View File

@@ -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,
); );

View File

@@ -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;

View File

@@ -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?,
); );
} }

View File

@@ -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,
}; };
} }

View File

@@ -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;
} }

View File

@@ -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),

View File

@@ -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:

View File

@@ -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:

View File

@@ -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);
});
}

View File

@@ -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);
});
} }

View 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);
});
}

View 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);
});
}

View File

@@ -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'),

View File

@@ -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);
}); });
} }

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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);
} }
} }