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. 배경
- 프론트엔드에서 인벤토리/승인 플로우를 실데이터에 맞춰 구현하기 위해서는 백엔드가 스펙(`stock_approval_system_api_v4.md`)상의 모든 마스터와 재고 트랜잭션 API를 제공해야 한다.
- 현 시점에는 `vendors`, `uoms`, `transaction_types`, `transaction_statuses`, `approval_statuses`, `approval_actions`, `warehouses` 엔드포인트까지만 구현되어 있으며, Flutter 화면은 아직 mock 데이터를 사용 중이다.
- 백엔드 코드를 직접 수정할 수 없는 상황이므로, 필요한 변경 사항을 명확히 정리해 전담 팀/담당자에게 전달한다.
- Flutter 프론트엔드(`superport_v2`)가 최신 백엔드(`superport_api_v2`)와 실연동을 준비하면서, 일부 엔드포인트가 미구현이거나 응답 스키마가 불완전해 화면 기능을 마무리하기 어렵다.
- 프론트는 Clean Architecture 기반으로 이미 도메인/레포지토리 계층을 구성했으며, UI 스펙에 맞춰 API 계약을 준수해야 한다.
- 본 문서는 백엔드 측 변경·추가 개발이 필요한 항목을 정리해 담당자에게 전달하기 위한 요청서이다.
## 2. 요청 범위
### 2.1 기본 경로 정렬
- 모든 REST 엔드포인트는 `/api/v1` prefix 하위로 노출되어야 하며, 기존 구현(vendors/uoms/transaction-types/transaction-statuses/approval-statuses/approval-actions/warehouses)도 동일한 경로를 유지해야 한다.
- OpenAPI/스펙 문서에는 버전 프리픽스가 명확히 표기되어야 하며, 변경 시 프론트엔드가 사용하는 베이스 URL(`Environment.baseUrl`)과 일치하도록 공지한다.
## 2. 주요 이슈 요약
- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 존재하지 않는다.
- 결재 단계(`approval-steps`) 관련 API가 단계 CRUD 조회/생성 응답을 돌려주지 않아 프론트 단계 관리 화면을 구현할 수 없다.
- 결재 단계 액션(`POST /approval-steps/{id}/actions`)과 승인 단계 일괄 등록/수정(`POST/PATCH /approvals/{id}/steps`)이 204만 반환해 프론트가 최신 결재 정보를 다시 받지 못한다.
- 그룹-메뉴 권한 목록이 메뉴 `route_path` 정보를 제공하지 않아 권한 매니저가 라우트별 권한을 구성할 수 없다.
### 2.2 마스터 데이터 API 확대
- 대상 테이블: `customers`, `products`, `employees`, `groups`, `menus`, `group_menu_permissions`, `approval_templates`, `approval_steps`(정의), `zipcodes` 검색용 API 등
- 요구 사항
- `/api/v1/<resource>` 패턴으로 목록/상세/생성/수정/삭제/복구 CRUD 일관성 유지
- 목록 API는 검색(q), 활성/비활성 필터, soft-delete 필터, 정렬(sort/order), 페이지네이션(page/page_size) 지원
- 관계형 데이터는 `find_also_related` 패턴으로 DTO에 포함 (예: 고객→zipcode, 그룹→permissions, 직원→group)
- 프론트엔드 Remote Repository(`lib/features/masters/**/data/repositories`)와 엔티티 스키마를 맞추기 위해 스펙 필드명 그대로 응답
## 3. 상세 요청
### 2.3 결재(Approval) 도메인 확장
- 리소스: `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates`
- 요구 사항
- 리스트/상세 API는 `include=steps,histories` 등 프론트가 사용하는 확장 파라미터를 지원해야 한다.
- 단계 배정(`POST /approvals/{id}/steps`), 단계 재배치(`PATCH /approvals/{id}/steps`), 단계 액션 수행(`POST /approval-steps/{id}/actions`)을 스펙대로 구현
- 승인 가능 여부 조회(`GET /approvals/{id}/can-proceed`) 및 복구(`/approvals/{id}/restore`) 포함
- 응답에는 Domain DTO(`ApprovalDto`, `ApprovalActionDto` 등)에서 필요로 하는 필드가 누락되지 않도록 검증
### 3.1 보고서 Export API 구현
- 엔드포인트:
- `GET /api/v1/reports/transactions/export`
- `GET /api/v1/reports/approvals/export`
- 요구 사항:
- 쿼리 파라미터: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `type_id`, `status_id`, `warehouse_id` 등 프론트에서 사용하는 필터 수용.
- 응답:
- 파일 다운로드(바이트 스트림) 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at`을 포함한 JSON.
- 인증/권한 정책 확정 후 문서화.
### 2.4 재고 트랜잭션 API 설계 및 구현
- 리소스: `stock_transactions` (입고/출고/대여), `transaction_lines`, `transaction_approvals` 등 스펙 정의 테이블
- 요구 사항
- 목록 필터: 상태, 창고, 고객/거래처, 기간(처리일/반납예정일 등), 포함(include=lines, approval_history 등)
- 상세 응답: 헤더 정보 + 라인아이템 + 승인 이력/로그 전달
- 상태 전이/승인 플로우 API (`submit`, `approve`, `reject`, `cancel`)와 재고 처리 결과 반영
- soft-delete 및 복구 정책 정의 (필요 시 논의)
- SeaORM 트랜잭션을 이용해 헤더/라인/로그 동시 저장
### 3.2 결재 단계/행위 API 정합성 보강
- `GET /api/v1/approval-steps` 및 단건/생성/수정/삭제/복구 API 구현이 필요하다. (프론트 `ApprovalStepRepository`가 CRUD 전체를 호출)
- `POST /api/v1/approval-steps/{id}/actions`는 204 대신 갱신된 결재 본문 혹은 최소한 단계와 상태 변화를 반환해야 한다.
- `POST|PATCH /api/v1/approvals/{id}/steps` 역시 204가 아닌
- 변경된 `approval` 요약, 혹은
- 새로 구성된 단계 리스트
를 포함한 JSON 응답을 제공해 프론트가 재조회 없이 상태를 갱신할 수 있도록 해 달라.
- 액션/단계 요청 본문은 `stock_approval_system_api_v4.md` 스펙과 동일하게 유지.
### 2.5 공통 고려사항
- DTO/응답 구조는 `stock_approval_system_api_v4.md`와 동기화하고, 변경 시 문서도 업데이트
- `script/run_api_tests.sh`에 각 리소스의 CRUD 및 상태 전이 스텝을 추가해 회귀 테스트 가능하도록 보완
- 샘플 데이터(`migration/002_sample_data.sql`)는 필수 참조 데이터만 유지하고, 대량 더미는 옵션 플래그로 분리
### 3.3 그룹-메뉴 권한 응답 확장
- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 다음 필드를 추가:
- `route_path` (launched 메뉴일 경우 실제 라우트 경로)
- 필요 시 `menu_code` 그대로 유지.
- 선택적으로 `include=group` 파라미터를 지원해 그룹 요약을 함께 반환하면 Front 권한 동기화 시 재조회가 줄어든다.
## 3. 선행 작업 및 의존성
- 데이터 모델 검증: 스펙과 현재 DB 스키마 일치 여부 확인, 필요한 경우 추가 마이그레이션 작성
- 인증/권한: 그룹-메뉴 권한 매핑을 API 보호 미들웨어에 적용 (추후 프론트엔드 권한 제어와 연동)
- 로깅/관측성: 주요 재고 트랜잭션 이벤트를 tracing 로그로 남겨 운영 대응
### 3.4 응답/에러 문서화
- 위 변경 사항이 반영되면 `stock_approval_system_api_v4.md`를 업데이트하고, 각 엔드포인트 예제 응답을 최신 상태로 반영한다.
- 회귀 테스트(`cargo test` + 통합 시나리오 스크립트)가 변경된 계약을 검증하도록 보강한다.
## 4. 수용 기준 (Acceptance Criteria)
- 모든 신규/확장된 엔드포인트에 대해 Actix 라우트, 도메인 DTO, SeaORM 리포지토리, 에러 매핑이 완비되어야 한다.
- `cargo check`, `cargo test`, `script/run_api_tests.sh`가 통과해야 하며, 샘플 DB로 기본 CRUD 시나리오가 동작할 것.
- README `Next Steps` 섹션 업데이트와 변경된 API 스펙 커밋이 포함되어야 한다.
## 4. 수용 기준
- 상기 엔드포인트가 모두 구현되고, 요청/응답이 문서와 일치해야 한다.
- 레거시 응답(204)에서 JSON 반환으로 변경될 경우, 클라이언트가 기대하는 키(`data.approval`, `data.steps` 등)를 포함해야 한다.
- `cargo fmt`, `cargo check`, `cargo test` 및 기존 CI 파이프라인이 통과한다.
## 5. 후속 조치
- 본 문서 확인 후 백엔드 담당자가 작업 범위/일정을 산출
- 작업 완료 시 프론트엔드 팀에 API mock 제거 및 실연동 착수 일정 공유
- 필요 시 추가 논의 사항을 본 문서 하단에 코멘트 형태로 기록
- 백엔드 담당자가 일정/우선순위를 산출해 프론트 팀과 공유.
- 구현 완료 후 샌드박스 환경에서 API 계약 검증 → 프론트엔드 실연동 착수.

View File

@@ -114,3 +114,18 @@
- [x] 그룹-메뉴 권한 복구 미구현(4건)은 `/group-menu-permissions/{id}/restore` 엔드포인트 공개 후 프론트 통합 테스트에 포함시킨다. (2025-10-19) 동일 문서 2.2절에 복구 API 요구사항을 명시하고 테스트 시나리오를 정리했다.
- [x] 프론트단에서는 `ApiErrorMapper``Failure` 파서를 보강해 403/409/422 응답 메시지를 토스트·다이얼로그에 그대로 노출하고, 재시도 시 가이드 문구를 제공한다.
- [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. 공통 규칙
- **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`.
- **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`.
@@ -206,6 +214,7 @@
"id": 301,
"customer_code": "C001",
"customer_name": "ABC물류",
"contact_name": "박담당",
"is_partner": true,
"is_general": false,
"email": "contact@abc.com",
@@ -229,6 +238,8 @@
}
```
> `contact_name`은 고객사 담당자 실명. 선택 입력이며 미입력 시 `null`.
`GET /employees?page=1`
```json
{
@@ -270,7 +281,8 @@
"menu": {
"id": 12,
"menu_code": "STOCK_MGMT",
"menu_name": "입출고 관리"
"menu_name": "입출고 관리",
"route_path": "/inventory/transactions"
},
"can_create": true,
"can_read": true,
@@ -418,10 +430,16 @@
"unit_price": 0
}
],
"customers": []
"customers": [],
"approval": {
"approval_no": "APP-2025-0001",
"requested_by_id": 7,
"note": "입고 결재"
}
}
```
응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다.
응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. `approval`
블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다.
### 4.2 목록 조회
`GET /stock-transactions?include=lines,customers,approval`
@@ -476,8 +494,53 @@
"note": null
}
],
"customers": [],
"approval": null
"customers": [
{
"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,
@@ -542,8 +605,53 @@
"note": null
}
],
"customers": [],
"approval": null
"customers": [
{
"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
- `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
리소스: `/approvals`, 보조 리소스: `/approval-steps`, `/approval-histories`
- 단계 상태가 바뀔 때마다 `approvals.current_step_id`는 차기 단계의 ID로 갱신되고, 전체 결재 상태(`approval_status_id`) 역시 해당 단계 상태로 업데이트된다.
- 템플릿에서 복제된 단계는 모두 `대기` 상태로 저장되며 템플릿이 이후 수정돼도 기존 결재에는 반영되지 않는다.
- `GET /approvals/{id}/can-proceed`는 현재 단계의 상태에 매핑된 `is_blocking_next` 값이 `false`일 때 `true`를 반환한다.
### 5.1 결재 생성
`POST /approvals`
```json
@@ -671,7 +820,24 @@
"status_name": "대기",
"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": {
"id": 7,
"employee_no": "E2025001",
@@ -683,7 +849,26 @@
"is_active": true,
"created_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": []
}
],
@@ -710,7 +895,24 @@
"is_blocking_next": true,
"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": {
"id": 7,
"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 단계 일괄 수정/재배치
`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 단계 행위
`POST /approval-steps/7001/actions`
@@ -794,7 +1088,104 @@
"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`에 기록된다.
프론트엔드는 204 응답이 아닌 위의 `{ "data": { ... } }` 본문을 소비해 화면 상태를 즉시 갱신해야 한다.
### 5.7 결재 상태 확인
`GET /approvals/5001/can-proceed`
@@ -820,6 +1211,126 @@
- `DELETE /approvals/5001`
- `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
@@ -940,11 +1451,20 @@
---
## 7. 보고서 API (선택)
- `GET /reports/transactions/export?from=2025-09-01&to=2025-09-30&type_id=2&warehouse_id=1&format=xlsx`
- `GET /reports/approvals/export?status_id=1&format=pdf`
## 7. 보고서 Export
- `format=xlsx|pdf` 파라미터를 지원한다. 현재는 `format=xlsx`만 성공하며, `format=pdf`를 지정하면 400 Bad Request가 반환된다.
- 응답은 즉시 다운로드 스트림으로 전달되며 `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 {}),
),
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(),
decidedAt: _parseDate(json['decided_at']),
@@ -263,7 +265,12 @@ class ApprovalHistoryDto {
return ApprovalHistoryDto(
id: json['id'] as int?,
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>
? ApprovalStatusDto.fromJson(

View File

@@ -175,22 +175,199 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
Map<String, dynamic> body,
) {
final data = body['data'];
if (data is Map<String, dynamic>) {
if (data['approval'] is Map<String, dynamic>) {
return data['approval'] as Map<String, dynamic>;
final dataMap = data is Map<String, dynamic> ? data : null;
Map<String, dynamic>? approval = _selectApprovalPayload(dataMap);
approval ??= _selectApprovalPayload(body);
if (approval == null) {
return null;
}
final merged = Map<String, dynamic>.from(approval);
if (dataMap != null) {
final steps = _mergeStepsPayload(
existing: merged['steps'],
data: dataMap,
);
if (steps != null) {
merged['steps'] = steps;
}
if (data['approval_data'] is Map<String, dynamic>) {
return data['approval_data'] as Map<String, dynamic>;
}
final hasStatus =
data.containsKey('status') || data.containsKey('approval_status');
if (data.containsKey('approval_no') && hasStatus) {
return data;
final histories = _mergeHistoriesPayload(
existing: merged['histories'],
data: dataMap,
);
if (histories != null) {
merged['histories'] = histories;
}
}
if (body['approval'] is Map<String, dynamic>) {
return body['approval'] as Map<String, dynamic>;
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 =
source.containsKey('status') || source.containsKey('approval_status');
if (source.containsKey('approval_no') && hasStatus) {
return Map<String, dynamic>.from(source);
}
if (source['approval'] == null && source['data'] is Map<String, dynamic>) {
return _selectApprovalPayload(source['data'] as Map<String, dynamic>);
}
return null;
}
List<Map<String, dynamic>>? _mergeStepsPayload({
required dynamic existing,
required Map<String, dynamic> data,
}) {
final steps = <Map<String, dynamic>>[];
void upsert(Map<String, dynamic> step) {
final id = step['id'] as int?;
final order = step['step_order'] as int?;
final index = steps.indexWhere((element) {
final elementId = element['id'] as int?;
if (elementId != null && id != null) {
return elementId == id;
}
if (order != null) {
return element['step_order'] == order;
}
return false;
});
final copy = Map<String, dynamic>.from(step);
if (index >= 0) {
steps[index] = copy;
} else {
steps.add(copy);
}
}
if (existing is List) {
for (final item in existing) {
if (item is Map<String, dynamic>) {
upsert(item);
}
}
}
final responseSteps = data['steps'];
if (responseSteps is List) {
for (final item in responseSteps) {
if (item is Map<String, dynamic>) {
upsert(item);
}
}
}
if (data['step'] is Map<String, dynamic>) {
upsert(data['step'] as Map<String, dynamic>);
}
if (data['next_step'] is Map<String, dynamic>) {
upsert(data['next_step'] as Map<String, dynamic>);
}
if (steps.isEmpty) {
return existing is List
? existing
.whereType<Map<String, dynamic>>()
.map((step) => Map<String, dynamic>.from(step))
.toList()
: null;
}
steps.sort((a, b) {
final orderA = a['step_order'] as int? ?? 0;
final orderB = b['step_order'] as int? ?? 0;
return orderA.compareTo(orderB);
});
return steps;
}
List<Map<String, dynamic>>? _mergeHistoriesPayload({
required dynamic existing,
required Map<String, dynamic> data,
}) {
final histories = <Map<String, dynamic>>[];
void append(Map<String, dynamic> history) {
histories.add(Map<String, dynamic>.from(history));
}
if (existing is List) {
for (final item in existing) {
if (item is Map<String, dynamic>) {
append(item);
}
}
}
final responseHistories = data['histories'];
if (responseHistories is List) {
for (final item in responseHistories) {
if (item is Map<String, dynamic>) {
append(item);
}
}
}
if (data['history'] is Map<String, dynamic>) {
append(data['history'] as Map<String, dynamic>);
}
if (histories.isEmpty) {
return existing is List
? existing
.whereType<Map<String, dynamic>>()
.map((history) => Map<String, dynamic>.from(history))
.toList()
: null;
}
DateTime? parseTime(Map<String, dynamic> json) {
String? read(dynamic value) {
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
return null;
}
final raw =
read(json['action_at']) ??
read(json['created_at']) ??
read(json['updated_at']);
if (raw == null) {
return null;
}
return DateTime.tryParse(raw);
}
histories.sort((a, b) {
final timeA = parseTime(a);
final timeB = parseTime(b);
if (timeA == null && timeB == null) {
return 0;
}
if (timeA == null) {
return 1;
}
if (timeB == null) {
return -1;
}
return timeA.compareTo(timeB);
});
return histories;
}
}

View File

@@ -31,10 +31,8 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (action != null && action.isNotEmpty) 'action': action,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
if (from != null) 'action_from': from.toIso8601String(),
if (to != null) 'action_to': to.toIso8601String(),
},
options: Options(responseType: ResponseType.json),
);

View File

@@ -1289,8 +1289,12 @@ class _InboundPageState extends State<InboundPage> {
);
final remarkController = TextEditingController(text: initial?.remark ?? '');
final transactionNumberController = TextEditingController(
text: initial?.transactionNumber ?? '저장 시 자동 생성',
text: initial?.transactionNumber ?? '',
);
final approvalNumberController = TextEditingController(
text: initial?.raw?.approval?.approvalNo ?? '',
);
final approvalNoteController = TextEditingController();
final transactionTypeValue =
initial?.transactionType ??
_transactionTypeLookup?.name ??
@@ -1311,6 +1315,8 @@ class _InboundPageState extends State<InboundPage> {
};
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? warehouseError;
String? statusError;
String? headerNotice;
@@ -1339,12 +1345,18 @@ class _InboundPageState extends State<InboundPage> {
writerController: writerController,
writerSelection: writerSelection,
requireWriterSelection: initial == null,
transactionNumberController: transactionNumberController,
transactionNumberRequired: initial == null,
approvalNumberController: approvalNumberController,
approvalNumberRequired: initial == null,
warehouseSelection: warehouseSelection,
statusValue: statusValue.value,
drafts: drafts,
lineErrors: lineErrors,
);
writerError = validationResult.writerError;
transactionNumberError = validationResult.transactionNumberError;
approvalNumberError = validationResult.approvalNumberError;
warehouseError = validationResult.warehouseError;
statusError = validationResult.statusError;
headerNotice = validationResult.headerNotice;
@@ -1388,6 +1400,9 @@ class _InboundPageState extends State<InboundPage> {
final remarkText = remarkController.text.trim();
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 initialRecord = initial;
@@ -1475,6 +1490,7 @@ class _InboundPageState extends State<InboundPage> {
.toList(growable: false);
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionNo: transactionNoValue,
transactionTypeId: transactionTypeLookup.id,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
@@ -1482,6 +1498,11 @@ class _InboundPageState extends State<InboundPage> {
createdById: createdById,
note: remarkValue,
lines: createLines,
approval: StockTransactionApprovalInput(
approvalNo: approvalNoValue,
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
),
);
result = created;
@@ -1635,10 +1656,41 @@ class _InboundPageState extends State<InboundPage> {
width: 240,
child: SuperportFormField(
label: '트랜잭션번호',
required: true,
errorText: transactionNumberError,
child: ShadInput(
controller: transactionNumberController,
readOnly: true,
enabled: false,
readOnly: initial != null,
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(
width: 500,
child: SuperportFormField(
@@ -1802,6 +1864,8 @@ class _InboundPageState extends State<InboundPage> {
writerController.dispose();
remarkController.dispose();
transactionNumberController.dispose();
approvalNumberController.dispose();
approvalNoteController.dispose();
transactionTypeController.dispose();
processedAt.dispose();
@@ -2346,6 +2410,10 @@ _InboundFormValidation _validateInboundForm({
required TextEditingController writerController,
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required TextEditingController transactionNumberController,
required bool transactionNumberRequired,
required TextEditingController approvalNumberController,
required bool approvalNumberRequired,
required InventoryWarehouseOption? warehouseSelection,
required String statusValue,
required List<_LineItemDraft> drafts,
@@ -2353,6 +2421,8 @@ _InboundFormValidation _validateInboundForm({
}) {
var isValid = true;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? warehouseError;
String? statusError;
String? headerNotice;
@@ -2368,6 +2438,18 @@ _InboundFormValidation _validateInboundForm({
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) {
warehouseError = '창고를 선택하세요.';
isValid = false;
@@ -2426,6 +2508,8 @@ _InboundFormValidation _validateInboundForm({
return _InboundFormValidation(
isValid: isValid,
writerError: writerError,
transactionNumberError: transactionNumberError,
approvalNumberError: approvalNumberError,
warehouseError: warehouseError,
statusError: statusError,
headerNotice: headerNotice,
@@ -2441,6 +2525,8 @@ class _InboundFormValidation {
const _InboundFormValidation({
required this.isValid,
this.writerError,
this.transactionNumberError,
this.approvalNumberError,
this.warehouseError,
this.statusError,
this.headerNotice,
@@ -2448,6 +2534,8 @@ class _InboundFormValidation {
final bool isValid;
final String? writerError;
final String? transactionNumberError;
final String? approvalNumberError;
final String? warehouseError;
final String? statusError;
final String? headerNotice;

View File

@@ -1435,6 +1435,13 @@ class _OutboundPageState extends State<OutboundPage> {
final transactionTypeController = TextEditingController(
text: transactionTypeValue,
);
final transactionNumberController = TextEditingController(
text: initial?.transactionNumber ?? '',
);
final approvalNumberController = TextEditingController(
text: initial?.raw?.approval?.approvalNo ?? '',
);
final approvalNoteController = TextEditingController();
final drafts =
initial?.items
@@ -1448,6 +1455,8 @@ class _OutboundPageState extends State<OutboundPage> {
};
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -1477,6 +1486,10 @@ class _OutboundPageState extends State<OutboundPage> {
writerController: writerController,
writerSelection: writerSelection,
requireWriterSelection: initial == null,
transactionNumberController: transactionNumberController,
transactionNumberRequired: initial == null,
approvalNumberController: approvalNumberController,
approvalNumberRequired: initial == null,
warehouseSelection: warehouseSelection,
statusValue: statusValue.value,
selectedCustomers: customerSelection
@@ -1487,6 +1500,8 @@ class _OutboundPageState extends State<OutboundPage> {
);
writerError = validation.writerError;
transactionNumberError = validation.transactionNumberError;
approvalNumberError = validation.approvalNumberError;
customerError = validation.customerError;
warehouseError = validation.warehouseError;
statusError = validation.statusError;
@@ -1531,6 +1546,9 @@ class _OutboundPageState extends State<OutboundPage> {
final remarkText = remarkController.text.trim();
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 lineDrafts = <TransactionLineDraft>[];
@@ -1656,6 +1674,7 @@ class _OutboundPageState extends State<OutboundPage> {
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionNo: transactionNoValue,
transactionTypeId: transactionTypeLookup.id,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
@@ -1664,6 +1683,11 @@ class _OutboundPageState extends State<OutboundPage> {
note: remarkValue,
lines: createLines,
customers: createCustomers,
approval: StockTransactionApprovalInput(
approvalNo: approvalNoValue,
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
),
);
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(
width: 240,
child: SuperportFormField(
@@ -1846,6 +1912,16 @@ class _OutboundPageState extends State<OutboundPage> {
),
),
),
SizedBox(
width: 500,
child: SuperportFormField(
label: '결재 메모',
child: ShadInput(
controller: approvalNoteController,
maxLines: 2,
),
),
),
SizedBox(
width: 360,
child: SuperportFormField(
@@ -2012,6 +2088,9 @@ class _OutboundPageState extends State<OutboundPage> {
writerController.dispose();
remarkController.dispose();
transactionTypeController.dispose();
transactionNumberController.dispose();
approvalNumberController.dispose();
approvalNoteController.dispose();
processedAt.dispose();
return result;
@@ -2460,6 +2539,10 @@ _OutboundFormValidation _validateOutboundForm({
required TextEditingController writerController,
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required TextEditingController transactionNumberController,
required bool transactionNumberRequired,
required TextEditingController approvalNumberController,
required bool approvalNumberRequired,
required InventoryWarehouseOption? warehouseSelection,
required String statusValue,
required List<InventoryCustomerOption> selectedCustomers,
@@ -2468,6 +2551,8 @@ _OutboundFormValidation _validateOutboundForm({
}) {
var isValid = true;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -2484,6 +2569,18 @@ _OutboundFormValidation _validateOutboundForm({
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) {
warehouseError = '창고를 선택하세요.';
isValid = false;
@@ -2547,6 +2644,8 @@ _OutboundFormValidation _validateOutboundForm({
return _OutboundFormValidation(
isValid: isValid,
writerError: writerError,
transactionNumberError: transactionNumberError,
approvalNumberError: approvalNumberError,
customerError: customerError,
warehouseError: warehouseError,
statusError: statusError,
@@ -2558,6 +2657,8 @@ class _OutboundFormValidation {
const _OutboundFormValidation({
required this.isValid,
this.writerError,
this.transactionNumberError,
this.approvalNumberError,
this.customerError,
this.warehouseError,
this.statusError,
@@ -2566,6 +2667,8 @@ class _OutboundFormValidation {
final bool isValid;
final String? writerError;
final String? transactionNumberError;
final String? approvalNumberError;
final String? customerError;
final String? warehouseError;
final String? statusError;

View File

@@ -1411,6 +1411,13 @@ class _RentalPageState extends State<RentalPage> {
final transactionTypeController = TextEditingController(
text: _transactionTypeForRental(rentalTypeValue.value),
);
final transactionNumberController = TextEditingController(
text: initial?.transactionNumber ?? '',
);
final approvalNumberController = TextEditingController(
text: initial?.raw?.approval?.approvalNo ?? '',
);
final approvalNoteController = TextEditingController();
final drafts =
initial?.items
@@ -1421,6 +1428,8 @@ class _RentalPageState extends State<RentalPage> {
RentalRecord? result;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -1452,6 +1461,10 @@ class _RentalPageState extends State<RentalPage> {
writerController: writerController,
writerSelection: writerSelection,
requireWriterSelection: initial == null,
transactionNumberController: transactionNumberController,
transactionNumberRequired: initial == null,
approvalNumberController: approvalNumberController,
approvalNumberRequired: initial == null,
warehouseSelection: warehouseSelection,
statusValue: statusValue.value,
selectedCustomers: customerSelection
@@ -1462,6 +1475,8 @@ class _RentalPageState extends State<RentalPage> {
);
writerError = validation.writerError;
transactionNumberError = validation.transactionNumberError;
approvalNumberError = validation.approvalNumberError;
customerError = validation.customerError;
warehouseError = validation.warehouseError;
statusError = validation.statusError;
@@ -1507,6 +1522,9 @@ class _RentalPageState extends State<RentalPage> {
final remarkText = remarkController.text.trim();
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 initialRecord = initial;
@@ -1633,6 +1651,7 @@ class _RentalPageState extends State<RentalPage> {
final transactionTypeId = selectedLookup.id;
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionNo: transactionNoValue,
transactionTypeId: transactionTypeId,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
@@ -1642,6 +1661,11 @@ class _RentalPageState extends State<RentalPage> {
expectedReturnDate: returnDue.value,
lines: createLines,
customers: createCustomers,
approval: StockTransactionApprovalInput(
approvalNo: approvalNoValue,
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
),
);
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(
width: 360,
child: _FormFieldLabel(
@@ -1939,6 +2031,16 @@ class _RentalPageState extends State<RentalPage> {
),
),
),
SizedBox(
width: 500,
child: _FormFieldLabel(
label: '결재 메모',
child: ShadInput(
controller: approvalNoteController,
maxLines: 2,
),
),
),
SizedBox(
width: 500,
child: _FormFieldLabel(
@@ -2068,6 +2170,9 @@ class _RentalPageState extends State<RentalPage> {
writerController.dispose();
remarkController.dispose();
transactionTypeController.dispose();
transactionNumberController.dispose();
approvalNumberController.dispose();
approvalNoteController.dispose();
processedAt.dispose();
returnDue.dispose();
@@ -2543,6 +2648,10 @@ _RentalFormValidation _validateRentalForm({
required TextEditingController writerController,
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required TextEditingController transactionNumberController,
required bool transactionNumberRequired,
required TextEditingController approvalNumberController,
required bool approvalNumberRequired,
required InventoryWarehouseOption? warehouseSelection,
required String statusValue,
required List<InventoryCustomerOption> selectedCustomers,
@@ -2551,6 +2660,8 @@ _RentalFormValidation _validateRentalForm({
}) {
var isValid = true;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -2567,6 +2678,18 @@ _RentalFormValidation _validateRentalForm({
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) {
warehouseError = '창고를 선택하세요.';
isValid = false;
@@ -2630,6 +2753,8 @@ _RentalFormValidation _validateRentalForm({
return _RentalFormValidation(
isValid: isValid,
writerError: writerError,
transactionNumberError: transactionNumberError,
approvalNumberError: approvalNumberError,
customerError: customerError,
warehouseError: warehouseError,
statusError: statusError,
@@ -2641,6 +2766,8 @@ class _RentalFormValidation {
const _RentalFormValidation({
required this.isValid,
this.writerError,
this.transactionNumberError,
this.approvalNumberError,
this.customerError,
this.warehouseError,
this.statusError,
@@ -2649,6 +2776,8 @@ class _RentalFormValidation {
final bool isValid;
final String? writerError;
final String? transactionNumberError;
final String? approvalNumberError;
final String? customerError;
final String? warehouseError;
final String? statusError;

View File

@@ -37,6 +37,7 @@ class InventoryEmployeeAutocompleteField extends StatefulWidget {
required this.onSuggestionSelected,
this.onChanged,
this.enabled = true,
this.placeholder = '작성자 이름 또는 사번 검색',
});
final TextEditingController controller;
@@ -44,6 +45,7 @@ class InventoryEmployeeAutocompleteField extends StatefulWidget {
final ValueChanged<InventoryEmployeeSuggestion?> onSuggestionSelected;
final VoidCallback? onChanged;
final bool enabled;
final String placeholder;
@override
State<InventoryEmployeeAutocompleteField> createState() =>
@@ -193,7 +195,7 @@ class _InventoryEmployeeAutocompleteFieldState
controller: textController,
focusNode: focusNode,
enabled: widget.enabled,
placeholder: const Text('작성자 이름 또는 사번 검색'),
placeholder: Text(widget.placeholder),
onChanged: (_) => widget.onChanged?.call(),
onSubmitted: (_) => onFieldSubmitted(),
);

View File

@@ -84,6 +84,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> submit(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/submit',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -93,6 +94,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> complete(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/complete',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -102,6 +104,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> approve(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/approve',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -111,6 +114,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> reject(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/reject',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -120,6 +124,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> cancel(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/cancel',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
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_routes.dart';
import '../../domain/entities/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 고객 연결 API를 호출하는 원격 저장소 구현체.
class TransactionCustomerRepositoryRemote
@@ -19,11 +16,11 @@ class TransactionCustomerRepositoryRemote
static const _customerPath = '${ApiRoutes.apiV1}/transaction-customers';
@override
Future<List<StockTransactionCustomer>> addCustomers(
Future<void> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
) async {
final response = await _api.post<Map<String, dynamic>>(
await _api.post<void>(
'$_basePath/$transactionId/customers',
data: {
'id': transactionId,
@@ -31,17 +28,15 @@ class TransactionCustomerRepositoryRemote
.map((customer) => customer.toJson())
.toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseCustomers(response.data);
}
@override
Future<List<StockTransactionCustomer>> updateCustomers(
Future<void> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
) async {
final response = await _api.patch<Map<String, dynamic>>(
await _api.patch<void>(
'$_basePath/$transactionId/customers',
data: {
'id': transactionId,
@@ -49,38 +44,11 @@ class TransactionCustomerRepositoryRemote
.map((customer) => customer.toJson())
.toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseCustomers(response.data);
}
@override
Future<void> deleteCustomer(int customerLinkId) async {
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_routes.dart';
import '../../domain/entities/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 라인 API를 호출하는 원격 저장소 구현체.
class TransactionLineRepositoryRemote implements TransactionLineRepository {
@@ -18,35 +15,31 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository {
static const _linePath = '${ApiRoutes.apiV1}/transaction-lines';
@override
Future<List<StockTransactionLine>> addLines(
Future<void> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
) async {
final response = await _api.post<Map<String, dynamic>>(
await _api.post<void>(
'$_basePath/$transactionId/lines',
data: {
'id': transactionId,
'lines': lines.map((line) => line.toJson()).toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseLines(response.data);
}
@override
Future<List<StockTransactionLine>> updateLines(
Future<void> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
) async {
final response = await _api.patch<Map<String, dynamic>>(
await _api.patch<void>(
'$_basePath/$transactionId/lines',
data: {
'id': transactionId,
'lines': lines.map((line) => line.toJson()).toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseLines(response.data);
}
@override
@@ -55,40 +48,10 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository {
}
@override
Future<StockTransactionLine> restoreLine(int lineId) async {
final response = await _api.post<Map<String, dynamic>>(
Future<void> restoreLine(int lineId) async {
await _api.post<void>(
'$_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.lines = const [],
this.customers = const [],
this.approval,
});
final String? transactionNo;
@@ -23,6 +24,7 @@ class StockTransactionCreateInput {
final DateTime? expectedReturnDate;
final List<TransactionLineCreateInput> lines;
final List<TransactionCustomerCreateInput> customers;
final StockTransactionApprovalInput? approval;
Map<String, dynamic> toPayload() {
return {
@@ -42,6 +44,7 @@ class StockTransactionCreateInput {
'customers': customers
.map((customer) => customer.toJson())
.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 {
/// 라인을 추가한다.
Future<List<StockTransactionLine>> addLines(
Future<void> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
);
/// 라인 정보를 일괄 수정한다.
Future<List<StockTransactionLine>> updateLines(
Future<void> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
);
@@ -62,19 +62,19 @@ abstract class TransactionLineRepository {
Future<void> deleteLine(int lineId);
/// 삭제된 라인을 복구한다.
Future<StockTransactionLine> restoreLine(int lineId);
Future<void> restoreLine(int lineId);
}
/// 재고 트랜잭션 고객 연결 저장소 인터페이스.
abstract class TransactionCustomerRepository {
/// 고객 연결을 추가한다.
Future<List<StockTransactionCustomer>> addCustomers(
Future<void> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
);
/// 고객 연결 정보를 수정한다.
Future<List<StockTransactionCustomer>> updateCustomers(
Future<void> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
);

View File

@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart';
import '../../../../core/network/api_error.dart';
import '../../../../core/network/failure.dart';
import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart';
@@ -73,9 +74,10 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) return;
final failure = Failure.from(error);
final description = failure.describe();
final message = description.isEmpty
? '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'
: description;
final hasApiDetails = failure.raw is ApiException;
final message = hasApiDetails && description.isNotEmpty
? description
: '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
setState(() {
errorMessage = message;
isLoading = false;

View File

@@ -133,7 +133,7 @@ class GroupPermissionMenuDto {
id: json['id'] as int? ?? json['menu_id'] as int,
menuCode: code,
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(),
'to': request.to.toIso8601String(),
'format': request.format.apiValue,
if (request.transactionTypeId != null)
'type_id': request.transactionTypeId,
if (request.statusId != null) 'status_id': request.statusId,
if (request.warehouseId != null) 'warehouse_id': request.warehouseId,
if (request.transactionStatusId != null)
'transaction_status_id': request.transactionStatusId,
if (request.approvalStatusId != null)
'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.to,
required this.format,
this.transactionTypeId,
this.statusId,
this.warehouseId,
this.transactionStatusId,
this.approvalStatusId,
this.requestedById,
});
/// 조회 시작 일자.
@@ -20,12 +20,12 @@ class ReportExportRequest {
/// 내보내기 파일 형식.
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: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/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/repositories/warehouse_repository.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/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
import 'package:superport_v2/core/network/failure.dart';
/// 보고서 다운로드 화면 루트 위젯.
class ReportingPage extends StatefulWidget {
@@ -36,7 +38,6 @@ class _ReportingPageState extends State<ReportingPage> {
ReportingRepository? _reportingRepository;
InventoryLookupRepository? _lookupRepository;
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
final Map<ReportTypeFilter, LookupItem> _transactionTypeLookup = {};
final Map<ReportStatusFilter, LookupItem> _transactionStatusLookup = {};
final Map<ReportStatusFilter, LookupItem> _approvalStatusLookup = {};
bool _isLoadingLookups = false;
@@ -45,12 +46,9 @@ class _ReportingPageState extends State<ReportingPage> {
String? _exportError;
ReportDownloadResult? _lastResult;
ReportExportFormat? _lastFormat;
static const Map<ReportTypeFilter, List<String>> _transactionTypeKeywords = {
ReportTypeFilter.inbound: ['입고', 'inbound'],
ReportTypeFilter.outbound: ['출고', 'outbound'],
ReportTypeFilter.rental: ['대여', 'rent', 'rental'],
};
final TextEditingController _requesterController = TextEditingController();
InventoryEmployeeSuggestion? _appliedRequester;
InventoryEmployeeSuggestion? _pendingRequester;
static const Map<ReportStatusFilter, List<String>>
_transactionStatusKeywords = {
@@ -105,6 +103,12 @@ class _ReportingPageState extends State<ReportingPage> {
_loadWarehouses();
}
@override
void dispose() {
_requesterController.dispose();
super.dispose();
}
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
Future<void> _loadWarehouses() async {
setState(() {
@@ -173,16 +177,12 @@ class _ReportingPageState extends State<ReportingPage> {
_lookupError = null;
});
try {
final transactionTypes = await repository.fetchTransactionTypes();
final transactionStatuses = await repository.fetchTransactionStatuses();
final approvalStatuses = await repository.fetchApprovalStatuses();
if (!mounted) {
return;
}
setState(() {
_transactionTypeLookup
..clear()
..addAll(_mapTransactionTypes(transactionTypes));
_transactionStatusLookup
..clear()
..addAll(
@@ -228,6 +228,9 @@ class _ReportingPageState extends State<ReportingPage> {
_pendingStatus = ReportStatusFilter.all;
_appliedWarehouse = WarehouseFilterOption.all;
_pendingWarehouse = WarehouseFilterOption.all;
_appliedRequester = null;
_pendingRequester = null;
_requesterController.clear();
});
}
@@ -238,6 +241,7 @@ class _ReportingPageState extends State<ReportingPage> {
_appliedType = _pendingType;
_appliedStatus = _pendingStatus;
_appliedWarehouse = _pendingWarehouse;
_appliedRequester = _pendingRequester;
});
}
@@ -249,7 +253,8 @@ class _ReportingPageState extends State<ReportingPage> {
return _appliedDateRange != null ||
_appliedType != ReportTypeFilter.all ||
_appliedStatus != ReportStatusFilter.all ||
_appliedWarehouse != WarehouseFilterOption.all;
_appliedWarehouse != WarehouseFilterOption.all ||
_appliedRequester != null;
}
bool get _hasAppliedFilters => _hasCustomFilters;
@@ -258,7 +263,8 @@ class _ReportingPageState extends State<ReportingPage> {
!_isSameRange(_pendingDateRange, _appliedDateRange) ||
_pendingType != _appliedType ||
_pendingStatus != _appliedStatus ||
_pendingWarehouse != _appliedWarehouse;
_pendingWarehouse != _appliedWarehouse ||
!_isSameRequester(_pendingRequester, _appliedRequester);
bool _isSameRange(DateTimeRange? a, DateTimeRange? b) {
if (identical(a, b)) {
@@ -270,6 +276,19 @@ class _ReportingPageState extends State<ReportingPage> {
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 target,
List<WarehouseFilterOption> options,
@@ -282,19 +301,6 @@ class _ReportingPageState extends State<ReportingPage> {
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(
List<LookupItem> items,
Map<ReportStatusFilter, List<String>> keywords,
@@ -326,25 +332,20 @@ class _ReportingPageState extends State<ReportingPage> {
return null;
}
int? _resolveTransactionTypeId() {
if (_appliedType == ReportTypeFilter.all ||
_appliedType == ReportTypeFilter.approval) {
return null;
}
final lookup = _transactionTypeLookup[_appliedType];
return lookup?.id;
}
int? _resolveStatusId() {
int? _resolveTransactionStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
if (_appliedType == ReportTypeFilter.approval) {
return _approvalStatusLookup[_appliedStatus]?.id;
}
return _transactionStatusLookup[_appliedStatus]?.id;
}
int? _resolveApprovalStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
return _approvalStatusLookup[_appliedStatus]?.id;
}
String _dateRangeLabel(DateTimeRange? range) {
if (range == null) {
return '기간 선택';
@@ -354,6 +355,13 @@ class _ReportingPageState extends State<ReportingPage> {
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 {
if (_isExporting) {
return;
@@ -376,9 +384,9 @@ class _ReportingPageState extends State<ReportingPage> {
from: range.start,
to: range.end,
format: format,
transactionTypeId: _resolveTransactionTypeId(),
statusId: _resolveStatusId(),
warehouseId: _appliedWarehouse.id,
transactionStatusId: _resolveTransactionStatusId(),
approvalStatusId: _resolveApprovalStatusId(),
requestedById: _appliedRequester?.id,
);
try {
final result = _appliedType == ReportTypeFilter.approval
@@ -394,7 +402,7 @@ class _ReportingPageState extends State<ReportingPage> {
if (result.hasDownloadUrl) {
SuperportToast.success(context, '다운로드 링크가 준비되었습니다.');
} else if (result.hasBytes) {
SuperportToast.success(context, '보고서 파일이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.');
await _saveBinaryResult(result, format);
} else {
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 {
try {
final opened = await launchUrl(url, mode: LaunchMode.externalApplication);
@@ -476,10 +529,7 @@ class _ReportingPageState extends State<ReportingPage> {
if (result.downloadUrl != null)
_SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()),
if (result.hasBytes && (result.downloadUrl == null))
const _SummaryRow(
label: '상태',
value: '바이너리 응답이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.',
),
const _SummaryRow(label: '상태', value: '바이너리 응답을 받아 자동 다운로드를 실행했습니다.'),
];
return Column(
@@ -530,9 +580,7 @@ class _ReportingPageState extends State<ReportingPage> {
child: const Text('XLSX 다운로드'),
),
ShadButton.outline(
onPressed: _canExport
? () => _handleExport(ReportExportFormat.pdf)
: null,
onPressed: _canExport ? _notifyPdfUnavailable : null,
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
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(
@@ -712,6 +794,10 @@ class _ReportingPageState extends State<ReportingPage> {
_SummaryRow(label: '유형', value: _appliedType.label),
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
_SummaryRow(label: '상태', value: _appliedStatus.label),
_SummaryRow(
label: '상신자',
value: _requesterLabel(_appliedRequester),
),
if (!_canExport)
Padding(
padding: const EdgeInsets.only(top: 12),

View File

@@ -698,13 +698,13 @@ packages:
source: hosted
version: "15.0.2"
web:
dependency: transitive
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "0.5.1"
webdriver:
dependency: transitive
description:

View File

@@ -46,6 +46,7 @@ dependencies:
flutter_dotenv: ^5.1.0
flutter_secure_storage: ^9.2.2
url_launcher: ^6.3.0
web: ^0.5.1
dev_dependencies:
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_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.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/features/inventory/inbound/presentation/pages/inbound_page.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() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -16,6 +20,14 @@ void main() {
await Environment.initialize();
});
setUp(() {
registerInventoryTestStubs();
});
tearDown(() async {
await GetIt.I.reset();
});
testWidgets('입고 필터 적용 및 초기화가 목록을 갱신한다', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
@@ -95,6 +107,27 @@ void main() {
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
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);
expect(productFields, findsWidgets);
@@ -105,7 +138,9 @@ void main() {
await tester.enterText(firstProductInput, 'XR-5000');
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();
final updatedProductFields = find.byType(InventoryProductAutocompleteField);
@@ -118,9 +153,47 @@ void main() {
await tester.enterText(secondProductInput, 'XR-5000');
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();
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);
});
Map<String, dynamic> customerResponse() {
return {
'data': {
'customers': [
{
'id': 301,
'customer': {
'id': 700,
'customer_code': 'C-1',
'customer_name': '슈퍼포트',
},
'note': '테스트',
},
],
},
};
}
test('addCustomers는 거래 ID를 포함해 POST 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/77/customers';
when(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: customerResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -63,7 +44,7 @@ void main() {
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
@@ -79,17 +60,16 @@ void main() {
test('updateCustomers는 PATCH 요청을 보낸다', () async {
const path = '/api/v1/stock-transactions/77/customers';
when(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: customerResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -98,7 +78,7 @@ void main() {
]);
verify(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),

View File

@@ -22,36 +22,19 @@ void main() {
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 {
const path = '/api/v1/stock-transactions/50/lines';
when(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: lineResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -66,7 +49,7 @@ void main() {
final payload =
verify(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
captureAny(),
data: captureAny(named: 'data'),
options: any(named: 'options'),
@@ -82,17 +65,16 @@ void main() {
test('updateLines는 PATCH 요청을 사용한다', () async {
const path = '/api/v1/stock-transactions/50/lines';
when(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: lineResponse(),
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
@@ -101,7 +83,7 @@ void main() {
]);
verify(
() => apiClient.patch<Map<String, dynamic>>(
() => apiClient.patch<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
@@ -141,31 +123,28 @@ void main() {
test('restoreLine은 복구 엔드포인트를 호출한다', () async {
const path = '/api/v1/transaction-lines/101/restore';
when(
() => apiClient.post<Map<String, dynamic>>(
() => apiClient.post<void>(
path,
data: any(named: 'data'),
options: any(named: 'options'),
cancelToken: any(named: 'cancelToken'),
),
).thenAnswer(
(_) async => Response<Map<String, dynamic>>(
data: {
'data': {
'id': 101,
'line_no': 1,
'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'},
'quantity': 3,
'unit_price': 1000,
},
},
(_) async => Response<void>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
statusCode: 204,
),
);
final line = await repository.restoreLine(101);
await repository.restoreLine(101);
expect(line.id, 101);
expect(line.lineNo, 1);
verify(
() => 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),
to: DateTime(2024, 1, 31),
format: ReportExportFormat.xlsx,
transactionTypeId: 3,
statusId: 1,
warehouseId: 9,
transactionStatusId: 3,
approvalStatusId: 7,
requestedById: 9,
);
final result = await repository.exportTransactions(request);
@@ -101,9 +101,9 @@ void main() {
expect(query['from'], request.from.toIso8601String());
expect(query['to'], request.to.toIso8601String());
expect(query['format'], 'xlsx');
expect(query['type_id'], 3);
expect(query['status_id'], 1);
expect(query['warehouse_id'], 9);
expect(query['transaction_status_id'], 3);
expect(query['approval_status_id'], 7);
expect(query['requested_by_id'], 9);
expect(result.downloadUrl.toString(), 'https://example.com/report.xlsx');
expect(result.filename, 'report.xlsx');
@@ -138,7 +138,7 @@ void main() {
from: DateTime(2024, 2, 1),
to: DateTime(2024, 2, 15),
format: ReportExportFormat.pdf,
statusId: 5,
approvalStatusId: 5,
);
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/repositories/reporting_repository.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';
void main() {

View File

@@ -349,27 +349,27 @@ class _StubTransactionLineRepository implements TransactionLineRepository {
}
@override
Future<List<StockTransactionLine>> addLines(
Future<void> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
) async {
return _linesFor(transactionId);
_linesFor(transactionId);
}
@override
Future<void> deleteLine(int lineId) async {}
@override
Future<List<StockTransactionLine>> updateLines(
Future<void> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
) async {
return _linesFor(transactionId);
_linesFor(transactionId);
}
@override
Future<StockTransactionLine> restoreLine(int lineId) async {
return _findLine(lineId);
Future<void> restoreLine(int lineId) async {
_findLine(lineId);
}
}
@@ -390,22 +390,22 @@ class _StubTransactionCustomerRepository
}
@override
Future<List<StockTransactionCustomer>> addCustomers(
Future<void> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
) async {
return _customersFor(transactionId);
_customersFor(transactionId);
}
@override
Future<void> deleteCustomer(int customerLinkId) async {}
@override
Future<List<StockTransactionCustomer>> updateCustomers(
Future<void> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
) async {
return _customersFor(transactionId);
_customersFor(transactionId);
}
}