From 7e933a2ddaa013a3e4c30bf247fda235e7abcb03 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 23 Oct 2025 14:02:31 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC=20=EB=8C=80=EC=9D=91=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/DTO_TASKS.md | 56 +++++ doc/backup/backend_change_requests.md | 150 ++++++------ doc/frontend_auto_numbering_update.md | 34 +++ doc/stock_approval_system_api_v4.md | 43 ++-- doc/stock_approval_system_spec_v4.md | 3 + .../stock_transaction_state_flow_test.dart | 4 +- lib/core/network/api_client.dart | 34 +++ lib/core/network/api_error.dart | 30 ++- lib/core/routing/app_router.dart | 14 ++ .../approval_repository_remote.dart | 17 +- .../approval_template_repository_remote.dart | 11 +- .../approvals/domain/entities/approval.dart | 3 - .../pages/approval_history_page.dart | 2 +- .../presentation/pages/approval_page.dart | 54 ++--- .../approval_step_repository_remote.dart | 36 ++- .../pages/approval_step_page.dart | 2 +- .../pages/approval_template_page.dart | 2 +- .../repositories/auth_repository_remote.dart | 6 +- .../dashboard_repository_remote.dart | 5 +- .../presentation/pages/dashboard_page.dart | 64 ++++- .../presentation/models/inbound_record.dart | 9 + .../presentation/pages/inbound_page.dart | 190 +++++++-------- .../widgets/inbound_detail_view.dart | 9 + .../presentation/models/outbound_record.dart | 9 + .../presentation/pages/outbound_page.dart | 177 +++++++------- .../widgets/outbound_detail_view.dart | 9 + .../presentation/models/rental_record.dart | 9 + .../presentation/pages/rental_page.dart | 219 ++++++++---------- .../entities/stock_transaction_input.dart | 10 +- .../customer_repository_remote.dart | 12 +- .../presentation/pages/customer_page.dart | 2 +- .../repositories/group_repository_remote.dart | 9 +- .../group/presentation/pages/group_page.dart | 2 +- .../group_permission_repository_remote.dart | 11 +- .../pages/group_permission_page.dart | 4 +- .../repositories/menu_repository_remote.dart | 9 +- .../menu/presentation/pages/menu_page.dart | 2 +- .../product_repository_remote.dart | 9 +- .../presentation/pages/product_page.dart | 2 +- .../repositories/user_repository_remote.dart | 9 +- .../user/presentation/pages/user_page.dart | 2 +- .../vendor_repository_remote.dart | 9 +- .../presentation/pages/vendor_page.dart | 4 +- .../warehouse_repository_remote.dart | 9 +- .../presentation/pages/warehouse_page.dart | 2 +- lib/main.dart | 13 +- lib/widgets/app_shell.dart | 3 +- test/core/network/api_client_test.dart | 57 +++++ test/core/network/api_error_test.dart | 56 +++++ .../data/approval_repository_remote_test.dart | 20 +- .../features/inventory/inbound_page_test.dart | 29 ++- .../inventory/outbound_page_test.dart | 10 +- test/features/inventory/rental_page_test.dart | 10 +- ...ck_transaction_repository_remote_test.dart | 11 + .../data/customer_repository_remote_test.dart | 17 ++ 55 files changed, 948 insertions(+), 586 deletions(-) create mode 100644 doc/DTO_TASKS.md create mode 100644 doc/frontend_auto_numbering_update.md diff --git a/doc/DTO_TASKS.md b/doc/DTO_TASKS.md new file mode 100644 index 0000000..9f9065b --- /dev/null +++ b/doc/DTO_TASKS.md @@ -0,0 +1,56 @@ +# DTO 작업 현황 + +## 1. 백엔드 협업 로그인·리프레시·대시보드 정비 +### 진행된 작업 +- 로그인·대시보드·보고서·결재 스키마 요구사항을 `doc/backup/backend_change_requests.md:1-79`에 재정리해 백엔드와 공유 가능한 단일 요청서로 정리했다. +### 남은 작업 +- 백엔드 담당자와 개발 일정·샌드박스 검증 순서를 확정하고 문서에 일정표/담당자를 추가해야 한다. +- 로그인·대시보드 외 보고서/권한/단계 API의 문서화 내용이 구현으로 연결되는지 주간 점검 미팅을 마련한다. + +## 2. API 계약 정비 (route_path, include_deleted, 표준 응답/에러 구조) +### 진행된 작업 +- 401 응답 메시지를 UI 안내 문구로 변환하도록 `lib/core/network/api_error.dart:90-139`을 보강했고, 대응 단위 테스트를 `test/core/network/api_error_test.dart:84-138`에 추가했다. +- 그룹 권한 목록에 `include_deleted` 파라미터와 `include=group,menu`를 반영하고(`lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart:21-43`), UI에서 `menu.path`를 노출하도록 테이블 컬럼을 확장했다(`lib/features/masters/group_permission/presentation/pages/group_permission_page.dart:855-888`). +- 결재 단계 원격 저장소가 `include=approval,approver,status`를 강제하도록 수정해 v4 스키마와 맞췄다(`lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart:30-44`). +- KPI delta/증감 아이콘을 대시보드 카드에 노출해 백엔드의 delta 필드를 활용할 준비를 마쳤다(`lib/features/dashboard/presentation/pages/dashboard_page.dart:258-326`). +### 남은 작업 +- ApiClient 계층에서 `{ "data": ... }` 언랩 처리를 공통화하거나, 미적용 저장소에서 동일 패턴을 반복하는 부분을 리팩터링해야 한다. +- `include_deleted`·`route_path`가 필요한 다른 리포지토리(예: 메뉴/사용자/보고서)가 기존 파라미터를 사용 중인지 재점검 후 일괄 적용이 필요하다. +- 백엔드에서 통일된 에러 코드/세부 구조를 확정하면 문서와 코드 주석을 업데이트한다. + +## 3. 번호 자동 부여 대응 +### 진행된 작업 +- 재고 트랜잭션/결재 생성 DTO에서 `transaction_no`·`approval_no`를 제거하고 요청 payload를 정리했다(`lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart:1-229`, `lib/features/approvals/domain/entities/approval.dart:200-224`). +- 입고/출고/대여/결재 생성 UI에서 번호 입력 필드를 텍스트 안내로 교체하고, 생성/수정 토스트에 서버가 할당한 번호를 표기하도록 수정했다(`lib/features/inventory/inbound/presentation/pages/inbound_page.dart:1711-1725`, `lib/features/inventory/outbound/presentation/pages/outbound_page.dart:1911-1917`, `lib/features/inventory/rental/presentation/pages/rental_page.dart:1909-1917`, `lib/features/approvals/presentation/pages/approval_page.dart:485-684`). +- 저장소/위젯/통합 테스트를 새 규칙에 맞춰 정비했다(`test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart:160-199`, `test/features/inventory/inbound_page_test.dart:110-207`, `test/features/inventory/outbound_page_test.dart:1-100`, `test/features/inventory/rental_page_test.dart:1-100`, `integration_test/stock_transaction_state_flow_test.dart:184-238`). +- 번호 정책과 작업 절차를 `doc/frontend_auto_numbering_update.md`와 `doc/stock_approval_system_api_v4.md`에 반영했다. +### 남은 작업 +- 생성 직후 알림/딥링크/내부 공유 링크에 새 번호를 주입하는 흐름(예: Slack·메일 템플릿, 상세 페이지 자동 이동)을 확인하고 필요한 리팩터링을 진행한다. +- QA 체크리스트(`doc/frontend_auto_numbering_update.md:23-28`)의 미완료 항목을 실제 시나리오 테스트로 채우고 결과를 문서화한다. + +## 4. 인증·대시보드 연동 +### 진행된 작업 +- 앱 시작 시 저장된 리프레시 토큰으로 세션을 갱신하고(`lib/main.dart:15-20`), 라우터에서 비로그인 사용자를 로그인 화면으로 리다이렉트하도록 가드 로직을 추가했다(`lib/core/routing/app_router.dart:37-49`). +- 백엔드 401 메시지를 UI 알림과 동기화하기 위해 에러 매퍼와 테스트를 보강했다(동일 경로 참조). +- 대시보드 카드가 delta 데이터와 아이콘을 표시할 수 있도록 뷰를 업데이트하고(`lib/features/dashboard/presentation/pages/dashboard_page.dart:258-326`), 저장소가 `/dashboard/summary` 응답의 `data` 래퍼를 처리하게 했다(`lib/features/dashboard/data/repositories/dashboard_repository_remote.dart:17-31`). +### 남은 작업 +- `AuthService.refreshSession()` 실패 시 초기 부트가 중단되지 않도록 예외 처리/로그아웃 플로우를 보완해야 한다. +- 대시보드 요약·로그인 요청에 대한 실패 로그/토스트 정책을 정의하고 컨트롤러에 적용한다. +- 백엔드에서 권한 목록/템플릿 포함 응답을 완료하면 PermissionSynchronizer와의 연동 테스트를 추가한다. + +## 5. 결재·재고 화면 보강 +### 진행된 작업 +- 창고 코드/우편번호/주소 등 중첩 객체를 상세 뷰에서 표시하도록 입고·출고·대여 레코드와 위젯을 확장했다(`lib/features/inventory/inbound/presentation/models/inbound_record.dart:31-60`, `lib/features/inventory/outbound/presentation/models/outbound_record.dart:31-60`, `lib/features/inventory/rental/presentation/models/rental_record.dart:33-65` 및 각 상세 위젯). +- 결재 생성 다이얼로그와 재고 모달이 서버 응답을 기반으로 성공 메시지를 출력하고 최신 번호를 다시 표시하도록 수정했다(`lib/features/approvals/presentation/pages/approval_page.dart:485-684`, `lib/features/inventory/inbound/presentation/pages/inbound_page.dart:1520-1566`, `lib/features/inventory/outbound/presentation/pages/outbound_page.dart:1706-1762`, `lib/features/inventory/rental/presentation/pages/rental_page.dart:1684-1742`). +### 남은 작업 +- 결재 단계/상태 전환 후 `ApprovalController`와 재고 컨트롤러가 API에서 받은 최신 객체를 즉시 바인딩하는지 확인하고, 필요한 경우 fetch 로직을 후속 호출로 보강한다. +- 재고 상세 다이얼로그(`lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart`)에서 새 번호·창고 상세를 활용하는지 검증하고 누락된 필드를 추가한다. +- 그룹 권한/재고 화면 필터에 `include_deleted`·`route_path` 확장 옵션을 노출할 UI 개선이 남아 있다. + +## 6. 테스트·문서 검증 +### 진행된 작업 +- 번호 자동 부여와 에러 매핑 관련 단위/위젯/통합 테스트를 업데이트했고, 신규 검증 시나리오를 문서로 가이드했다(상기 테스트 파일 및 `doc/frontend_auto_numbering_update.md`). +- `doc/stock_approval_system_spec_v4.md:389-507`에 자동 번호 규칙을 명시해 백엔드와의 참조 문서를 최신화했다. +### 남은 작업 +- 보고서 Export, 대시보드 요약, 인증 토큰 시나리오에 대한 단위/위젯 테스트를 추가해 회귀 범위를 확대한다. +- QA 시나리오(번호 증가·Export 성공·대시보드 데이터 확인)를 `doc/IMPLEMENTATION_TASKS.md`나 별도 체크리스트에 반영하고, 실행 결과를 주기적으로 기록해야 한다. diff --git a/doc/backup/backend_change_requests.md b/doc/backup/backend_change_requests.md index 8ebbdd3..6bb39cc 100644 --- a/doc/backup/backend_change_requests.md +++ b/doc/backup/backend_change_requests.md @@ -1,97 +1,79 @@ -# 백엔드 수정 요청서 (2025-10-20 갱신) +# 백엔드 수정 요청서 (2025-10-16 갱신) ## 1. 배경 -- 프런트엔드는 `.env.development`에서 `API_BASE_URL=http://43.201.34.104:8080`을 사용해 실서버 로그인 API를 호출한다. -- 동일 API를 사용하는 운영 환경에서는 CORS 문제가 없지만, 로컬 웹 개발 시 브라우저가 `http://localhost:` 오리진으로 사전 요청(preflight)을 보내면서 403 대신 CORS 차단이 발생한다. -- 프런트(`superport_v2`)와 백엔드(`superport_api_v2`) 양쪽 구현을 재검토한 결과, 로그인 계약은 일치하나 실서버에서 CORS 응답 헤더가 전혀 내려오지 않는 것으로 확인되었다. -- 로컬 개발 및 QA가 모두 실서버를 바라보고 있어, 백엔드에서 CORS 허용 정책을 명시적으로 정비해야 한다. +- Flutter 프런트엔드(`superport_v2`)와 최신 백엔드(`superport_api_v2`) 사이 계약을 점검한 결과, 다수의 엔드포인트가 미구현이거나 응답 스키마가 상이해 실사용 플로우를 마무리할 수 없다. +- 프런트는 Clean Architecture 구조 및 DTO를 백엔드 스펙(v4)에 맞춰 구현한 상태이며, 실연동 전까지 계약 정합성을 확보해야 한다. +- 본 문서는 백엔드 측 추가 개발/수정을 요청하기 위한 정리 문서이다. -## 2. 현상 및 재현 절차 -- 브라우저 콘솔 오류: - ``` - Access to XMLHttpRequest at 'http://43.201.34.104:8080/api/v1/auth/login' from origin 'http://localhost:50408' - has been blocked by CORS policy: Response to preflight request doesn't pass access control check: - No 'Access-Control-Allow-Origin' header is present on the requested resource. - ``` -- curl 재현(사전 요청): +## 2. 주요 이슈 요약 +- 로그인 및 대시보드 핵심 엔드포인트(`/api/v1/auth/**`, `/api/v1/dashboard/summary`)가 존재하지 않아 애플리케이션 초기 진입이 불가능하다. +- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 미구현 상태다. +- 결재·재고 API 응답 키가 프런트 DTO와 불일치하여 승인 상태, 요청자, 제품/벤더 정보 등이 전부 기본값으로 표시되며, 단계/상태 전환 이후 최신 데이터를 확보할 수 없다. +- 결재 단계(`approval-steps`) API가 단계 CRUD/액션 수행 후 적절한 본문을 반환하지 않고, 목록 필터(승인자·상태·검색)도 지원하지 않는다. +- 그룹-메뉴 권한 API가 라우팅 정보를 제공하지 않고, 삭제 항목 조회 파라미터가 프런트와 불일치해 권한 동기화가 깨진다. - ```bash - curl -i -X OPTIONS \ - http://43.201.34.104:8080/api/v1/auth/login \ - -H 'Origin: http://localhost:50408' \ - -H 'Access-Control-Request-Method: POST' - ``` +## 3. 상세 요청 - 실제 응답: `HTTP/1.1 404 Not Found` + 헤더 없음 → CORS 미적용. +### 3.1 로그인/세션 및 대시보드 API 구현 +- 엔드포인트 + - `POST /api/v1/auth/login`: `identifier`, `password`, `remember_me`(bool) 입력을 받아 `{ "data": { "access_token", "refresh_token", "expires_at", "user", "permissions" } }` 구조를 반환해야 한다. `user` 객체는 `{ id, name, employee_no, email, primary_group { id, name } }` 필드를 포함하고, `permissions`는 `resource`와 `actions[]`(소문자 문자열)로 구성된다. + - `POST /api/v1/auth/refresh`: `refresh_token`으로 세션을 갱신하며 응답 스키마는 로그인과 동일하다. + - `GET /api/v1/dashboard/summary`: `{ "data": { "generated_at", "kpis": [], "recent_transactions": [], "pending_approvals": [] } }` 형태로 내려 KPI 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 한다. +- 요구 사항 + - `kpis[]` 항목은 `{ key, label, value, trend_label, delta }` 필드를 제공해 프런트 차트 증감률을 계산할 수 있도록 한다. + - `recent_transactions[]`는 `{ transaction_no, transaction_date, transaction_type, status_name, created_by }` 문자열 필드로 구성한다. + - `pending_approvals[]`는 `{ approval_no, title, step_summary, requested_at }`을 포함하며 `requested_at`은 ISO8601 UTC 문자열로 반환한다. + - 로그인 실패 시 `invalid credentials`, 비활성 계정 접근 시 `account is inactive`, 갱신 토큰 만료는 `token expired`, 재사용·서명 오류는 `invalid token` 메시지를 반환해 프런트 알림 문구와 동일하게 맞춘다. + - 인증 실패(401), 세션 만료·권한 거부(403) 시 `{ "error": { "code": , "message": "...", "details": [...] } }` 규격을 사용하고, 만료/재사용 토큰별 메시지를 문서화한다. -- 실제 요청도 동일하게 헤더가 비어 있음: +### 3.2 보고서 Export API 구현 +- 엔드포인트 + - `GET /api/v1/reports/transactions/export` + - `GET /api/v1/reports/approvals/export` +- 요구 사항 + - 공통 쿼리: `from`, `to`, `format(xlsx|pdf)`, `transaction_status_id`, `approval_status_id`, `requested_by_id`. + - 트랜잭션 보고서 `from`·`to` 값은 `yyyy-MM-dd` 형식, 결재 보고서는 ISO8601 UTC 타임스탬프를 지원한다. + - 응답은 기본적으로 파일 스트림(`Content-Type`은 선택한 포맷의 MIME, `Content-Disposition: attachment; filename=""`)이며, 스토리지 연계 시 `{ "data": { "download_url", "filename", "mime_type", "expires_at" } }` 메타데이터로 대체할 수 있다. + - `format=pdf` 요청도 정상 처리하고, 지원 불가 시 명확한 4xx 코드·메시지를 반환하도록 문서화한다. + - 모든 다운로드 요청에 대해 접근 권한·감사 로그 정책을 명시한다. - ```bash - curl -i -X POST \ - http://43.201.34.104:8080/api/v1/auth/login \ - -H 'Origin: http://localhost:50408' \ - -H 'Content-Type: application/json' \ - -d '{"identifier":"test","password":"test"}' - ``` +### 3.3 결재/재고 응답 스키마 정합성 +- 결재 목록·단건 응답은 프런트 도메인 모델과 동일한 키를 사용한다. + - 단건 응답은 `{ "data": { ... } }` 혹은 `{ "data": { "approval": { ... } } }` 구조를 유지하고, `approval_no`, `transaction_no`, `status { id, name, color }`, `requester { id, employee_no, name }`, `current_step { id, step_order, status { id, name, is_blocking_next, is_terminal }, approver { id, employee_no, name }, assigned_at, decided_at, note }`, `steps[]`, `histories[]`, `created_at`, `updated_at`을 포함한다. + - 모든 단계·이력 항목은 `status` 키로 정규화하고(`step_status` 금지), `histories[]`에는 `action { id, name }`, `from_status`, `to_status`, `approver`, `action_at`, `note`를 내려준다. + - `approval` 객체에는 필요 시 `transaction { id, transaction_no }`, `template_name` 등을 함께 포함해 단계 목록에서도 동일 데이터를 재사용할 수 있도록 한다. +- 재고 트랜잭션 응답은 중첩 객체 구조를 보장한다. + - 헤더: `transaction_type { id, name }`, `transaction_status { id, name }`, `warehouse { id, warehouse_code, warehouse_name, zipcode { ... } }`, `created_by { id, employee_no, employee_name }`, `expected_return_date`. + - 라인: `lines[].product { id, product_code, product_name, vendor { id, vendor_name }, uom { id, uom_name } }`, `quantity`, `unit_price`, `note`. + - 고객: `customers[].customer { id, customer_code, customer_name }`, `note`. + - 결재 요약: `approval { id, approval_no, status { id, name, is_blocking_next } }`. + - `quantity`, `unit_price`는 BigDecimal 직렬화 그대로 전달하되 `null`은 키를 생략하지 말 것(프런트 DTO가 숫자·문자열 모두를 파싱한다). + - `warehouse.zipcode`는 최소 `zipcode`, `road_name`을 포함하고 추가 주소 필드가 있으면 그대로 노출한다. +- 상태 전환(Submit/Approve/Reject/Cancel/Complete) 응답은 최신 `data.transaction` 전체 또는 최소한 `data.transaction_status`, `data.updated_at`, `data.approval`을 포함해 UI가 즉시 갱신되도록 한다. - 응답: `401 Unauthorized` 본문은 내려오지만 `Access-Control-Allow-Origin` 헤더가 없음. +### 3.4 결재 단계/행위 API 정합성 +- `GET /api/v1/approval-steps`는 `approver_id`, `approval_id`, `status_id`(또는 `step_status_id`), `q`(결재번호·승인자 키워드)를 지원하고, 항상 `include=approval,approver,status` 형태의 확장을 처리한다. 응답 항목에는 `approval { id, approval_no, transaction_no, template_name }`이 포함되어야 한다. +- 단계 생성·수정·복구 응답은 `{ "data": { ... } }` 형태로 단계 요약을 반환하고, 단계 행위·일괄 배정 응답은 최신 결재 데이터를 `data.approval` 또는 `data.approval.steps`/`data.histories`에 담아 돌려준다. +- 모든 단계·행위 응답에서 단계 상태 키는 `status`로 통일하고, `step_status_id`는 요청/응답에서 보조 필드로만 유지한다. -## 3. 프런트/백엔드 로그인 계약 점검 -- **프런트 요청 구조** - - 경로: `POST ${ApiRoutes.apiV1}/auth/login` → `/api/v1/auth/login` - - 페이로드: `{ identifier, password, remember_me }` (`lib/features/auth/data/repositories/auth_repository_remote.dart:18`) - - 응답 파싱: `{ data: { access_token, refresh_token, expires_at, user, permissions[] } }` - (`lib/features/auth/data/dtos/auth_session_dto.dart`) -- **백엔드 구현** - - 라우트: `#[post("/login")]` (`backend/src/api/v1/auth.rs:17`) → `web::scope("/api/v1")` - - 요청 모델: `LoginRequest { identifier: String, password: String, remember_me: bool }` - (`backend/src/domain/auth.rs:9`) - - 응답 모델: `AuthSessionResponse { data: AuthSessionData { ... } }` -- **계약 비교 결론** - - 필드 명/자료형 모두 일치, remember_me 기본값 및 데이터 매핑도 호환. - - 로그인 자체는 401/403 흐름이 정상이나, 브라우저 오리진이 차단되어 요청이 전달되지 못함. +### 3.5 그룹-메뉴 권한 응답 확장 +- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`(가능하면 `path` 보조 필드 포함)를 항상 채운다. +- `deleted=true`(또는 `include_deleted=true`) 파라미터를 허용해 소프트 삭제 항목을 조회할 수 있게 하고, 응답 항목에 `is_deleted`를 노출한다. +- `include=group,menu` 확장을 공식화해 그룹/메뉴 요약을 한 번에 받을 수 있도록 한다. -## 4. 근본 원인 분석 -- 백엔드 `App::new()` 정의는 `build_cors(&config.cors)` 미들웨어를 `wrap`하고 - `default_service`에서 `OPTIONS` 가드를 204로 처리하도록 구현되어 있음 (`backend/src/app/mod.rs:36-132`). -- `config/default.toml`의 `[cors]` 설정은 `allowed_origins = []`로 전체 허용이 기본이지만, - 실제 운영 환경에서는 `APP_ENV`에 대응하는 설정 또는 환경 변수로 제한된 오리진만 등록한 것으로 추정된다. -- 하지만 허용 목록에서 로컬 호스트가 빠진 경우라면 CORS 미들웨어가 `403`을 반환해야 하는데, - 현재는 단순 404/401 응답으로 보아 **미들웨어가 동작하지 않거나, 리버스 프록시/로드밸런서 구간에서 CORS 헤더가 삭제**되고 있다. -- 결과적으로 브라우저는 `Access-Control-Allow-Origin`을 받지 못하고 사전 요청 단계에서 차단된다. +### 3.6 결재 생성/수정 응답 보강 +- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답은 `{ "data": { "approval": { ... } } }` 형태로 최신 결재 요약과 `steps[]`, 필요 시 `histories[]`를 포함해야 한다. +- `approval_status_id`가 생략되면 자동으로 기본 대기 상태를 설정하는 규칙을 명시하고, `approval_no`는 서버가 자동 발급(포맷 `APP-YYYYMMDDNNNN`)함을 문서화한다. -## 5. 요청 사항 -1. **백엔드 Actix CORS 설정 재점검** - - `build_cors`가 실제 배포 바이너리에도 적용되는지 확인하고, 필요한 경우 `Cors::default()` 대신 - `Cors::permissive()` 또는 `allowed_origin_fn` 로깅을 추가해 런타임에서 허용 여부를 추적한다. - - `supports_credentials()`를 유지하면서도 최소 `http://localhost` 기반 개발 포트 전체를 허용하도록 - `allowed_origin_fn`에서 와일드카드 검사를 추가하거나, 설정 파일에 와일드카드 표기를 허용하도록 개선한다. - ```rust - .allowed_origin_fn(move |origin, _| { - if allow_all { - return true; - } - if origin.as_bytes().starts_with(b"http://localhost") { - return true; - } - allowed_list.iter().any(|allowed| allowed == origin) - }) - ``` - - 운영 배포용 설정(`APP_ENV=production`)에도 `cors.allowed_origins`에 - `https://{prod-domain}` + `http://localhost` (또는 사내 VPN 도메인)을 명시한다. -2. **리버스 프록시/로드밸런서 검증** - - Nginx/ALB 등 중간 계층이 `OPTIONS` 메서드를 백엔드로 전달하는지 확인하고, 차단 시 `proxy_set_header Access-Control-Allow-Origin` 등을 설정한다. - - 모든 사전 요청이 최소 `204` 혹은 `200`과 함께 `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`를 반환하도록 보장한다. -3. **로그인 핸들러 응답 헤더 확인** - - 인증 성공/실패 여부와 관계없이 `Access-Control-Allow-Origin`이 반드시 포함되도록 통합 테스트를 추가한다. - - 예시: `cargo test cors_allows_login_origin` 형태의 통합 테스트에서 `Origin: http://localhost:50408` 헤더를 넣고 응답 헤더를 검증. +### 3.7 응답/에러 문서화 및 테스트 +- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 모두 반영하고, 인증/대시보드/결재 단계/보고서 섹션을 최신 상태로 유지한다. +- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가한다. -## 6. 검증 및 수용 기준 -- `curl -X OPTIONS` 및 `curl -X POST` 재현 시 `Access-Control-Allow-Origin: http://localhost:50408` 헤더가 내려오고 브라우저 CORS 에러가 사라질 것. -- `flutter run -d chrome --web-port 50408`에서 로그인 성공/실패 흐름이 정상 동작. -- 백엔드 `cargo fmt`, `cargo test` 모두 통과. -- `stock_approval_system_api_v4.md` 또는 운영 문서에 허용 오리진 정책 및 설정 방법을 명시. +## 4. 수용 기준 +- 상기 엔드포인트 및 스키마 변경이 구현되고, 요청/응답이 문서와 일치해야 한다. +- 기존 204 응답은 JSON 응답으로 교체되고, 키(`data.approval`, `data.transaction` 등)가 프런트 기대와 동일해야 한다. +- `cargo fmt`, `cargo check`, `cargo test` 및 CI 파이프라인이 통과한다. -## 7. 후속 조치 -- 백엔드 담당자가 실제 배포 서버의 환경 변수/리버스 프록시 설정을 확인 후 조치 내용을 공유. -- 수정 배포 이후 프런트 팀이 실서버 연결 테스트를 수행하고, 필요한 경우 추가 허용 오리진 목록을 요청. +## 5. 후속 조치 +- 백엔드 담당자가 개발 일정·우선순위를 산출해 프런트 팀과 공유. +- 구현 완료 후 샌드박스 환경에서 계약 검증 → 프런트엔드 실연동 검증 착수. diff --git a/doc/frontend_auto_numbering_update.md b/doc/frontend_auto_numbering_update.md new file mode 100644 index 0000000..a10c17b --- /dev/null +++ b/doc/frontend_auto_numbering_update.md @@ -0,0 +1,34 @@ +# 입출고·결재 번호 자동 부여 대응 가이드 + +프런트엔드 변경 시 유의해야 할 내용을 정리했습니다. 모든 일정은 백엔드 배포(문서 버전 v4) 이후 적용을 권장합니다. + +## 주요 변경 요약 +- 서버가 `transaction_no`와 `approval_no`를 자동 생성합니다. 포맷은 `TRX-YYYYMMDDNNNN`, `APP-YYYYMMDDNNNN`이며 일자별 4자리 시퀀스를 사용합니다. +- 생성 요청 본문에서 두 필드를 제거해야 합니다. 백엔드가 값을 무시하므로 전송 시 불필요한 필드 오류가 날 수 있습니다. +- 생성 응답(`POST /stock-transactions`, `POST /approvals`)에 포함된 번호를 UI에 표기하거나 후속 액션에 사용해야 합니다. + +## 작업 항목 +1. **트랜잭션 생성 화면** + - 번호 입력 필드 제거 및 레이아웃 정리. + - 생성 직후 응답(`data.transaction_no`)을 받아 상세 화면/알림에 표기. +2. **결재 생성/상신 화면** + - `approval.approval_no` 필드 제거. + - 응답(`data.approval.approval_no`)을 활용해 결재 상세 링크/알림 업데이트. +3. **API 클라이언트 수정** + - 공유 DTO/타입스크립트 인터페이스에서 `transaction_no`, `approval_no`를 삭제. + - E2E/단위 테스트에서 하드코딩된 번호 값 삭제 및 응답 값 기반 검증으로 변경. +4. **리스트/검색 기능** + - 표시 포맷이 바뀌었는지 확인하고 필요 시 날짜·시퀀스 분리 표시 적용. + +## 검증 체크리스트 +- [ ] 트랜잭션 생성 요청 payload에 `transaction_no`가 포함되지 않는다. +- [ ] 결재 생성 요청 payload에 `approval_no`가 포함되지 않는다. +- [ ] 생성 이후 상세 페이지/알림에 새 번호가 반영된다. +- [ ] 기존 북마크/딥링크가 새 번호 포맷(`-` 포함 13자리)과 호환되는지 확인한다. +- [ ] QA 환경에서 동일 일자 다건 생성 시 번호가 0001, 0002… 순으로 증가하는지 확인한다. + +## 참고 문서 +- `stock_approval_system_api_v4.md` 4.1, 5.1 섹션(요청 본문 변경 사항) +- `stock_approval_system_spec_v4.md` 3.14, 3.19 테이블(번호 관리 규칙) + +문의 사항은 #inventory-backend 채널로 공유 바랍니다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index 3cf4f14..64954b3 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -414,7 +414,6 @@ `POST /stock-transactions` ```json { - "transaction_no": "TXN-2025-0001", "transaction_type_id": 1, "transaction_status_id": 1, "warehouse_id": 1, @@ -437,13 +436,14 @@ ], "customers": [], "approval": { - "approval_no": "APP-2025-0001", "requested_by_id": 7, "note": "입고 결재" } } ``` -응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. `approval` +응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. `transaction_no` +및 `approval.approval_no`는 요청 시 생략하며, 서버가 각각 `TRX-YYYYMMDDNNNN`, +`APP-YYYYMMDDNNNN` 패턴으로 생성한 값을 응답에서 확인한다. `approval` 블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다. ### 4.2 목록 조회 @@ -455,7 +455,7 @@ "items": [ { "id": 9001, - "transaction_no": "TXN-2025-0001", + "transaction_no": "TRX-202511100001", "transaction_type": { "id": 1, "name": "입고" @@ -519,7 +519,7 @@ ], "approval": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "status": { "id": 1, "name": "대기", @@ -571,7 +571,7 @@ { "data": { "id": 9001, - "transaction_no": "TXN-2025-0001", + "transaction_no": "TRX-202511100001", "transaction_type": { "id": 1, "name": "입고" @@ -636,7 +636,7 @@ ], "approval": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "status": { "id": 1, "name": "대기", @@ -861,7 +861,6 @@ ```json { "transaction_id": 9001, - "approval_no": "APP-2025-0001", "approval_status_id": 1, "requested_by_id": 7, "note": "입고 결재" @@ -873,7 +872,7 @@ "data": { "approval": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "status": { "id": 1, "name": "대기", @@ -896,7 +895,7 @@ } } ``` -- `approval_no`는 활성 결재 기준으로 중복 불가하며(409 Conflict), 길이는 1~30자다. +- `approval_no`는 서버가 자동 발급하는 읽기 전용 필드로 `APP-YYYYMMDDNNNN` 형식을 따른다. 클라이언트는 필드를 전송하지 않으며, 중복 방지는 서버에서 처리된다. - 최초 생성 시 `approval_status_id`에는 `대기` 상태 ID를 전달하고, 서버는 동일 상태로 저장한다. - 단계나 이력이 존재하면 `data.approval.steps`, `data.approval.histories`가 함께 반환된다. @@ -907,10 +906,10 @@ "items": [ { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "transaction": { "id": 9001, - "transaction_no": "TXN-2025-0001" + "transaction_no": "TRX-202511100001" }, "status": { "id": 1, @@ -1006,10 +1005,10 @@ { "data": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "transaction": { "id": 9001, - "transaction_no": "TXN-2025-0001" + "transaction_no": "TRX-202511100001" }, "status": { "id": 1, @@ -1159,7 +1158,7 @@ ], "approval": { "id": 5001, - "transaction_no": "TXN-2025-0001", + "transaction_no": "TRX-202511100001", "status": { "id": 1, "name": "대기", @@ -1241,7 +1240,7 @@ ], "approval": { "id": 5001, - "transaction_no": "TXN-2025-0001", + "transaction_no": "TRX-202511100001", "status": { "id": 1, "name": "대기", @@ -1274,7 +1273,7 @@ "data": { "approval": { "id": 5001, - "transaction_no": "TXN-2025-0001", + "transaction_no": "TRX-202511100001", "status": { "id": 2, "name": "진행중", @@ -1401,7 +1400,7 @@ "data": { "approval": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "status": { "id": 2, "name": "진행중", @@ -1463,7 +1462,7 @@ }, "approval": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "status": { "id": 2, "name": "진행중", @@ -1537,7 +1536,7 @@ }, "approval": { "id": 5001, - "approval_no": "APP-2025-0001", + "approval_no": "APP-202511100001", "status": { "id": 2, "name": "진행중", @@ -1790,7 +1789,7 @@ ], "recent_transactions": [ { - "transaction_no": "TXN-2025-0001", + "transaction_no": "TRX-202511100001", "transaction_date": "2025-09-18", "transaction_type": "입고", "status_name": "상신", @@ -1799,7 +1798,7 @@ ], "pending_approvals": [ { - "approval_no": "APP-2025-0005", + "approval_no": "APP-202511100005", "title": "출고 결재", "step_summary": "2단계/3단계 진행중", "requested_at": "2025-09-17T03:00:00Z" diff --git a/doc/stock_approval_system_spec_v4.md b/doc/stock_approval_system_spec_v4.md index 4b3947d..b74a8ce 100644 --- a/doc/stock_approval_system_spec_v4.md +++ b/doc/stock_approval_system_spec_v4.md @@ -389,6 +389,7 @@ zipcodes ||--o{ customers : addressed | updated_at | 변경일시 | timestamp | - | now() | Y | | | | > 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생. +> 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다. > 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신) --- @@ -500,6 +501,8 @@ zipcodes ||--o{ customers : addressed | created_at | 생성일시 | timestamp | - | now() | Y | | | | | updated_at | 변경일시 | timestamp | - | now() | Y | | | | +> 번호 발급: 서버가 `APP-YYYYMMDDNNNN` 형식으로 `approval_no`를 생성하며 클라이언트 입력을 허용하지 않는다. + --- ### 3.20 `approval_steps` (결재_단계) diff --git a/integration_test/stock_transaction_state_flow_test.dart b/integration_test/stock_transaction_state_flow_test.dart index d616069..ea18e1e 100644 --- a/integration_test/stock_transaction_state_flow_test.dart +++ b/integration_test/stock_transaction_state_flow_test.dart @@ -183,10 +183,10 @@ class _FakeStockTransactionRepository implements StockTransactionRepository { @override Future create(StockTransactionCreateInput input) async { final id = _sequence++; + final generatedNo = 'FAKE-${id.toString().padLeft(6, '0')}'; final transaction = StockTransaction( id: id, - transactionNo: - input.transactionNo ?? 'FAKE-${id.toString().padLeft(6, '0')}', + transactionNo: generatedNo, transactionDate: input.transactionDate, type: StockTransactionType(id: input.transactionTypeId, name: '테스트 트랜잭션'), status: StockTransactionStatus(id: initialStatusId, name: '작성중'), diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 9e569a2..1fa7d6d 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -12,6 +12,7 @@ class ApiClient { final Dio _dio; final ApiErrorMapper _errorMapper; + static const _dataKey = 'data'; /// 내부에서 사용하는 Dio 인스턴스 /// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다. @@ -99,4 +100,37 @@ class ApiClient { throw _errorMapper.map(error); } } + + /// `{ "data": ... }` 형태의 응답에서 내부 데이터를 추출한다. + /// + /// - `data` 키가 존재하면 해당 값을 반환한다. + /// - `data`가 맵 타입이 아니거나 null이면 원본 본문을 그대로 돌려준다. + /// - 최종적으로 맵이 아니면 빈 맵을 반환한다. + Map unwrapAsMap(Response response) { + final payload = _maybeUnwrap(response.data); + if (payload is Map) { + return payload; + } + final original = response.data; + if (original is Map) { + return original; + } + return const {}; + } + + /// 응답 본문에서 envelope을 제거해 반환한다. + /// + /// - `{ "data": [...] }`는 내부 리스트를 돌려준다. + /// - `data` 구조가 아니면 원본을 그대로 반환한다. + dynamic unwrap(Response response) => _maybeUnwrap(response.data); + + dynamic _maybeUnwrap(dynamic body) { + if (body is Map && body.containsKey(_dataKey)) { + final nested = body[_dataKey]; + if (nested != null) { + return nested; + } + } + return body; + } } diff --git a/lib/core/network/api_error.dart b/lib/core/network/api_error.dart index b4dc4f1..9ef2df8 100644 --- a/lib/core/network/api_error.dart +++ b/lib/core/network/api_error.dart @@ -44,6 +44,7 @@ class ApiErrorMapper { final status = error.response?.statusCode; final data = error.response?.data; final message = _resolveMessage(error, data); + final details = _extractDetails(data); if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.receiveTimeout || @@ -76,7 +77,6 @@ class ApiErrorMapper { } if (status != null) { - final details = _extractDetails(data); switch (status) { case 400: return ApiException( @@ -89,8 +89,9 @@ class ApiErrorMapper { case 401: return ApiException( code: ApiErrorCode.unauthorized, - message: '세션이 만료되었습니다. 다시 로그인해 주세요.', + message: _localizeUnauthorizedMessage(message), statusCode: status, + details: details, cause: error, ); case 403: @@ -98,7 +99,7 @@ class ApiErrorMapper { code: ApiErrorCode.forbidden, message: message, statusCode: status, - details: _extractDetails(data), + details: details, cause: error, ); case 404: @@ -106,6 +107,7 @@ class ApiErrorMapper { code: ApiErrorCode.notFound, message: '요청한 리소스를 찾을 수 없습니다.', statusCode: status, + details: details, cause: error, ); case 409: @@ -133,6 +135,7 @@ class ApiErrorMapper { code: ApiErrorCode.unknown, message: message, statusCode: status, + details: details, cause: error, ); } @@ -332,4 +335,25 @@ class ApiErrorMapper { } return [value]; } + + /// 인증 실패 응답 메시지를 한글 안내로 정규화한다. + String _localizeUnauthorizedMessage(String message) { + final trimmed = message.trim(); + if (trimmed.isEmpty) { + return '세션이 만료되었습니다. 다시 로그인해 주세요.'; + } + final normalized = trimmed.toLowerCase(); + switch (normalized) { + case 'invalid credentials': + return '아이디 또는 비밀번호가 올바르지 않습니다.'; + case 'account is inactive': + return '비활성 계정입니다. 관리자에게 문의하세요.'; + case 'token expired': + return '세션이 만료되었습니다. 다시 로그인해 주세요.'; + case 'invalid token': + return '유효하지 않은 토큰입니다. 다시 로그인해 주세요.'; + default: + return trimmed; + } + } } diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index ac3af58..1821491 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import '../../features/approvals/history/presentation/pages/approval_history_page.dart'; +import '../../features/auth/application/auth_service.dart'; import '../../features/approvals/request/presentation/pages/approval_request_page.dart'; import '../../features/approvals/step/presentation/pages/approval_step_page.dart'; import '../../features/approvals/template/presentation/pages/approval_template_page.dart'; @@ -34,6 +36,18 @@ final _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final appRouter = GoRouter( navigatorKey: _rootNavigatorKey, initialLocation: loginRoutePath, + redirect: (context, state) { + final authService = GetIt.I(); + final loggedIn = authService.session != null; + final loggingIn = state.uri.path == loginRoutePath; + if (!loggedIn && !loggingIn) { + return loginRoutePath; + } + if (loggedIn && loggingIn) { + return dashboardRoutePath; + } + return null; + }, routes: [ GoRoute(path: '/', redirect: (_, __) => loginRoutePath), GoRoute( diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index c037a7a..49fd0c6 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -72,8 +72,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')}, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ApprovalDto.fromJson(data).toEntity(); + return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 활성화된 결재 행위 목록을 조회한다. @@ -133,8 +132,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository { '$_basePath/$id/can-proceed', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ApprovalProceedStatusDto.fromJson(data).toEntity(); + return ApprovalProceedStatusDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(); } /// 새로운 결재를 생성한다. @@ -145,8 +145,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ApprovalDto.fromJson(data).toEntity(); + return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 결재 기본 정보를 수정한다. @@ -157,8 +156,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ApprovalDto.fromJson(data).toEntity(); + return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 결재를 삭제(비활성화)한다. @@ -174,8 +172,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ApprovalDto.fromJson(data).toEntity(); + return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다. diff --git a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart index 91ff1c5..a2b58e7 100644 --- a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart @@ -51,9 +51,8 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { query: {if (includeSteps) 'include': 'steps'}, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; return ApprovalTemplateDto.fromJson( - data, + _api.unwrapAsMap(response), ).toEntity(includeSteps: includeSteps); } @@ -68,9 +67,8 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { data: input.toCreatePayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; final created = ApprovalTemplateDto.fromJson( - data, + _api.unwrapAsMap(response), ).toEntity(includeSteps: false); if (steps.isNotEmpty) { await _postSteps(created.id, steps); @@ -109,8 +107,9 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ApprovalTemplateDto.fromJson(data).toEntity(includeSteps: false); + return ApprovalTemplateDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(includeSteps: false); } /// 템플릿 단계 전체를 신규로 등록한다. diff --git a/lib/features/approvals/domain/entities/approval.dart b/lib/features/approvals/domain/entities/approval.dart index 6202159..953857c 100644 --- a/lib/features/approvals/domain/entities/approval.dart +++ b/lib/features/approvals/domain/entities/approval.dart @@ -204,14 +204,12 @@ extension ApprovalStepActionTypeX on ApprovalStepActionType { class ApprovalCreateInput { ApprovalCreateInput({ required this.transactionId, - required this.approvalNo, required this.approvalStatusId, required this.requestedById, this.note, }); final int transactionId; - final String approvalNo; final int approvalStatusId; final int requestedById; final String? note; @@ -220,7 +218,6 @@ class ApprovalCreateInput { final trimmedNote = note?.trim(); return { 'transaction_id': transactionId, - 'approval_no': approvalNo, 'approval_status_id': approvalStatusId, 'requested_by_id': requestedById, if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index 246b107..df58613 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -116,7 +116,7 @@ class _ApprovalHistoryEnabledPageState final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final sortedHistories = _applySorting(histories); return AppLayout( diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index a60f8bd..6dfd3c1 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -135,7 +135,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -378,14 +378,13 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { /// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다. Future _openCreateApprovalDialog() async { - final approvalNoController = TextEditingController(); final transactionController = TextEditingController(); final requesterController = TextEditingController(); final noteController = TextEditingController(); + String? createdApprovalNo; InventoryEmployeeSuggestion? requesterSelection; int? statusId = _controller.defaultApprovalStatusId; String? transactionError; - String? approvalNoError; String? statusError; String? requesterError; @@ -422,8 +421,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { onPressed: isSubmitting ? null : () async { - final approvalNo = approvalNoController.text - .trim(); final transactionText = transactionController.text .trim(); final transactionId = int.tryParse( @@ -433,9 +430,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { final hasStatuses = statusOptions.isNotEmpty; setState(() { - approvalNoError = approvalNo.isEmpty - ? '결재번호를 입력하세요.' - : null; transactionError = transactionText.isEmpty ? '트랜잭션 ID를 입력하세요.' : (transactionId == null @@ -449,8 +443,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { : null; }); - if (approvalNoError != null || - transactionError != null || + if (transactionError != null || statusError != null || requesterError != null) { return; @@ -458,7 +451,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { final input = ApprovalCreateInput( transactionId: transactionId!, - approvalNo: approvalNo, approvalStatusId: statusId!, requestedById: requesterSelection!.id, note: note.isEmpty ? null : note, @@ -470,6 +462,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { return; } if (result != null) { + createdApprovalNo = result.approvalNo; Navigator.of( context, rootNavigator: true, @@ -489,28 +482,18 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Text('결재번호', style: shadTheme.textTheme.small), - const SizedBox(height: 8), - ShadInput( - controller: approvalNoController, - enabled: !isSubmitting, - placeholder: const Text('예: APP-2025-0001'), - onChanged: (_) { - if (approvalNoError != null) { - setState(() => approvalNoError = null); - } - }, - ), - if (approvalNoError != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - approvalNoError!, - style: shadTheme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: shadTheme.colorScheme.mutedForeground + .withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), ), + child: Text( + '결재번호는 저장 시 자동으로 생성됩니다.', + style: shadTheme.textTheme.muted, + ), + ), const SizedBox(height: 16), Text('트랜잭션 ID', style: shadTheme.textTheme.small), const SizedBox(height: 8), @@ -689,13 +672,16 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { }, ); - approvalNoController.dispose(); transactionController.dispose(); requesterController.dispose(); noteController.dispose(); if (created == true && mounted) { - SuperportToast.success(context, '결재를 생성했습니다.'); + final number = createdApprovalNo ?? '-'; + SuperportToast.success( + context, + '결재를 생성했습니다. ($number)', + ); } } diff --git a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart index b1df62c..b0de3f8 100644 --- a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart +++ b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart @@ -33,13 +33,10 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { 'page': page, 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, - if (statusId != null) ...{ - 'status_id': statusId, - 'step_status_id': statusId, - }, + if (statusId != null) ...{'status_id': statusId}, if (approverId != null) 'approver_id': approverId, if (approvalId != null) 'approval_id': approvalId, - 'include': 'approval,approver,step_status', + 'include': 'approval,approver,status', }, options: Options(responseType: ResponseType.json), ); @@ -54,8 +51,9 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { '$_basePath/$id', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? const {}; - return ApprovalStepRecordDto.fromJson(data).toEntity(); + return ApprovalStepRecordDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(); } /// 결재 단계를 생성한다. @@ -66,11 +64,9 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final raw = response.data; - final data = - (raw?['data'] as Map?) ?? - (raw is Map ? raw : const {}); - return ApprovalStepRecordDto.fromJson(data).toEntity(); + return ApprovalStepRecordDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(); } /// 결재 단계를 수정한다. @@ -81,11 +77,9 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final raw = response.data; - final data = - (raw?['data'] as Map?) ?? - (raw is Map ? raw : const {}); - return ApprovalStepRecordDto.fromJson(data).toEntity(); + return ApprovalStepRecordDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(); } /// 결재 단계를 비활성화한다. @@ -101,10 +95,8 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final raw = response.data; - final data = - (raw?['data'] as Map?) ?? - (raw is Map ? raw : const {}); - return ApprovalStepRecordDto.fromJson(data).toEntity(); + return ApprovalStepRecordDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(); } } diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index cbccc56..0293563 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -118,7 +118,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final pageSize = result?.pageSize ?? records.length; final totalPages = pageSize == 0 ? 1 - : (totalCount / pageSize).ceil().clamp(1, 9999) as int; + : (totalCount / pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index 9a0b866..c5bcb80 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -116,7 +116,7 @@ class _ApprovalTemplateEnabledPageState final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final showReset = _searchController.text.trim().isNotEmpty || _controller.statusFilter != ApprovalTemplateStatusFilter.all; diff --git a/lib/features/auth/data/repositories/auth_repository_remote.dart b/lib/features/auth/data/repositories/auth_repository_remote.dart index df72fe4..91e2bd6 100644 --- a/lib/features/auth/data/repositories/auth_repository_remote.dart +++ b/lib/features/auth/data/repositories/auth_repository_remote.dart @@ -26,8 +26,7 @@ class AuthRepositoryRemote implements AuthRepository { }, options: Options(responseType: ResponseType.json), ); - final json = (response.data?['data'] as Map?) ?? {}; - return AuthSessionDto.fromJson(json).toEntity(); + return AuthSessionDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } @override @@ -37,7 +36,6 @@ class AuthRepositoryRemote implements AuthRepository { data: {'refresh_token': refreshToken}, options: Options(responseType: ResponseType.json), ); - final json = (response.data?['data'] as Map?) ?? {}; - return AuthSessionDto.fromJson(json).toEntity(); + return AuthSessionDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart b/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart index bf5072f..e209ead 100644 --- a/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart +++ b/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart @@ -20,9 +20,6 @@ class DashboardRepositoryRemote implements DashboardRepository { _summaryPath, options: Options(responseType: ResponseType.json), ); - final json = (response.data?['data'] as Map?) ?? - response.data ?? - const {}; - return DashboardSummaryDto.fromJson(json).toEntity(); + return DashboardSummaryDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index b5a188e..d90d24f 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -257,7 +257,7 @@ class _KpiCard extends StatelessWidget { const SizedBox(height: 6), Text(kpi?.displayValue ?? '--', style: theme.textTheme.h3), const SizedBox(height: 8), - Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted), + _DeltaTrendRow(kpi: kpi), ], ), ), @@ -265,6 +265,68 @@ class _KpiCard extends StatelessWidget { } } +class _DeltaTrendRow extends StatelessWidget { + const _DeltaTrendRow({this.kpi}); + + final DashboardKpi? kpi; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final delta = kpi?.delta; + + if (delta == null) { + return Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted); + } + + final absDelta = delta.abs(); + final percent = (absDelta * 100).toStringAsFixed(1); + final trimmedPercent = percent.endsWith('.0') + ? percent.substring(0, percent.length - 2) + : percent; + final percentText = delta > 0 + ? '+$trimmedPercent%' + : delta < 0 + ? '-$trimmedPercent%' + : '0%'; + + final icon = delta > 0 + ? lucide.LucideIcons.arrowUpRight + : delta < 0 + ? lucide.LucideIcons.arrowDownRight + : lucide.LucideIcons.minus; + + final Color color; + if (delta > 0) { + color = theme.colorScheme.primary; + } else if (delta < 0) { + color = theme.colorScheme.destructive; + } else { + color = theme.textTheme.muted.color ?? theme.colorScheme.mutedForeground; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Text( + percentText, + style: theme.textTheme.small.copyWith(color: color), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + kpi?.trendLabel ?? '전일 대비', + overflow: TextOverflow.ellipsis, + style: theme.textTheme.muted, + ), + ), + ], + ); + } +} + class _RecentTransactionsCard extends StatelessWidget { const _RecentTransactionsCard({required this.transactions}); diff --git a/lib/features/inventory/inbound/presentation/models/inbound_record.dart b/lib/features/inventory/inbound/presentation/models/inbound_record.dart index b76c15b..a3fd54c 100644 --- a/lib/features/inventory/inbound/presentation/models/inbound_record.dart +++ b/lib/features/inventory/inbound/presentation/models/inbound_record.dart @@ -11,6 +11,9 @@ class InboundRecord { required this.processedAt, required this.warehouse, this.warehouseId, + this.warehouseCode, + this.warehouseZipcode, + this.warehouseAddress, required this.status, this.statusId, required this.writer, @@ -32,6 +35,9 @@ class InboundRecord { processedAt: transaction.transactionDate, warehouse: transaction.warehouse.name, warehouseId: transaction.warehouse.id, + warehouseCode: transaction.warehouse.code, + warehouseZipcode: transaction.warehouse.zipcode, + warehouseAddress: transaction.warehouse.addressLine, status: transaction.status.name, statusId: transaction.status.id, writer: transaction.createdBy.name, @@ -54,6 +60,9 @@ class InboundRecord { final DateTime processedAt; final String warehouse; final int? warehouseId; + final String? warehouseCode; + final String? warehouseZipcode; + final String? warehouseAddress; final String status; final int? statusId; final String writer; diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 7985fa4..14eecb1 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -75,6 +75,10 @@ class _InboundPageState extends State { String? _errorMessage; Set _processingTransactionIds = {}; + String? _routeSelectedNumber; + String? _pendingDetailNumber; + bool _suppressNextRouteSelection = false; + late List _statusOptions; final Map _statusLookup = {}; LookupItem? _transactionTypeLookup; @@ -184,6 +188,26 @@ class _InboundPageState extends State { controller.transactionType ?? _transactionTypeLookup; _refreshSelection(); }); + final detailNumber = _pendingDetailNumber; + if (detailNumber != null) { + _pendingDetailNumber = null; + InboundRecord? matched; + for (final record in _records) { + if (record.transactionNumber == detailNumber) { + matched = record; + break; + } + } + if (matched != null) { + final target = matched; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _showDetailDialog(target); + }); + } + } } @override @@ -456,10 +480,7 @@ class _InboundPageState extends State { mobile: (_) => _InboundMobileList( records: visibleRecords, selected: _selectedRecord, - onSelect: (record) { - setState(() => _selectedRecord = record); - _showDetailDialog(record); - }, + onSelect: _handleRecordTap, dateFormatter: _dateFormatter, currencyFormatter: _currencyFormatter, transitionsEnabled: _transitionsEnabled, @@ -663,18 +684,37 @@ class _InboundPageState extends State { const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight), onRowTap: (rowIndex) { final record = records[rowIndex]; - setState(() { - _selectedRecord = record; - }); - _showDetailDialog(record); + _selectRecord(record, openDetail: true); }, ); } + void _handleRecordTap(InboundRecord record) { + _selectRecord(record, openDetail: true); + } + List _visibleColumnsFor(DeviceBreakpoint breakpoint) { return InboundTableSpec.visibleColumns(breakpoint); } + void _selectRecord(InboundRecord? record, {bool openDetail = false}) { + if (!mounted) return; + setState(() { + _selectedRecord = record; + }); + final selectedNumber = record?.transactionNumber; + _routeSelectedNumber = selectedNumber; + _updateRoute(selected: selectedNumber); + if (openDetail && record != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _showDetailDialog(record); + }); + } + } + List _buildTableCells( InboundRecord record, List visibleColumns, @@ -767,18 +807,14 @@ class _InboundPageState extends State { Future _handleCreate() async { final record = await _showInboundFormDialog(); if (record != null) { - setState(() { - _selectedRecord = record; - }); + _selectRecord(record, openDetail: true); } } Future _handleEdit(InboundRecord record) async { final updated = await _showInboundFormDialog(initial: record); if (updated != null) { - setState(() { - _selectedRecord = updated; - }); + _selectRecord(updated, openDetail: true); } } @@ -1082,6 +1118,10 @@ class _InboundPageState extends State { ? pageSizeParam : InboundTableSpec.pageSizeOptions.first; final page = (pageParam != null && pageParam > 0) ? pageParam : 1; + final selectedParam = params['selected']?.trim(); + final selectedNumber = selectedParam == null || selectedParam.isEmpty + ? null + : selectedParam; void assign() { _pendingQuery = query; @@ -1106,6 +1146,13 @@ class _InboundPageState extends State { _sortAscending = resolvedSortAscending; _pageSize = pageSize; _currentPage = page; + _routeSelectedNumber = selectedNumber; + if (_suppressNextRouteSelection) { + _pendingDetailNumber = null; + } else { + _pendingDetailNumber = selectedNumber; + } + _suppressNextRouteSelection = false; _refreshSelection(); } @@ -1121,9 +1168,7 @@ class _InboundPageState extends State { void _goToPage(int page) { final totalItems = _result?.total ?? _filteredRecords.length; final totalPages = _calculateTotalPages(totalItems); - final int target = page < 1 - ? 1 - : (page > totalPages ? totalPages : page); + final int target = page < 1 ? 1 : (page > totalPages ? totalPages : page); if (target == _currentPage) { return; } @@ -1141,6 +1186,16 @@ class _InboundPageState extends State { return; } + final selectedNumber = _routeSelectedNumber; + if (selectedNumber != null) { + for (final record in filtered) { + if (record.transactionNumber == selectedNumber) { + _selectedRecord = record; + return; + } + } + } + final current = _selectedRecord; if (current != null) { InboundRecord? matched; @@ -1162,7 +1217,7 @@ class _InboundPageState extends State { _selectedRecord = filtered.first; } - void _updateRoute({int? page, int? pageSize}) { + void _updateRoute({int? page, int? pageSize, String? selected}) { if (!mounted) return; final targetPage = page ?? _currentPage; final targetPageSize = pageSize ?? _pageSize; @@ -1208,6 +1263,10 @@ class _InboundPageState extends State { if (targetPageSize != InboundTableSpec.pageSizeOptions.first) { params['page_size'] = targetPageSize.toString(); } + final selectedNumber = selected ?? _selectedRecord?.transactionNumber; + if (selectedNumber != null && selectedNumber.isNotEmpty) { + params['selected'] = selectedNumber; + } final uri = Uri( path: widget.routeUri.path, @@ -1221,6 +1280,7 @@ class _InboundPageState extends State { if (router == null) { return; } + _suppressNextRouteSelection = true; router.go(newLocation); } @@ -1351,12 +1411,8 @@ class _InboundPageState extends State { text: writerLabel(writerSelection), ); final remarkController = TextEditingController(text: initial?.remark ?? ''); - final transactionNumberController = TextEditingController( - text: initial?.transactionNumber ?? '', - ); - final approvalNumberController = TextEditingController( - text: initial?.raw?.approval?.approvalNo ?? '', - ); + final assignedTransactionNo = initial?.transactionNumber; + final assignedApprovalNo = initial?.raw?.approval?.approvalNo; final approvalNoteController = TextEditingController(); final transactionTypeValue = initial?.transactionType ?? @@ -1378,8 +1434,6 @@ class _InboundPageState extends State { }; String? writerError; - String? transactionNumberError; - String? approvalNumberError; String? warehouseError; String? statusError; String? headerNotice; @@ -1408,18 +1462,12 @@ 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; @@ -1463,8 +1511,6 @@ 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; @@ -1529,7 +1575,10 @@ class _InboundPageState extends State { if (!mounted) { return; } - SuperportToast.success(context, '입고 정보가 수정되었습니다.'); + SuperportToast.success( + context, + '입고 정보가 수정되었습니다. (${updated.transactionNumber})', + ); navigator.pop(); return; } @@ -1553,7 +1602,6 @@ class _InboundPageState extends State { .toList(growable: false); final created = await controller.createTransaction( StockTransactionCreateInput( - transactionNo: transactionNoValue, transactionTypeId: transactionTypeLookup.id, transactionStatusId: statusItem.id, warehouseId: warehouseId, @@ -1562,7 +1610,6 @@ class _InboundPageState extends State { note: remarkValue, lines: createLines, approval: StockTransactionApprovalInput( - approvalNo: approvalNoValue, requestedById: createdById, note: approvalNoteValue.isEmpty ? null : approvalNoteValue, ), @@ -1573,7 +1620,10 @@ class _InboundPageState extends State { if (!mounted) { return; } - SuperportToast.success(context, '입고가 등록되었습니다.'); + SuperportToast.success( + context, + '입고가 등록되었습니다. (${created.transactionNumber})', + ); navigator.pop(); } catch (error) { updateSaving(false); @@ -1719,20 +1769,11 @@ class _InboundPageState extends State { width: 240, child: SuperportFormField( label: '트랜잭션번호', - required: true, - errorText: transactionNumberError, - child: ShadInput( - controller: transactionNumberController, - readOnly: initial != null, - enabled: initial == null, - placeholder: const Text('예: IN-2024-0001'), - onChanged: (_) { - if (transactionNumberError != null) { - setState(() { - transactionNumberError = null; - }); - } - }, + child: Text( + assignedTransactionNo ?? '저장 시 자동 생성', + style: assignedTransactionNo == null + ? theme.textTheme.muted + : theme.textTheme.p, ), ), ), @@ -1740,20 +1781,11 @@ class _InboundPageState extends State { 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; - }); - } - }, + child: Text( + assignedApprovalNo ?? '저장 시 자동 생성', + style: assignedApprovalNo == null + ? theme.textTheme.muted + : theme.textTheme.p, ), ), ), @@ -1926,8 +1958,6 @@ class _InboundPageState extends State { statusValue.dispose(); writerController.dispose(); remarkController.dispose(); - transactionNumberController.dispose(); - approvalNumberController.dispose(); approvalNoteController.dispose(); transactionTypeController.dispose(); processedAt.dispose(); @@ -2271,10 +2301,6 @@ _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, @@ -2282,8 +2308,6 @@ _InboundFormValidation _validateInboundForm({ }) { var isValid = true; String? writerError; - String? transactionNumberError; - String? approvalNumberError; String? warehouseError; String? statusError; String? headerNotice; @@ -2299,18 +2323,6 @@ _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; @@ -2369,8 +2381,6 @@ _InboundFormValidation _validateInboundForm({ return _InboundFormValidation( isValid: isValid, writerError: writerError, - transactionNumberError: transactionNumberError, - approvalNumberError: approvalNumberError, warehouseError: warehouseError, statusError: statusError, headerNotice: headerNotice, @@ -2386,8 +2396,6 @@ class _InboundFormValidation { const _InboundFormValidation({ required this.isValid, this.writerError, - this.transactionNumberError, - this.approvalNumberError, this.warehouseError, this.statusError, this.headerNotice, @@ -2395,8 +2403,6 @@ 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/inbound/presentation/widgets/inbound_detail_view.dart b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart index a152662..af6f004 100644 --- a/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart +++ b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart @@ -43,6 +43,15 @@ class InboundDetailView extends StatelessWidget { value: dateFormatter.format(record.processedAt), ), _DetailChip(label: '창고', value: record.warehouse), + if (record.warehouseCode != null && + record.warehouseCode!.trim().isNotEmpty) + _DetailChip(label: '창고 코드', value: record.warehouseCode!), + if (record.warehouseZipcode != null && + record.warehouseZipcode!.trim().isNotEmpty) + _DetailChip(label: '창고 우편번호', value: record.warehouseZipcode!), + if (record.warehouseAddress != null && + record.warehouseAddress!.trim().isNotEmpty) + _DetailChip(label: '창고 주소', value: record.warehouseAddress!), _DetailChip(label: '트랜잭션 유형', value: record.transactionType), _DetailChip(label: '상태', value: record.status), _DetailChip(label: '작성자', value: record.writer), diff --git a/lib/features/inventory/outbound/presentation/models/outbound_record.dart b/lib/features/inventory/outbound/presentation/models/outbound_record.dart index 922d336..41ad203 100644 --- a/lib/features/inventory/outbound/presentation/models/outbound_record.dart +++ b/lib/features/inventory/outbound/presentation/models/outbound_record.dart @@ -11,6 +11,9 @@ class OutboundRecord { required this.processedAt, required this.warehouse, this.warehouseId, + this.warehouseCode, + this.warehouseZipcode, + this.warehouseAddress, required this.status, this.statusId, required this.writer, @@ -31,6 +34,9 @@ class OutboundRecord { processedAt: transaction.transactionDate, warehouse: transaction.warehouse.name, warehouseId: transaction.warehouse.id, + warehouseCode: transaction.warehouse.code, + warehouseZipcode: transaction.warehouse.zipcode, + warehouseAddress: transaction.warehouse.addressLine, status: transaction.status.name, statusId: transaction.status.id, writer: transaction.createdBy.name, @@ -54,6 +60,9 @@ class OutboundRecord { final DateTime processedAt; final String warehouse; final int? warehouseId; + final String? warehouseCode; + final String? warehouseZipcode; + final String? warehouseAddress; final String status; final int? statusId; final String writer; diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 3ed88ad..55be40d 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -79,6 +79,10 @@ class _OutboundPageState extends State { String? _errorMessage; Set _processingTransactionIds = {}; + String? _routeSelectedNumber; + String? _pendingDetailNumber; + bool _suppressNextRouteSelection = false; + late List _statusOptions; final Map _statusLookup = {}; LookupItem? _transactionTypeLookup; @@ -256,6 +260,26 @@ class _OutboundPageState extends State { controller.transactionType ?? _transactionTypeLookup; _refreshSelection(); }); + final detailNumber = _pendingDetailNumber; + if (detailNumber != null) { + _pendingDetailNumber = null; + OutboundRecord? matched; + for (final record in _records) { + if (record.transactionNumber == detailNumber) { + matched = record; + break; + } + } + if (matched != null) { + final target = matched; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _showDetailDialog(target); + }); + } + } } @override @@ -580,10 +604,7 @@ class _OutboundPageState extends State { ), onRowTap: (rowIndex) { final record = visibleRecords[rowIndex]; - setState(() { - _selectedRecord = record; - }); - _showDetailDialog(record); + _selectRecord(record, openDetail: true); }, ), ), @@ -748,6 +769,24 @@ class _OutboundPageState extends State { return records; } + void _selectRecord(OutboundRecord? record, {bool openDetail = false}) { + if (!mounted) return; + setState(() { + _selectedRecord = record; + }); + final selectedNumber = record?.transactionNumber; + _routeSelectedNumber = selectedNumber; + _updateRoute(selected: selectedNumber); + if (openDetail && record != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _showDetailDialog(record); + }); + } + } + List _buildRecordRow(OutboundRecord record) { final primaryItem = record.items.isNotEmpty ? record.items.first : null; return [ @@ -848,18 +887,14 @@ class _OutboundPageState extends State { Future _handleCreate() async { final record = await _showOutboundFormDialog(); if (record != null) { - setState(() { - _selectedRecord = record; - }); + _selectRecord(record, openDetail: true); } } Future _handleEdit(OutboundRecord record) async { final updated = await _showOutboundFormDialog(initial: record); if (updated != null) { - setState(() { - _selectedRecord = updated; - }); + _selectRecord(updated, openDetail: true); } } @@ -1163,6 +1198,10 @@ class _OutboundPageState extends State { (params['order'] ?? '').toLowerCase() == 'asc'; final pageSizeParam = int.tryParse(params['page_size'] ?? ''); final pageParam = int.tryParse(params['page'] ?? ''); + final selectedParam = params['selected']?.trim(); + final selectedNumber = selectedParam == null || selectedParam.isEmpty + ? null + : selectedParam; final warehouseId = warehouseIdParam; final warehouseLabel = warehouseParam != null && warehouseParam.isNotEmpty @@ -1232,6 +1271,13 @@ class _OutboundPageState extends State { _sortAscending = resolvedSortAscending; _pageSize = pageSize; _currentPage = page; + _routeSelectedNumber = selectedNumber; + if (_suppressNextRouteSelection) { + _pendingDetailNumber = null; + } else { + _pendingDetailNumber = selectedNumber; + } + _suppressNextRouteSelection = false; _refreshSelection(); } @@ -1265,6 +1311,16 @@ class _OutboundPageState extends State { return; } + final selectedNumber = _routeSelectedNumber; + if (selectedNumber != null) { + for (final record in filtered) { + if (record.transactionNumber == selectedNumber) { + _selectedRecord = record; + return; + } + } + } + final current = _selectedRecord; if (current != null) { OutboundRecord? matched; @@ -1286,7 +1342,7 @@ class _OutboundPageState extends State { _selectedRecord = filtered.first; } - void _updateRoute({int? page, int? pageSize}) { + void _updateRoute({int? page, int? pageSize, String? selected}) { if (!mounted) return; final targetPage = page ?? _currentPage; final targetPageSize = pageSize ?? _pageSize; @@ -1336,6 +1392,10 @@ class _OutboundPageState extends State { if (targetPageSize != OutboundTableSpec.pageSizeOptions.first) { params['page_size'] = targetPageSize.toString(); } + final selectedNumber = selected ?? _selectedRecord?.transactionNumber; + if (selectedNumber != null && selectedNumber.isNotEmpty) { + params['selected'] = selectedNumber; + } final uri = Uri( path: widget.routeUri.path, @@ -1349,6 +1409,7 @@ class _OutboundPageState extends State { if (router == null) { return; } + _suppressNextRouteSelection = true; router.go(newLocation); } @@ -1510,12 +1571,8 @@ class _OutboundPageState extends State { final transactionTypeController = TextEditingController( text: transactionTypeValue, ); - final transactionNumberController = TextEditingController( - text: initial?.transactionNumber ?? '', - ); - final approvalNumberController = TextEditingController( - text: initial?.raw?.approval?.approvalNo ?? '', - ); + final assignedTransactionNo = initial?.transactionNumber; + final assignedApprovalNo = initial?.raw?.approval?.approvalNo; final approvalNoteController = TextEditingController(); final drafts = @@ -1530,8 +1587,6 @@ class _OutboundPageState extends State { }; String? writerError; - String? transactionNumberError; - String? approvalNumberError; String? customerError; String? warehouseError; String? statusError; @@ -1561,10 +1616,6 @@ 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 @@ -1575,8 +1626,6 @@ class _OutboundPageState extends State { ); writerError = validation.writerError; - transactionNumberError = validation.transactionNumberError; - approvalNumberError = validation.approvalNumberError; customerError = validation.customerError; warehouseError = validation.warehouseError; statusError = validation.statusError; @@ -1621,8 +1670,6 @@ 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; @@ -1715,7 +1762,10 @@ class _OutboundPageState extends State { if (!mounted) { return; } - SuperportToast.success(context, '출고 정보가 수정되었습니다.'); + SuperportToast.success( + context, + '출고 정보가 수정되었습니다. (${updated.transactionNumber})', + ); navigator.pop(); return; } @@ -1749,7 +1799,6 @@ class _OutboundPageState extends State { final created = await controller.createTransaction( StockTransactionCreateInput( - transactionNo: transactionNoValue, transactionTypeId: transactionTypeLookup.id, transactionStatusId: statusItem.id, warehouseId: warehouseId, @@ -1759,7 +1808,6 @@ class _OutboundPageState extends State { lines: createLines, customers: createCustomers, approval: StockTransactionApprovalInput( - approvalNo: approvalNoValue, requestedById: createdById, note: approvalNoteValue.isEmpty ? null : approvalNoteValue, ), @@ -1770,7 +1818,10 @@ class _OutboundPageState extends State { if (!mounted) { return; } - SuperportToast.success(context, '출고가 등록되었습니다.'); + SuperportToast.success( + context, + '출고가 등록되었습니다. (${created.transactionNumber})', + ); navigator.pop(); } catch (error) { updateSaving(false); @@ -1911,20 +1962,11 @@ class _OutboundPageState extends State { 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; - }); - } - }, + child: Text( + assignedTransactionNo ?? '저장 시 자동 생성', + style: assignedTransactionNo == null + ? theme.textTheme.muted + : theme.textTheme.p, ), ), ), @@ -1932,20 +1974,11 @@ class _OutboundPageState extends State { 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; - }); - } - }, + child: Text( + assignedApprovalNo ?? '저장 시 자동 생성', + style: assignedApprovalNo == null + ? theme.textTheme.muted + : theme.textTheme.p, ), ), ), @@ -2163,8 +2196,6 @@ class _OutboundPageState extends State { writerController.dispose(); remarkController.dispose(); transactionTypeController.dispose(); - transactionNumberController.dispose(); - approvalNumberController.dispose(); approvalNoteController.dispose(); processedAt.dispose(); @@ -2418,10 +2449,6 @@ _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, @@ -2430,8 +2457,6 @@ _OutboundFormValidation _validateOutboundForm({ }) { var isValid = true; String? writerError; - String? transactionNumberError; - String? approvalNumberError; String? customerError; String? warehouseError; String? statusError; @@ -2448,18 +2473,6 @@ _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; @@ -2523,8 +2536,6 @@ _OutboundFormValidation _validateOutboundForm({ return _OutboundFormValidation( isValid: isValid, writerError: writerError, - transactionNumberError: transactionNumberError, - approvalNumberError: approvalNumberError, customerError: customerError, warehouseError: warehouseError, statusError: statusError, @@ -2536,8 +2547,6 @@ class _OutboundFormValidation { const _OutboundFormValidation({ required this.isValid, this.writerError, - this.transactionNumberError, - this.approvalNumberError, this.customerError, this.warehouseError, this.statusError, @@ -2546,8 +2555,6 @@ 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/outbound/presentation/widgets/outbound_detail_view.dart b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart index f3aace0..e175869 100644 --- a/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart +++ b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart @@ -43,6 +43,15 @@ class OutboundDetailView extends StatelessWidget { value: dateFormatter.format(record.processedAt), ), _DetailChip(label: '창고', value: record.warehouse), + if (record.warehouseCode != null && + record.warehouseCode!.trim().isNotEmpty) + _DetailChip(label: '창고 코드', value: record.warehouseCode!), + if (record.warehouseZipcode != null && + record.warehouseZipcode!.trim().isNotEmpty) + _DetailChip(label: '창고 우편번호', value: record.warehouseZipcode!), + if (record.warehouseAddress != null && + record.warehouseAddress!.trim().isNotEmpty) + _DetailChip(label: '창고 주소', value: record.warehouseAddress!), _DetailChip(label: '트랜잭션 유형', value: record.transactionType), _DetailChip(label: '상태', value: record.status), _DetailChip(label: '작성자', value: record.writer), diff --git a/lib/features/inventory/rental/presentation/models/rental_record.dart b/lib/features/inventory/rental/presentation/models/rental_record.dart index 36f6bb5..97bb42d 100644 --- a/lib/features/inventory/rental/presentation/models/rental_record.dart +++ b/lib/features/inventory/rental/presentation/models/rental_record.dart @@ -12,6 +12,9 @@ class RentalRecord { required this.processedAt, required this.warehouse, this.warehouseId, + this.warehouseCode, + this.warehouseZipcode, + this.warehouseAddress, required this.status, this.statusId, required this.writer, @@ -34,6 +37,9 @@ class RentalRecord { processedAt: transaction.transactionDate, warehouse: transaction.warehouse.name, warehouseId: transaction.warehouse.id, + warehouseCode: transaction.warehouse.code, + warehouseZipcode: transaction.warehouse.zipcode, + warehouseAddress: transaction.warehouse.addressLine, status: transaction.status.name, statusId: transaction.status.id, writer: transaction.createdBy.name, @@ -59,6 +65,9 @@ class RentalRecord { final DateTime processedAt; final String warehouse; final int? warehouseId; + final String? warehouseCode; + final String? warehouseZipcode; + final String? warehouseAddress; final String status; final int? statusId; final String writer; diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index da83ecf..9193944 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -80,6 +80,10 @@ class _RentalPageState extends State { String? _errorMessage; Set _processingTransactionIds = {}; + String? _routeSelectedNumber; + String? _pendingDetailNumber; + bool _suppressNextRouteSelection = false; + late List _statusOptions; final Map _statusLookup = {}; LookupItem? _rentTransactionType; @@ -193,6 +197,26 @@ class _RentalPageState extends State { ); _refreshSelection(); }); + final detailNumber = _pendingDetailNumber; + if (detailNumber != null) { + _pendingDetailNumber = null; + RentalRecord? matched; + for (final record in _records) { + if (record.transactionNumber == detailNumber) { + matched = record; + break; + } + } + if (matched != null) { + final target = matched; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _showDetailDialog(target); + }); + } + } } @override @@ -525,10 +549,7 @@ class _RentalPageState extends State { ), onRowTap: (rowIndex) { final record = visibleRecords[rowIndex]; - setState(() { - _selectedRecord = record; - }); - _showDetailDialog(record); + _selectRecord(record, openDetail: true); }, ), ), @@ -708,6 +729,24 @@ class _RentalPageState extends State { return records; } + void _selectRecord(RentalRecord? record, {bool openDetail = false}) { + if (!mounted) return; + setState(() { + _selectedRecord = record; + }); + final selectedNumber = record?.transactionNumber; + _routeSelectedNumber = selectedNumber; + _updateRoute(selected: selectedNumber); + if (openDetail && record != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _showDetailDialog(record); + }); + } + } + List _buildRecordRow(RentalRecord record) { return [ record.number.split('-').last, @@ -805,18 +844,14 @@ class _RentalPageState extends State { Future _handleCreate() async { final record = await _showRentalFormDialog(); if (record != null) { - setState(() { - _selectedRecord = record; - }); + _selectRecord(record, openDetail: true); } } Future _handleEdit(RentalRecord record) async { final updated = await _showRentalFormDialog(initial: record); if (updated != null) { - setState(() { - _selectedRecord = updated; - }); + _selectRecord(updated, openDetail: true); } } @@ -1113,6 +1148,10 @@ class _RentalPageState extends State { (params['order'] ?? '').toLowerCase() == 'asc'; final pageSizeParam = int.tryParse(params['page_size'] ?? ''); final pageParam = int.tryParse(params['page'] ?? ''); + final selectedParam = params['selected']?.trim(); + final selectedNumber = selectedParam == null || selectedParam.isEmpty + ? null + : selectedParam; final warehouseId = warehouseIdParam; final warehouseLabel = warehouseParam != null && warehouseParam.isNotEmpty @@ -1166,6 +1205,13 @@ class _RentalPageState extends State { _sortAscending = resolvedSortAscending; _pageSize = pageSize; _currentPage = page; + _routeSelectedNumber = selectedNumber; + if (_suppressNextRouteSelection) { + _pendingDetailNumber = null; + } else { + _pendingDetailNumber = selectedNumber; + } + _suppressNextRouteSelection = false; _refreshSelection(); } @@ -1181,9 +1227,7 @@ class _RentalPageState extends State { void _goToPage(int page) { final totalItems = _result?.total ?? _filteredRecords.length; final totalPages = _calculateTotalPages(totalItems); - final int target = page < 1 - ? 1 - : (page > totalPages ? totalPages : page); + final int target = page < 1 ? 1 : (page > totalPages ? totalPages : page); if (target == _currentPage) { return; } @@ -1201,6 +1245,16 @@ class _RentalPageState extends State { return; } + final selectedNumber = _routeSelectedNumber; + if (selectedNumber != null) { + for (final record in filtered) { + if (record.transactionNumber == selectedNumber) { + _selectedRecord = record; + return; + } + } + } + final current = _selectedRecord; if (current != null) { RentalRecord? matched; @@ -1222,7 +1276,7 @@ class _RentalPageState extends State { _selectedRecord = filtered.first; } - void _updateRoute({int? page, int? pageSize}) { + void _updateRoute({int? page, int? pageSize, String? selected}) { if (!mounted) return; final targetPage = page ?? _currentPage; final targetPageSize = pageSize ?? _pageSize; @@ -1276,6 +1330,10 @@ class _RentalPageState extends State { if (targetPageSize != RentalTableSpec.pageSizeOptions.first) { params['page_size'] = targetPageSize.toString(); } + final selectedNumber = selected ?? _selectedRecord?.transactionNumber; + if (selectedNumber != null && selectedNumber.isNotEmpty) { + params['selected'] = selectedNumber; + } final uri = Uri( path: widget.routeUri.path, @@ -1289,6 +1347,7 @@ class _RentalPageState extends State { if (router == null) { return; } + _suppressNextRouteSelection = true; router.go(newLocation); } @@ -1488,12 +1547,8 @@ 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 assignedTransactionNo = initial?.transactionNumber; + final assignedApprovalNo = initial?.raw?.approval?.approvalNo; final approvalNoteController = TextEditingController(); final drafts = @@ -1505,8 +1560,6 @@ class _RentalPageState extends State { RentalRecord? result; String? writerError; - String? transactionNumberError; - String? approvalNumberError; String? customerError; String? warehouseError; String? statusError; @@ -1538,10 +1591,6 @@ 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 @@ -1552,8 +1601,6 @@ class _RentalPageState extends State { ); writerError = validation.writerError; - transactionNumberError = validation.transactionNumberError; - approvalNumberError = validation.approvalNumberError; customerError = validation.customerError; warehouseError = validation.warehouseError; statusError = validation.statusError; @@ -1599,8 +1646,6 @@ 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; @@ -1693,7 +1738,10 @@ class _RentalPageState extends State { if (!mounted) { return; } - SuperportToast.success(context, '대여 정보가 수정되었습니다.'); + SuperportToast.success( + context, + '대여 정보가 수정되었습니다. (${updated.transactionNumber})', + ); navigator.pop(); return; } @@ -1728,7 +1776,6 @@ class _RentalPageState extends State { final transactionTypeId = selectedLookup.id; final created = await controller.createTransaction( StockTransactionCreateInput( - transactionNo: transactionNoValue, transactionTypeId: transactionTypeId, transactionStatusId: statusItem.id, warehouseId: warehouseId, @@ -1739,7 +1786,6 @@ class _RentalPageState extends State { lines: createLines, customers: createCustomers, approval: StockTransactionApprovalInput( - approvalNo: approvalNoValue, requestedById: createdById, note: approvalNoteValue.isEmpty ? null : approvalNoteValue, ), @@ -1750,7 +1796,10 @@ class _RentalPageState extends State { if (!mounted) { return; } - SuperportToast.success(context, '대여가 등록되었습니다.'); + SuperportToast.success( + context, + '대여가 등록되었습니다. (${created.transactionNumber})', + ); navigator.pop(); } catch (error) { updateSaving(false); @@ -1918,70 +1967,26 @@ 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, - ), - ), - ), - ], + child: _FormFieldLabel( + label: '트랜잭션번호', + child: Text( + assignedTransactionNo ?? '저장 시 자동 생성', + style: assignedTransactionNo == null + ? theme.textTheme.muted + : theme.textTheme.p, + ), ), ), 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, - ), - ), - ), - ], + child: _FormFieldLabel( + label: '결재번호', + child: Text( + assignedApprovalNo ?? '저장 시 자동 생성', + style: assignedApprovalNo == null + ? theme.textTheme.muted + : theme.textTheme.p, + ), ), ), SizedBox( @@ -2247,8 +2252,6 @@ class _RentalPageState extends State { writerController.dispose(); remarkController.dispose(); transactionTypeController.dispose(); - transactionNumberController.dispose(); - approvalNumberController.dispose(); approvalNoteController.dispose(); processedAt.dispose(); returnDue.dispose(); @@ -2523,10 +2526,6 @@ _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, @@ -2535,8 +2534,6 @@ _RentalFormValidation _validateRentalForm({ }) { var isValid = true; String? writerError; - String? transactionNumberError; - String? approvalNumberError; String? customerError; String? warehouseError; String? statusError; @@ -2553,18 +2550,6 @@ _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; @@ -2628,8 +2613,6 @@ _RentalFormValidation _validateRentalForm({ return _RentalFormValidation( isValid: isValid, writerError: writerError, - transactionNumberError: transactionNumberError, - approvalNumberError: approvalNumberError, customerError: customerError, warehouseError: warehouseError, statusError: statusError, @@ -2641,8 +2624,6 @@ class _RentalFormValidation { const _RentalFormValidation({ required this.isValid, this.writerError, - this.transactionNumberError, - this.approvalNumberError, this.customerError, this.warehouseError, this.statusError, @@ -2651,8 +2632,6 @@ 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/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index a4d676d..8956431 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -1,7 +1,6 @@ /// 재고 트랜잭션 생성 입력 모델. class StockTransactionCreateInput { StockTransactionCreateInput({ - this.transactionNo, required this.transactionTypeId, required this.transactionStatusId, required this.warehouseId, @@ -14,7 +13,6 @@ class StockTransactionCreateInput { this.approval, }); - final String? transactionNo; final int transactionTypeId; final int transactionStatusId; final int warehouseId; @@ -29,8 +27,6 @@ class StockTransactionCreateInput { Map toPayload() { final sanitizedNote = note?.trim(); return { - if (transactionNo != null && transactionNo!.trim().isNotEmpty) - 'transaction_no': transactionNo, 'transaction_type_id': transactionTypeId, 'transaction_status_id': transactionStatusId, 'warehouse_id': warehouseId, @@ -213,23 +209,21 @@ 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() { + final trimmedNote = note?.trim(); return { - 'approval_no': approvalNo, if (approvalStatusId != null) 'approval_status_id': approvalStatusId, 'requested_by_id': requestedById, - 'note': note?.trim(), + if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, }; } } diff --git a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart index 78b63d5..3c91dcf 100644 --- a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart +++ b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart @@ -47,8 +47,7 @@ class CustomerRepositoryRemote implements CustomerRepository { query: {if (includeZipcode) 'include': 'zipcode'}, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return CustomerDto.fromJson(data).toEntity(); + return CustomerDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 고객을 생성한다. @@ -59,8 +58,7 @@ class CustomerRepositoryRemote implements CustomerRepository { data: customerInputToJson(input), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return CustomerDto.fromJson(data).toEntity(); + return CustomerDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 고객 정보를 수정한다. @@ -72,8 +70,7 @@ class CustomerRepositoryRemote implements CustomerRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return CustomerDto.fromJson(data).toEntity(); + return CustomerDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 고객을 삭제한다. @@ -89,7 +86,6 @@ class CustomerRepositoryRemote implements CustomerRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return CustomerDto.fromJson(data).toEntity(); + return CustomerDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 281adb3..b3c92e2 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -190,7 +190,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/features/masters/group/data/repositories/group_repository_remote.dart b/lib/features/masters/group/data/repositories/group_repository_remote.dart index a482ca2..04a3fb3 100644 --- a/lib/features/masters/group/data/repositories/group_repository_remote.dart +++ b/lib/features/masters/group/data/repositories/group_repository_remote.dart @@ -56,8 +56,7 @@ class GroupRepositoryRemote implements GroupRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return GroupDto.fromJson(data).toEntity(); + return GroupDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 그룹 정보를 수정한다. @@ -69,8 +68,7 @@ class GroupRepositoryRemote implements GroupRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return GroupDto.fromJson(data).toEntity(); + return GroupDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 그룹을 삭제한다. @@ -86,7 +84,6 @@ class GroupRepositoryRemote implements GroupRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return GroupDto.fromJson(data).toEntity(); + return GroupDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index ea6d741..14ce799 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -130,7 +130,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart index 25adcc3..b746baf 100644 --- a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart +++ b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart @@ -34,7 +34,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { if (groupId != null) 'group_id': groupId, if (menuId != null) 'menu_id': menuId, if (isActive != null) 'active': isActive, - if (includeDeleted) 'deleted': true, + if (includeDeleted) 'include_deleted': true, 'include': 'group,menu', }, options: Options(responseType: ResponseType.json), @@ -50,8 +50,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return GroupPermissionDto.fromJson(data).toEntity(); + return GroupPermissionDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 그룹 권한을 수정한다. @@ -63,8 +62,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return GroupPermissionDto.fromJson(data).toEntity(); + return GroupPermissionDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 그룹 권한을 삭제한다. @@ -80,7 +78,6 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return GroupPermissionDto.fromJson(data).toEntity(); + return GroupPermissionDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 8069785..30286ba 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -181,7 +181,7 @@ class _GroupPermissionEnabledPageState final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -856,6 +856,7 @@ class _PermissionTable extends StatelessWidget { 'ID', '그룹명', '메뉴명', + '라우트 경로', '생성', '조회', '수정', @@ -872,6 +873,7 @@ class _PermissionTable extends StatelessWidget { permission.id?.toString() ?? '-', permission.group.groupName, permission.menu.menuName, + permission.menu.path ?? '-', permission.canCreate ? 'Y' : '-', permission.canRead ? 'Y' : '-', permission.canUpdate ? 'Y' : '-', diff --git a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart index 68bc264..91521a4 100644 --- a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart +++ b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart @@ -49,8 +49,7 @@ class MenuRepositoryRemote implements MenuRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return MenuDto.fromJson(data).toEntity(); + return MenuDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 메뉴 정보를 수정한다. @@ -62,8 +61,7 @@ class MenuRepositoryRemote implements MenuRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return MenuDto.fromJson(data).toEntity(); + return MenuDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 메뉴를 삭제한다. @@ -79,7 +77,6 @@ class MenuRepositoryRemote implements MenuRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return MenuDto.fromJson(data).toEntity(); + return MenuDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index 66060ba..6f603ac 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -151,7 +151,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/features/masters/product/data/repositories/product_repository_remote.dart b/lib/features/masters/product/data/repositories/product_repository_remote.dart index 1f273bd..619942c 100644 --- a/lib/features/masters/product/data/repositories/product_repository_remote.dart +++ b/lib/features/masters/product/data/repositories/product_repository_remote.dart @@ -49,8 +49,7 @@ class ProductRepositoryRemote implements ProductRepository { data: productInputToJson(input), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ProductDto.fromJson(data).toEntity(); + return ProductDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 제품 정보를 수정한다. @@ -62,8 +61,7 @@ class ProductRepositoryRemote implements ProductRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ProductDto.fromJson(data).toEntity(); + return ProductDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 제품을 삭제한다. @@ -79,7 +77,6 @@ class ProductRepositoryRemote implements ProductRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return ProductDto.fromJson(data).toEntity(); + return ProductDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 635d7ea..07d435a 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -156,7 +156,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/features/masters/user/data/repositories/user_repository_remote.dart b/lib/features/masters/user/data/repositories/user_repository_remote.dart index 2a4ea5c..4fdaedb 100644 --- a/lib/features/masters/user/data/repositories/user_repository_remote.dart +++ b/lib/features/masters/user/data/repositories/user_repository_remote.dart @@ -47,8 +47,7 @@ class UserRepositoryRemote implements UserRepository { data: input.toPayload(), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return UserDto.fromJson(data).toEntity(); + return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 사용자 정보를 수정한다. @@ -60,8 +59,7 @@ class UserRepositoryRemote implements UserRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return UserDto.fromJson(data).toEntity(); + return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 사용자를 삭제한다. @@ -77,7 +75,6 @@ class UserRepositoryRemote implements UserRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return UserDto.fromJson(data).toEntity(); + return UserDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index 6abe7f8..7e547b1 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -163,7 +163,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart index b55a91a..d4845a5 100644 --- a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart +++ b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart @@ -44,8 +44,7 @@ class VendorRepositoryRemote implements VendorRepository { data: vendorInputToJson(input), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return VendorDto.fromJson(data).toEntity(); + return VendorDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } @override @@ -56,8 +55,7 @@ class VendorRepositoryRemote implements VendorRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return VendorDto.fromJson(data).toEntity(); + return VendorDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } @override @@ -71,7 +69,6 @@ class VendorRepositoryRemote implements VendorRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return VendorDto.fromJson(data).toEntity(); + return VendorDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index cf262b2..d44226d 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -149,7 +149,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -365,7 +365,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final result = _controller.result; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); if (page < 1) { page = 1; } else if (page > totalPages) { diff --git a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart index a359bb4..db03bf4 100644 --- a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart +++ b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart @@ -46,8 +46,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository { data: warehouseInputToJson(input), options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return WarehouseDto.fromJson(data).toEntity(); + return WarehouseDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 창고 정보를 수정한다. @@ -59,8 +58,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository { data: payload, options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return WarehouseDto.fromJson(data).toEntity(); + return WarehouseDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } /// 창고를 삭제한다. @@ -76,7 +74,6 @@ class WarehouseRepositoryRemote implements WarehouseRepository { '$_basePath/$id/restore', options: Options(responseType: ResponseType.json), ); - final data = (response.data?['data'] as Map?) ?? {}; - return WarehouseDto.fromJson(data).toEntity(); + return WarehouseDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 2e0f08b..b73233d 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -158,7 +158,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; + : (result.total / result.pageSize).ceil().clamp(1, 9999); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; diff --git a/lib/main.dart b/lib/main.dart index 5102956..afe93e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'core/config/environment.dart'; +import 'core/permissions/permission_manager.dart'; import 'core/routing/app_router.dart'; import 'core/theme/superport_shad_theme.dart'; import 'core/theme/theme_controller.dart'; +import 'features/auth/application/auth_service.dart'; import 'injection_container.dart'; -import 'core/permissions/permission_manager.dart'; /// Superport 애플리케이션 진입점. 환경 초기화 후 앱 위젯을 실행한다. Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Environment.initialize(); await initInjection(baseUrl: Environment.baseUrl); + final authService = GetIt.I(); + try { + await authService.refreshSession(); + } catch (error, stackTrace) { + // 초기 자동 로그인 갱신이 실패하면 세션을 정리하고 로그인 화면으로 진입한다. + debugPrint('세션 갱신 실패: $error'); + debugPrintStack(stackTrace: stackTrace); + await authService.clearSession(); + } runApp(const SuperportApp()); } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index bbe7ad8..1f5c8be 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -433,7 +432,6 @@ class _AccountMenuButton extends StatelessWidget { description: session == null ? '로그인 정보를 찾을 수 없습니다.' : '현재 로그인된 계정 세부 정보를 확인하세요.', - child: _AccountInfoContent(session: session), footer: Builder( builder: (dialogContext) { return Padding( @@ -458,6 +456,7 @@ class _AccountMenuButton extends StatelessWidget { }, ), scrollable: session != null && session.permissions.length > 6, + child: _AccountInfoContent(session: session), ), ); if (shouldLogout == true) { diff --git a/test/core/network/api_client_test.dart b/test/core/network/api_client_test.dart index 197e62e..7883067 100644 --- a/test/core/network/api_client_test.dart +++ b/test/core/network/api_client_test.dart @@ -93,5 +93,62 @@ void main() { verify(() => mapper.map(dioError)).called(1); }); + + group('unwrapAsMap', () { + test('data 키가 있으면 내부 맵을 반환한다', () { + final response = Response( + requestOptions: RequestOptions(path: '/auth/login'), + statusCode: 200, + data: { + 'data': {'token': 'abc'}, + }, + ); + + expect(client.unwrapAsMap(response), {'token': 'abc'}); + }); + + test('data 키가 없으면 원본 맵을 반환한다', () { + final response = Response( + requestOptions: RequestOptions(path: '/menus'), + statusCode: 200, + data: {'items': []}, + ); + + expect(client.unwrapAsMap(response), {'items': []}); + }); + + test('본문이 null이면 빈 맵을 반환한다', () { + final response = Response( + requestOptions: RequestOptions(path: '/menus'), + statusCode: 200, + ); + + expect(client.unwrapAsMap(response), isEmpty); + }); + }); + + group('unwrap', () { + test('data 키가 있는 리스트를 반환한다', () { + final response = Response( + requestOptions: RequestOptions(path: '/reports'), + statusCode: 200, + data: { + 'data': [1, 2, 3], + }, + ); + + expect(client.unwrap(response), [1, 2, 3]); + }); + + test('data 키가 없으면 원본을 그대로 반환한다', () { + final response = Response( + requestOptions: RequestOptions(path: '/health'), + statusCode: 200, + data: {'status': 'ok'}, + ); + + expect(client.unwrap(response), {'status': 'ok'}); + }); + }); }); } diff --git a/test/core/network/api_error_test.dart b/test/core/network/api_error_test.dart index d7916de..e804cff 100644 --- a/test/core/network/api_error_test.dart +++ b/test/core/network/api_error_test.dart @@ -80,4 +80,60 @@ void main() { expect(asIterable(action), contains('approve')); expect(asIterable(resource), contains('stock-transactions')); }); + + group('401 응답 메시지 매핑', () { + test('invalid credentials 메시지를 한글로 변환한다', () { + final mapper = ApiErrorMapper(); + final requestOptions = RequestOptions(path: '/auth/login'); + final dioException = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 401, + data: { + 'error': { + 'message': 'invalid credentials', + }, + }, + ), + ); + + final exception = mapper.map(dioException); + + expect(exception.code, ApiErrorCode.unauthorized); + expect(exception.message, '아이디 또는 비밀번호가 올바르지 않습니다.'); + }); + + test('token expired 메시지를 세션 만료 문구로 변환하고 상세를 보존한다', () { + final mapper = ApiErrorMapper(); + final requestOptions = RequestOptions(path: '/auth/refresh'); + final dioException = DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response( + requestOptions: requestOptions, + statusCode: 401, + data: { + 'error': { + 'message': 'token expired', + 'details': [ + {'field': 'token', 'message': '만료되었습니다.'}, + ], + }, + }, + ), + ); + + final exception = mapper.map(dioException); + + expect(exception.message, '세션이 만료되었습니다. 다시 로그인해 주세요.'); + final tokenDetails = exception.details?['token']; + if (tokenDetails is Iterable) { + expect(tokenDetails, contains('만료되었습니다.')); + } else { + expect(tokenDetails, '만료되었습니다.'); + } + }); + }); } diff --git a/test/features/approvals/data/approval_repository_remote_test.dart b/test/features/approvals/data/approval_repository_remote_test.dart index e0e144d..3b7a7e9 100644 --- a/test/features/approvals/data/approval_repository_remote_test.dart +++ b/test/features/approvals/data/approval_repository_remote_test.dart @@ -15,11 +15,28 @@ void main() { setUpAll(() { registerFallbackValue(Options()); registerFallbackValue(CancelToken()); + registerFallbackValue( + Response(requestOptions: RequestOptions(path: '/')), + ); }); setUp(() { apiClient = _MockApiClient(); repository = ApprovalRepositoryRemote(apiClient: apiClient); + when(() => apiClient.unwrapAsMap(any())).thenAnswer((invocation) { + final response = invocation.positionalArguments.first; + if (response is Response) { + final data = response.data; + if (data is Map) { + final nested = data['data']; + if (nested is Map) { + return nested; + } + return data; + } + } + return {}; + }); }); test('list는 신규 필터 파라미터를 전달한다', () async { @@ -93,7 +110,6 @@ void main() { final input = ApprovalCreateInput( transactionId: 9001, - approvalNo: 'APP-2025-0001', approvalStatusId: 1, requestedById: 7, note: ' 신규 결재 ', @@ -113,7 +129,7 @@ void main() { expect(captured.first, equals(path)); final payload = captured[1] as Map; expect(payload['transaction_id'], 9001); - expect(payload['approval_no'], 'APP-2025-0001'); + expect(payload.containsKey('approval_no'), isFalse); expect(payload['approval_status_id'], 1); expect(payload['requested_by_id'], 7); expect(payload['note'], '신규 결재'); diff --git a/test/features/inventory/inbound_page_test.dart b/test/features/inventory/inbound_page_test.dart index c682795..a86c9f5 100644 --- a/test/features/inventory/inbound_page_test.dart +++ b/test/features/inventory/inbound_page_test.dart @@ -116,17 +116,18 @@ void main() { expect(transactionField, findsOneWidget); expect(approvalField, findsOneWidget); - final transactionInput = find.descendant( - of: transactionField, - matching: find.byType(EditableText), + expect( + find.descendant( + of: transactionField, + matching: find.byType(EditableText), + ), + findsNothing, ); - final approvalInput = find.descendant( - of: approvalField, - matching: find.byType(EditableText), + expect( + find.descendant(of: approvalField, matching: find.byType(EditableText)), + findsNothing, ); - await tester.enterText(transactionInput.first, 'IN-TEST-001'); - await tester.enterText(approvalInput.first, 'APP-TEST-001'); - await tester.pump(); + expect(find.text('저장 시 자동 생성'), findsAtLeastNWidgets(2)); final productFields = find.byType(InventoryProductAutocompleteField); expect(productFields, findsWidgets); @@ -161,7 +162,7 @@ void main() { expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget); }); - testWidgets('입고 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async { + testWidgets('입고 등록 모달은 번호 입력 없이 저장 안내만 제공한다', (tester) async { final view = tester.view; view.physicalSize = const Size(1280, 900); view.devicePixelRatio = 1.0; @@ -190,10 +191,14 @@ void main() { await tester.tap(find.widgetWithText(ShadButton, '입고 등록')); await tester.pumpAndSettle(); + expect(find.text('저장 시 자동 생성'), findsAtLeastNWidgets(2)); + await tester.tap(find.widgetWithText(ShadButton, '저장')); await tester.pump(); - expect(find.text('거래번호를 입력하세요.'), findsOneWidget); - expect(find.text('결재번호를 입력하세요.'), findsOneWidget); + expect(find.text('거래번호를 입력하세요.'), findsNothing); + expect(find.text('결재번호를 입력하세요.'), findsNothing); + expect(find.textContaining('자동완성에서 선택'), findsOneWidget); + expect(find.textContaining('창고를 선택'), findsOneWidget); }); } diff --git a/test/features/inventory/outbound_page_test.dart b/test/features/inventory/outbound_page_test.dart index 8750e55..0207101 100644 --- a/test/features/inventory/outbound_page_test.dart +++ b/test/features/inventory/outbound_page_test.dart @@ -25,7 +25,7 @@ void main() { await GetIt.I.reset(); }); - testWidgets('출고 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async { + testWidgets('출고 등록 모달은 번호 입력 없이 자동 생성 안내를 제공한다', (tester) async { final view = tester.view; view.physicalSize = const Size(1280, 900); view.devicePixelRatio = 1.0; @@ -54,10 +54,14 @@ void main() { await tester.tap(find.widgetWithText(ShadButton, '출고 등록')); await tester.pumpAndSettle(); + expect(find.text('저장 시 자동 생성'), findsAtLeastNWidgets(2)); + await tester.tap(find.widgetWithText(ShadButton, '저장')); await tester.pump(); - expect(find.text('거래번호를 입력하세요.'), findsOneWidget); - expect(find.text('결재번호를 입력하세요.'), findsOneWidget); + expect(find.text('거래번호를 입력하세요.'), findsNothing); + expect(find.text('결재번호를 입력하세요.'), findsNothing); + expect(find.textContaining('자동완성에서 선택'), findsOneWidget); + expect(find.text('창고를 선택하세요.'), findsOneWidget); }); } diff --git a/test/features/inventory/rental_page_test.dart b/test/features/inventory/rental_page_test.dart index bf0ffff..6e701f5 100644 --- a/test/features/inventory/rental_page_test.dart +++ b/test/features/inventory/rental_page_test.dart @@ -25,7 +25,7 @@ void main() { await GetIt.I.reset(); }); - testWidgets('대여 등록 모달은 거래번호와 결재번호를 필수로 요구한다', (tester) async { + testWidgets('대여 등록 모달은 번호 입력 없이 자동 생성 안내를 제공한다', (tester) async { final view = tester.view; view.physicalSize = const Size(1280, 900); view.devicePixelRatio = 1.0; @@ -54,10 +54,14 @@ void main() { await tester.tap(find.widgetWithText(ShadButton, '대여 등록')); await tester.pumpAndSettle(); + expect(find.text('저장 시 자동 생성'), findsAtLeastNWidgets(2)); + await tester.tap(find.widgetWithText(ShadButton, '저장')); await tester.pump(); - expect(find.text('거래번호를 입력하세요.'), findsOneWidget); - expect(find.text('결재번호를 입력하세요.'), findsOneWidget); + expect(find.text('거래번호를 입력하세요.'), findsNothing); + expect(find.text('결재번호를 입력하세요.'), findsNothing); + expect(find.textContaining('자동완성에서 선택'), findsOneWidget); + expect(find.text('최소 1개의 고객사를 선택하세요.'), findsOneWidget); }); } diff --git a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart index 98d82b3..c943cd3 100644 --- a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart +++ b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart @@ -164,6 +164,11 @@ void main() { ), ], customers: [TransactionCustomerCreateInput(customerId: 7)], + approval: StockTransactionApprovalInput( + requestedById: 9, + approvalStatusId: 5, + note: '승인 요청', + ), ); await repository.create(input); @@ -185,6 +190,12 @@ void main() { expect(payload['created_by_id'], 9); expect(payload['lines'], isA()); expect(payload['customers'], isA()); + expect(payload.containsKey('transaction_no'), isFalse); + final approvalPayload = payload['approval'] as Map; + expect(approvalPayload.containsKey('approval_no'), isFalse); + expect(approvalPayload['requested_by_id'], 9); + expect(approvalPayload['approval_status_id'], 5); + expect(approvalPayload['note'], '승인 요청'); }); test('submit은 /submit 엔드포인트를 호출한다', () async { diff --git a/test/features/masters/customer/data/customer_repository_remote_test.dart b/test/features/masters/customer/data/customer_repository_remote_test.dart index 4da8def..f8b1942 100644 --- a/test/features/masters/customer/data/customer_repository_remote_test.dart +++ b/test/features/masters/customer/data/customer_repository_remote_test.dart @@ -14,11 +14,28 @@ void main() { setUpAll(() { registerFallbackValue(Options()); registerFallbackValue(CancelToken()); + registerFallbackValue( + Response(requestOptions: RequestOptions(path: '/')), + ); }); setUp(() { apiClient = _MockApiClient(); repository = CustomerRepositoryRemote(apiClient: apiClient); + when(() => apiClient.unwrapAsMap(any())).thenAnswer((invocation) { + final response = invocation.positionalArguments.first; + if (response is Response) { + final data = response.data; + if (data is Map) { + final nested = data['data']; + if (nested is Map) { + return nested; + } + return data; + } + } + return {}; + }); }); test('list 호출 시 필터를 쿼리에 포함한다', () async {