From d5c99627db93c61f0a77d012db179536ac01a9ff Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 16 Oct 2025 14:57:07 +0900 Subject: [PATCH] =?UTF-8?q?API=20v4=20=EA=B3=84=EC=95=BD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=ED=95=98=EA=B3=A0=20=EB=B3=B4=EA=B3=A0=EC=84=9C=C2=B7?= =?UTF-8?q?=EC=9E=85=EC=B6=9C=EA=B3=A0=20=ED=99=94=EB=A9=B4=20=EC=8B=A4?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/backup/backend_change_requests.md | 86 ++- doc/frontend_api_alignment_plan.md | 15 + doc/stock_approval_system_api_v4.md | 552 +++++++++++++++++- lib/core/services/file_saver.dart | 15 + lib/core/services/file_saver_stub.dart | 10 + lib/core/services/file_saver_web.dart | 20 + .../approvals/data/dtos/approval_dto.dart | 11 +- .../approval_repository_remote.dart | 201 ++++++- .../approval_history_repository_remote.dart | 6 +- .../presentation/pages/inbound_page.dart | 94 ++- .../presentation/pages/outbound_page.dart | 103 ++++ .../presentation/pages/rental_page.dart | 129 ++++ .../widgets/employee_autocomplete_field.dart | 4 +- .../stock_transaction_repository_remote.dart | 5 + ...ransaction_customer_repository_remote.dart | 40 +- .../transaction_line_repository_remote.dart | 49 +- .../entities/stock_transaction_input.dart | 27 + .../stock_transaction_repository.dart | 10 +- .../login/presentation/pages/login_page.dart | 8 +- .../data/dtos/group_permission_dto.dart | 2 +- .../reporting_repository_remote.dart | 10 +- .../entities/report_export_request.dart | 16 +- .../presentation/pages/reporting_page.dart | 188 ++++-- pubspec.lock | 6 +- pubspec.yaml | 1 + .../data/approval_repository_remote_test.dart | 150 +++++ .../features/inventory/inbound_page_test.dart | 77 ++- .../inventory/outbound_page_test.dart | 63 ++ test/features/inventory/rental_page_test.dart | 63 ++ ...ction_customer_repository_remote_test.dart | 36 +- ...ansaction_line_repository_remote_test.dart | 61 +- .../reporting_repository_remote_test.dart | 14 +- .../reporting/reporting_page_test.dart | 2 - test/helpers/inventory_test_stubs.dart | 20 +- 34 files changed, 1767 insertions(+), 327 deletions(-) create mode 100644 lib/core/services/file_saver.dart create mode 100644 lib/core/services/file_saver_stub.dart create mode 100644 lib/core/services/file_saver_web.dart create mode 100644 test/features/approvals/data/approval_repository_remote_test.dart create mode 100644 test/features/inventory/outbound_page_test.dart create mode 100644 test/features/inventory/rental_page_test.dart diff --git a/doc/backup/backend_change_requests.md b/doc/backup/backend_change_requests.md index f3aad1a..b4f993c 100644 --- a/doc/backup/backend_change_requests.md +++ b/doc/backup/backend_change_requests.md @@ -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/` 패턴으로 목록/상세/생성/수정/삭제/복구 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 계약 검증 → 프론트엔드 실연동 착수. diff --git a/doc/frontend_api_alignment_plan.md b/doc/frontend_api_alignment_plan.md index f07fd1a..c1cdda0 100644 --- a/doc/frontend_api_alignment_plan.md +++ b/doc/frontend_api_alignment_plan.md @@ -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 문서에도 신규 체크리스트를 반영한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index 8131254..a9252d4 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -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/` 패턴을 따르며, 목록 필터·페이지네이션·`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 타임스탬프 범위 필터다. --- diff --git a/lib/core/services/file_saver.dart b/lib/core/services/file_saver.dart new file mode 100644 index 0000000..dded063 --- /dev/null +++ b/lib/core/services/file_saver.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +import 'file_saver_stub.dart' if (dart.library.html) 'file_saver_web.dart'; + +/// 바이트 데이터를 로컬 파일로 저장한다. +Future 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); +} diff --git a/lib/core/services/file_saver_stub.dart b/lib/core/services/file_saver_stub.dart new file mode 100644 index 0000000..80c9732 --- /dev/null +++ b/lib/core/services/file_saver_stub.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; + +/// 웹 외 플랫폼에서 파일 저장이 호출되면 예외를 발생시킨다. +Future saveFileBytesImpl({ + required Uint8List bytes, + required String filename, + required String mimeType, +}) async { + throw UnsupportedError('현재 플랫폼에서는 파일 저장을 지원하지 않습니다.'); +} diff --git a/lib/core/services/file_saver_web.dart b/lib/core/services/file_saver_web.dart new file mode 100644 index 0000000..9856edb --- /dev/null +++ b/lib/core/services/file_saver_web.dart @@ -0,0 +1,20 @@ +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +/// 웹 환경에서 Anchor 요소를 사용해 파일 저장을 트리거한다. +Future 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(); +} diff --git a/lib/features/approvals/data/dtos/approval_dto.dart b/lib/features/approvals/data/dtos/approval_dto.dart index 9ee00ce..be633d5 100644 --- a/lib/features/approvals/data/dtos/approval_dto.dart +++ b/lib/features/approvals/data/dtos/approval_dto.dart @@ -214,7 +214,9 @@ class ApprovalStepDto { (json['approver'] as Map? ?? const {}), ), status: ApprovalStatusDto.fromJson( - (json['status'] as Map? ?? const {}), + (json['status'] as Map? ?? + json['step_status'] as Map? ?? + 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? ?? const {}), + json['action'] is Map + ? json['action'] as Map + : { + 'id': json['approval_action_id'], + 'name': json['approval_action_name'], + }, ), fromStatus: json['from_status'] is Map ? ApprovalStatusDto.fromJson( diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index 92a2b64..c40e20b 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -175,22 +175,199 @@ class ApprovalRepositoryRemote implements ApprovalRepository { Map body, ) { final data = body['data']; - if (data is Map) { - if (data['approval'] is Map) { - return data['approval'] as Map; + final dataMap = data is Map ? data : null; + Map? approval = _selectApprovalPayload(dataMap); + approval ??= _selectApprovalPayload(body); + if (approval == null) { + return null; + } + + final merged = Map.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) { - return data['approval_data'] as Map; - } - 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) { - return body['approval'] as Map; + return merged; + } + + Map? _selectApprovalPayload(Map? source) { + if (source == null) { + return null; + } + if (source['approval'] is Map) { + return Map.from( + source['approval'] as Map, + ); + } + if (source['approval_data'] is Map) { + return Map.from( + source['approval_data'] as Map, + ); + } + final hasStatus = + source.containsKey('status') || source.containsKey('approval_status'); + if (source.containsKey('approval_no') && hasStatus) { + return Map.from(source); + } + if (source['approval'] == null && source['data'] is Map) { + return _selectApprovalPayload(source['data'] as Map); } return null; } + + List>? _mergeStepsPayload({ + required dynamic existing, + required Map data, + }) { + final steps = >[]; + + void upsert(Map 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.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) { + upsert(item); + } + } + } + + final responseSteps = data['steps']; + if (responseSteps is List) { + for (final item in responseSteps) { + if (item is Map) { + upsert(item); + } + } + } + + if (data['step'] is Map) { + upsert(data['step'] as Map); + } + if (data['next_step'] is Map) { + upsert(data['next_step'] as Map); + } + + if (steps.isEmpty) { + return existing is List + ? existing + .whereType>() + .map((step) => Map.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>? _mergeHistoriesPayload({ + required dynamic existing, + required Map data, + }) { + final histories = >[]; + + void append(Map history) { + histories.add(Map.from(history)); + } + + if (existing is List) { + for (final item in existing) { + if (item is Map) { + append(item); + } + } + } + + final responseHistories = data['histories']; + if (responseHistories is List) { + for (final item in responseHistories) { + if (item is Map) { + append(item); + } + } + } + + if (data['history'] is Map) { + append(data['history'] as Map); + } + + if (histories.isEmpty) { + return existing is List + ? existing + .whereType>() + .map((history) => Map.from(history)) + .toList() + : null; + } + + DateTime? parseTime(Map 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; + } } diff --git a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart index 10a01fa..aa5c988 100644 --- a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart +++ b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart @@ -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), ); diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 5d7a9e5..f09b800 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -1289,8 +1289,12 @@ class _InboundPageState extends State { ); 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 { }; String? writerError; + String? transactionNumberError; + String? approvalNumberError; String? warehouseError; String? statusError; String? headerNotice; @@ -1339,12 +1345,18 @@ class _InboundPageState extends State { 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 { 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 { .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 { 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 { 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 { ), ), ), + 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 { 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; diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 8aba167..143a3fb 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -1435,6 +1435,13 @@ class _OutboundPageState extends State { 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 { }; String? writerError; + String? transactionNumberError; + String? approvalNumberError; String? customerError; String? warehouseError; String? statusError; @@ -1477,6 +1486,10 @@ class _OutboundPageState extends State { 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 { ); 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 { 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 = []; @@ -1656,6 +1674,7 @@ class _OutboundPageState extends State { final created = await controller.createTransaction( StockTransactionCreateInput( + transactionNo: transactionNoValue, transactionTypeId: transactionTypeLookup.id, transactionStatusId: statusItem.id, warehouseId: warehouseId, @@ -1664,6 +1683,11 @@ class _OutboundPageState extends State { 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 { ), ), ), + 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 { ), ), ), + 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 { 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 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; diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 6ec704e..f657cdf 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -1411,6 +1411,13 @@ class _RentalPageState extends State { 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 { RentalRecord? result; String? writerError; + String? transactionNumberError; + String? approvalNumberError; String? customerError; String? warehouseError; String? statusError; @@ -1452,6 +1461,10 @@ class _RentalPageState extends State { 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 { ); 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 { 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 { 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 { 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 { ), ), ), + 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 { ), ), ), + 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 { 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 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; diff --git a/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart b/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart index 496643a..0fba14c 100644 --- a/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart +++ b/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart @@ -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 onSuggestionSelected; final VoidCallback? onChanged; final bool enabled; + final String placeholder; @override State 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(), ); diff --git a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart index 2558418..d7d43f2 100644 --- a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart +++ b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart @@ -84,6 +84,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { Future submit(int id) async { final response = await _api.post>( '$_basePath/$id/submit', + data: {'id': id}, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); @@ -93,6 +94,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { Future complete(int id) async { final response = await _api.post>( '$_basePath/$id/complete', + data: {'id': id}, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); @@ -102,6 +104,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { Future approve(int id) async { final response = await _api.post>( '$_basePath/$id/approve', + data: {'id': id}, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); @@ -111,6 +114,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { Future reject(int id) async { final response = await _api.post>( '$_basePath/$id/reject', + data: {'id': id}, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); @@ -120,6 +124,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { Future cancel(int id) async { final response = await _api.post>( '$_basePath/$id/cancel', + data: {'id': id}, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); diff --git a/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart index 3fb7902..3461994 100644 --- a/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart +++ b/lib/features/inventory/transactions/data/repositories/transaction_customer_repository_remote.dart @@ -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> addCustomers( + Future addCustomers( int transactionId, List customers, ) async { - final response = await _api.post>( + await _api.post( '$_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> updateCustomers( + Future updateCustomers( int transactionId, List customers, ) async { - final response = await _api.patch>( + await _api.patch( '$_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 deleteCustomer(int customerLinkId) async { await _api.delete('$_customerPath/$customerLinkId'); } - - List _parseCustomers(Map? 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 _extractData(Map? body) { - if (body == null) { - return {}; - } - if (body['data'] is Map) { - return body['data'] as Map; - } - return body; - } } diff --git a/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart index 17fb9a3..2c152c5 100644 --- a/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart +++ b/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart @@ -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> addLines( + Future addLines( int transactionId, List lines, ) async { - final response = await _api.post>( + await _api.post( '$_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> updateLines( + Future updateLines( int transactionId, List lines, ) async { - final response = await _api.patch>( + await _api.patch( '$_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 restoreLine(int lineId) async { - final response = await _api.post>( + Future restoreLine(int lineId) async { + await _api.post( '$_linePath/$lineId/restore', - options: Options(responseType: ResponseType.json), ); - final lines = _parseLines(response.data); - if (lines.isEmpty) { - throw StateError('복구된 라인 정보를 찾을 수 없습니다.'); - } - return lines.first; } - List _parseLines(Map? 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 _extractData(Map? body) { - if (body == null) { - return {}; - } - if (body['data'] is Map) { - return body['data'] as Map; - } - return body; - } } diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index eadc2f3..bd06b2b 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -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 lines; final List customers; + final StockTransactionApprovalInput? approval; Map 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 toJson() { + return { + 'approval_no': approvalNo, + if (approvalStatusId != null) 'approval_status_id': approvalStatusId, + 'requested_by_id': requestedById, + if (note != null && note!.trim().isNotEmpty) 'note': note, + }; + } +} diff --git a/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart b/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart index 66a757d..02022f3 100644 --- a/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart +++ b/lib/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart @@ -47,13 +47,13 @@ abstract class StockTransactionRepository { /// 재고 트랜잭션 라인 저장소 인터페이스. abstract class TransactionLineRepository { /// 라인을 추가한다. - Future> addLines( + Future addLines( int transactionId, List lines, ); /// 라인 정보를 일괄 수정한다. - Future> updateLines( + Future updateLines( int transactionId, List lines, ); @@ -62,19 +62,19 @@ abstract class TransactionLineRepository { Future deleteLine(int lineId); /// 삭제된 라인을 복구한다. - Future restoreLine(int lineId); + Future restoreLine(int lineId); } /// 재고 트랜잭션 고객 연결 저장소 인터페이스. abstract class TransactionCustomerRepository { /// 고객 연결을 추가한다. - Future> addCustomers( + Future addCustomers( int transactionId, List customers, ); /// 고객 연결 정보를 수정한다. - Future> updateCustomers( + Future updateCustomers( int transactionId, List customers, ); diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index e64a360..5747f5d 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -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 { 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; diff --git a/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart b/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart index 705808c..a40253b 100644 --- a/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart +++ b/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart @@ -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?, ); } diff --git a/lib/features/reporting/data/repositories/reporting_repository_remote.dart b/lib/features/reporting/data/repositories/reporting_repository_remote.dart index 2f52bad..d0158bc 100644 --- a/lib/features/reporting/data/repositories/reporting_repository_remote.dart +++ b/lib/features/reporting/data/repositories/reporting_repository_remote.dart @@ -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, }; } diff --git a/lib/features/reporting/domain/entities/report_export_request.dart b/lib/features/reporting/domain/entities/report_export_request.dart index 192d529..c155e82 100644 --- a/lib/features/reporting/domain/entities/report_export_request.dart +++ b/lib/features/reporting/domain/entities/report_export_request.dart @@ -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; } diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index 681b457..8ce44ef 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -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 { ReportingRepository? _reportingRepository; InventoryLookupRepository? _lookupRepository; final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd'); - final Map _transactionTypeLookup = {}; final Map _transactionStatusLookup = {}; final Map _approvalStatusLookup = {}; bool _isLoadingLookups = false; @@ -45,12 +46,9 @@ class _ReportingPageState extends State { String? _exportError; ReportDownloadResult? _lastResult; ReportExportFormat? _lastFormat; - - static const Map> _transactionTypeKeywords = { - ReportTypeFilter.inbound: ['입고', 'inbound'], - ReportTypeFilter.outbound: ['출고', 'outbound'], - ReportTypeFilter.rental: ['대여', 'rent', 'rental'], - }; + final TextEditingController _requesterController = TextEditingController(); + InventoryEmployeeSuggestion? _appliedRequester; + InventoryEmployeeSuggestion? _pendingRequester; static const Map> _transactionStatusKeywords = { @@ -105,6 +103,12 @@ class _ReportingPageState extends State { _loadWarehouses(); } + @override + void dispose() { + _requesterController.dispose(); + super.dispose(); + } + /// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다. Future _loadWarehouses() async { setState(() { @@ -173,16 +177,12 @@ class _ReportingPageState extends State { _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 { _pendingStatus = ReportStatusFilter.all; _appliedWarehouse = WarehouseFilterOption.all; _pendingWarehouse = WarehouseFilterOption.all; + _appliedRequester = null; + _pendingRequester = null; + _requesterController.clear(); }); } @@ -238,6 +241,7 @@ class _ReportingPageState extends State { _appliedType = _pendingType; _appliedStatus = _pendingStatus; _appliedWarehouse = _pendingWarehouse; + _appliedRequester = _pendingRequester; }); } @@ -249,7 +253,8 @@ class _ReportingPageState extends State { 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 { !_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 { 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 options, @@ -282,19 +301,6 @@ class _ReportingPageState extends State { return options.first; } - Map _mapTransactionTypes( - List items, - ) { - final result = {}; - for (final entry in _transactionTypeKeywords.entries) { - final matched = _matchLookup(items, entry.value); - if (matched != null) { - result[entry.key] = matched; - } - } - return result; - } - Map _mapStatusByKeyword( List items, Map> keywords, @@ -326,25 +332,20 @@ class _ReportingPageState extends State { 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 { String _formatDate(DateTime value) => _dateFormat.format(value); + String _requesterLabel(InventoryEmployeeSuggestion? suggestion) { + if (suggestion == null) { + return '전체 상신자'; + } + return '${suggestion.name} (${suggestion.employeeNo})'; + } + Future _handleExport(ReportExportFormat format) async { if (_isExporting) { return; @@ -376,9 +384,9 @@ class _ReportingPageState extends State { 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 { 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 { } } + Future _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 _launchDownloadUrl(Uri url) async { try { final opened = await launchUrl(url, mode: LaunchMode.externalApplication); @@ -476,10 +529,7 @@ class _ReportingPageState extends State { 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 { 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 { ], ), ), + 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 { _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), diff --git a/pubspec.lock b/pubspec.lock index 0cfb467..4ddf89f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 4fa4365..8b2504b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/features/approvals/data/approval_repository_remote_test.dart b/test/features/approvals/data/approval_repository_remote_test.dart new file mode 100644 index 0000000..134e3bf --- /dev/null +++ b/test/features/approvals/data/approval_repository_remote_test.dart @@ -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 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 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>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + 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); + }); +} diff --git a/test/features/inventory/inbound_page_test.dart b/test/features/inventory/inbound_page_test.dart index d9bb2e5..c682795 100644 --- a/test/features/inventory/inbound_page_test.dart +++ b/test/features/inventory/inbound_page_test.dart @@ -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); + }); } diff --git a/test/features/inventory/outbound_page_test.dart b/test/features/inventory/outbound_page_test.dart new file mode 100644 index 0000000..8750e55 --- /dev/null +++ b/test/features/inventory/outbound_page_test.dart @@ -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); + }); +} diff --git a/test/features/inventory/rental_page_test.dart b/test/features/inventory/rental_page_test.dart new file mode 100644 index 0000000..bf0ffff --- /dev/null +++ b/test/features/inventory/rental_page_test.dart @@ -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); + }); +} diff --git a/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart b/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart index 17f8b78..1b8f49e 100644 --- a/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart +++ b/test/features/inventory/transactions/data/transaction_customer_repository_remote_test.dart @@ -22,38 +22,19 @@ void main() { repository = TransactionCustomerRepositoryRemote(apiClient: apiClient); }); - Map 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>( + () => apiClient.post( path, data: any(named: 'data'), options: any(named: 'options'), cancelToken: any(named: 'cancelToken'), ), ).thenAnswer( - (_) async => Response>( - data: customerResponse(), + (_) async => Response( requestOptions: RequestOptions(path: path), - statusCode: 200, + statusCode: 204, ), ); @@ -63,7 +44,7 @@ void main() { final payload = verify( - () => apiClient.post>( + () => apiClient.post( 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>( + () => apiClient.patch( path, data: any(named: 'data'), options: any(named: 'options'), cancelToken: any(named: 'cancelToken'), ), ).thenAnswer( - (_) async => Response>( - data: customerResponse(), + (_) async => Response( requestOptions: RequestOptions(path: path), - statusCode: 200, + statusCode: 204, ), ); @@ -98,7 +78,7 @@ void main() { ]); verify( - () => apiClient.patch>( + () => apiClient.patch( path, data: any(named: 'data'), options: any(named: 'options'), diff --git a/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart b/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart index 7a2cbc3..c47ca31 100644 --- a/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart +++ b/test/features/inventory/transactions/data/transaction_line_repository_remote_test.dart @@ -22,36 +22,19 @@ void main() { repository = TransactionLineRepositoryRemote(apiClient: apiClient); }); - Map 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>( + () => apiClient.post( path, data: any(named: 'data'), options: any(named: 'options'), cancelToken: any(named: 'cancelToken'), ), ).thenAnswer( - (_) async => Response>( - data: lineResponse(), + (_) async => Response( requestOptions: RequestOptions(path: path), - statusCode: 200, + statusCode: 204, ), ); @@ -66,7 +49,7 @@ void main() { final payload = verify( - () => apiClient.post>( + () => apiClient.post( 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>( + () => apiClient.patch( path, data: any(named: 'data'), options: any(named: 'options'), cancelToken: any(named: 'cancelToken'), ), ).thenAnswer( - (_) async => Response>( - data: lineResponse(), + (_) async => Response( requestOptions: RequestOptions(path: path), - statusCode: 200, + statusCode: 204, ), ); @@ -101,7 +83,7 @@ void main() { ]); verify( - () => apiClient.patch>( + () => apiClient.patch( 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>( + () => apiClient.post( path, data: any(named: 'data'), options: any(named: 'options'), cancelToken: any(named: 'cancelToken'), ), ).thenAnswer( - (_) async => Response>( - data: { - 'data': { - 'id': 101, - 'line_no': 1, - 'product': {'id': 11, 'product_code': 'P-1', 'product_name': '품목'}, - 'quantity': 3, - 'unit_price': 1000, - }, - }, + (_) async => Response( 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( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).called(1); }); } diff --git a/test/features/reporting/data/reporting_repository_remote_test.dart b/test/features/reporting/data/reporting_repository_remote_test.dart index 9aae13d..b0e0f17 100644 --- a/test/features/reporting/data/reporting_repository_remote_test.dart +++ b/test/features/reporting/data/reporting_repository_remote_test.dart @@ -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); diff --git a/test/features/reporting/reporting_page_test.dart b/test/features/reporting/reporting_page_test.dart index dda06a0..9f42704 100644 --- a/test/features/reporting/reporting_page_test.dart +++ b/test/features/reporting/reporting_page_test.dart @@ -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() { diff --git a/test/helpers/inventory_test_stubs.dart b/test/helpers/inventory_test_stubs.dart index cfeed9a..4430c47 100644 --- a/test/helpers/inventory_test_stubs.dart +++ b/test/helpers/inventory_test_stubs.dart @@ -349,27 +349,27 @@ class _StubTransactionLineRepository implements TransactionLineRepository { } @override - Future> addLines( + Future addLines( int transactionId, List lines, ) async { - return _linesFor(transactionId); + _linesFor(transactionId); } @override Future deleteLine(int lineId) async {} @override - Future> updateLines( + Future updateLines( int transactionId, List lines, ) async { - return _linesFor(transactionId); + _linesFor(transactionId); } @override - Future restoreLine(int lineId) async { - return _findLine(lineId); + Future restoreLine(int lineId) async { + _findLine(lineId); } } @@ -390,22 +390,22 @@ class _StubTransactionCustomerRepository } @override - Future> addCustomers( + Future addCustomers( int transactionId, List customers, ) async { - return _customersFor(transactionId); + _customersFor(transactionId); } @override Future deleteCustomer(int customerLinkId) async {} @override - Future> updateCustomers( + Future updateCustomers( int transactionId, List customers, ) async { - return _customersFor(transactionId); + _customersFor(transactionId); } }