From b3da3a5c60bab2ff91252c408d1bdd403252bcaa Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 17 Oct 2025 16:09:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=B0=8F=20=EA=B2=B0=EC=9E=AC=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/backup/backend_change_requests.md | 64 ++- doc/frontend_backend_alignment_report.md | 66 +++ doc/stock_approval_system_api_v4.md | 521 +++++++++++++----- .../domain/entities/approval_step_input.dart | 7 +- .../step/domain/approval_step_input_test.dart | 48 ++ 5 files changed, 544 insertions(+), 162 deletions(-) create mode 100644 doc/frontend_backend_alignment_report.md create mode 100644 test/features/approvals/step/domain/approval_step_input_test.dart diff --git a/doc/backup/backend_change_requests.md b/doc/backup/backend_change_requests.md index 41b2906..8052777 100644 --- a/doc/backup/backend_change_requests.md +++ b/doc/backup/backend_change_requests.md @@ -16,52 +16,58 @@ ### 3.1 로그인/세션 및 대시보드 API 구현 - 엔드포인트 - - `POST /api/v1/auth/login`: `identifier`, `password`, `remember_me`를 받아 Access/Refresh 토큰, 만료 시각, 사용자 요약(`employee`)을 반환. - - `POST /api/v1/auth/refresh`: `refresh_token`으로 토큰을 갱신. - - `GET /api/v1/dashboard/summary`: KPI(inbound/outbound/pending_approvals/customer_inquiries), 결재 대기 목록, 기간별 전표 요약, `generated_at`을 포함한 JSON 반환. + - `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 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 한다. - 요구 사항 - - 응답 포맷은 `{ "data": { ... } }` 컨벤션을 따른다. - - 인증 실패/세션 만료 케이스별 HTTP 코드(401/403)와 메시지 정책을 문서화한다. + - `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` - - 응답: 파일 스트림 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at` - - 권한/감사 로그 정책을 명확히 할 것. + - 공통 쿼리: `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 코드·메시지를 반환하도록 문서화한다. + - 모든 다운로드 요청에 대해 접근 권한·감사 로그 정책을 명시한다. ### 3.3 결재/재고 응답 스키마 정합성 -- 결재 요약 응답의 필드명을 다음과 같이 정렬: - - `approval_status` → `status` - - `requested_by` → `requester` - - `current_step.step_status` → `current_step.status` - - `histories[].approval_action_id` 대신 `histories[].action { id, name }`을 포함 -- 재고 트랜잭션 응답은 헤더/라인/고객 정보를 도메인 모델 구조에 맞게 중첩 객체로 내려준다. - - 헤더: `transaction_type { id, name }`, `transaction_status { id, name }`, `warehouse { id, warehouse_code, warehouse_name }`, `created_by { id, employee_no, employee_name }` - - 라인: `product { id, product_code, product_name, vendor { id, vendor_name }, uom { id, uom_name } }` - - 고객: `customer { id, customer_code, customer_name }` -- 상태 전환(Submit/Approve/Reject/Cancel/Complete) 응답에 최신 `data.transaction` 혹은 최소한 `data.transaction_status`와 `data.updated_at`을 포함. +- 결재 목록·단건 응답은 프런트 도메인 모델과 동일한 키를 사용한다. + - 단건 응답은 `{ "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가 즉시 갱신되도록 한다. ### 3.4 결재 단계/행위 API 정합성 -- `GET /api/v1/approval-steps`에 다음 필터를 지원: - - `approver_id`, `approval_id`, `status_id`(또는 `step_status_id`), `q`(결재번호/승인자 키워드) -- 단계/액션/일괄 배정 API는 204 대신 갱신된 결재 전체(`data.approval`) 또는 단계 리스트를 JSON으로 반환. -- 응답에 단계가 속한 템플릿명(`approval.template_name`)을 포함. +- `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.5 그룹-메뉴 권한 응답 확장 -- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`를 추가. -- 삭제 항목 조회 시 `deleted=true` 파라미터 혹은 `include_deleted=true` 별칭을 허용하고, 응답에 `is_deleted`를 포함. -- `include=group` 파라미터를 공식 문서화하여 그룹 정보를 함께 반환. +- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`(가능하면 `path` 보조 필드 포함)를 항상 채운다. +- `deleted=true`(또는 `include_deleted=true`) 파라미터를 허용해 소프트 삭제 항목을 조회할 수 있게 하고, 응답 항목에 `is_deleted`를 노출한다. +- `include=group,menu` 확장을 공식화해 그룹/메뉴 요약을 한 번에 받을 수 있도록 한다. ### 3.6 결재 생성/수정 응답 보강 -- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답에 최신 결재 요약(`data.approval`)과 단계 리스트(`data.approval.steps`)를 포함. -- `approval_status_id`가 요청에 없을 경우 기본 대기 상태를 할당하는 규칙을 문서화하고, 결재번호 중복/포맷 검증 로직을 명확히 한다. +- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답은 `{ "data": { "approval": { ... } } }` 형태로 최신 결재 요약과 `steps[]`, 필요 시 `histories[]`를 포함해야 한다. +- `approval_status_id`가 생략되면 자동으로 기본 대기 상태를 설정하는 규칙을 명시하고, `approval_no` 포맷·중복 검증 실패 시 상세 에러 메시지를 반환한다. ### 3.7 응답/에러 문서화 및 테스트 -- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 반영. -- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가. +- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 모두 반영하고, 인증/대시보드/결재 단계/보고서 섹션을 최신 상태로 유지한다. +- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가한다. ## 4. 수용 기준 - 상기 엔드포인트 및 스키마 변경이 구현되고, 요청/응답이 문서와 일치해야 한다. diff --git a/doc/frontend_backend_alignment_report.md b/doc/frontend_backend_alignment_report.md new file mode 100644 index 0000000..c5f48d3 --- /dev/null +++ b/doc/frontend_backend_alignment_report.md @@ -0,0 +1,66 @@ +# 프런트엔드/백엔드 정합성 점검 리포트 (2025-10-21) + +## 개요 +- 기준 문서: `doc/backup/backend_change_requests.md`와 최신 계약 문서(`doc/stock_approval_system_api_v4.md`)를 토대로 Flutter 프런트(`superport_v2`)와 Rust 백엔드(`superport_api_v2`) 구현을 재검증했다. +- 백엔드 팀이 전달한 최신 패치(로그인/트랜잭션, 결재 단계, 대시보드·보고서, 권한)와 `cargo test` 통과 결과를 반영해 실제 로그인 → 대시보드 → 재고/결재 → 보고서/권한 흐름을 다시 점검했다. + +## 주요 정합성 결과 +| 구분 | 내용 | 결과 | 후속 조치 | +| --- | --- | --- | --- | +| 1 | 대여/출고 `expected_return_date` 저장·조회 | ✅ 해결 (`backend/src/domain/stock_transactions.rs:274`, `backend/src/adapters/repositories/stock_transactions.rs:808`) | 프런트 DTO·폼이 필드를 유지하는지 위젯 테스트로 확인 | +| 2 | 결재 단계 `q`·`status_id` 필터 및 `status` 응답 | ✅ 해결 (`backend/src/domain/approval_steps.rs:31`, `backend/src/adapters/repositories/approval_steps.rs:162`) | 검색/필터 UI와 리스트 표시가 새 계약(`status`)을 반영하는지 시나리오 테스트 필요 | +| 3 | 결재 단계 요청/응답 내 상태 필드 정규화 | ✅ 해결 (요청 `status_id`, 응답 `status`) | 프런트 DTO(`lib/features/approvals/step/domain/entities/approval_step_input.dart:30`)에서 레거시 `step_status_id` 제거 여부 확인 | +| 4 | 보고서 PDF 스트리밍·메타데이터 생성 | ✅ 해결 (`backend/src/api/v1/reports.rs:94`) | `ReportingRepositoryRemote`가 스트림·파일명 메타 처리하는지 합동 점검 | +| 5 | 그룹-메뉴 권한 `path`·`is_deleted`·`include_deleted` | ✅ 해결 (`backend/src/domain/group_menu_permissions.rs:149`, `backend/src/adapters/repositories/group_menu_permissions.rs:227`) | DTO/필터·권한 편집 UI가 추가 필드로 회귀 없는지 테스트 | +| 6 | 대시보드 KPI `delta` 전일 대비 비율 계산 | ✅ 해결 (`backend/src/adapters/repositories/dashboard.rs:61`) | KPI 카드/차트가 백분율·부호 표시를 지원하는지 확인 | + +아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다. + +## 로그인 & 세션 +- 변경 없음: 로그인/세션 API는 기존 계약과 동일하며(`backend/src/api/v1/login.rs`), 프런트 `AuthSessionDto` 매핑도 변동이 없다(`lib/features/auth/data/dtos/auth_session_dto.dart:17`). +- 체크포인트: 세션 만료 401 처리 시 백엔드 토큰 갱신 로직은 유지되므로, 프런트 재시도/로그아웃 UX를 QA 체크리스트에 유지한다. +- 추가 확인: `POST /api/v1/auth/refresh` 오류 메시지가 문서 규격(`token expired`, `invalid token`)으로 일치하는지 스테이징 로그로 검증한다. 메시지 표준화가 미완료인 경우 `Failure` 매퍼에서 임시 매핑을 추가해야 한다. + +## 대시보드 +- KPI `delta`가 전일 대비 증감률(예: `0.125` → 12.5%)로 채워지며(`backend/src/adapters/repositories/dashboard.rs:61`), 프런트는 % 포맷과 부호를 고려해 렌더링해야 한다(`lib/features/dashboard/presentation/widgets/dashboard_kpi_card.dart`). +- `step_summary` 포맷이 `"2단계 / 승인자"`에서 `"2단계 · 승인자"`로 정규화됐다. 문자열을 그대로 노출하는 UI라면 디자인팀과 표시 규칙을 다시 합의한다. +- 추가 활동: 대시보드 테스트에서 `delta != null` 기준으로 동작하는 메트릭 뱃지/차트 회귀 여부를 확인한다. + +## 재고·대여 트랜잭션 +- `expected_return_date`가 생성/수정/조회 전 흐름에 포함된다(`backend/src/domain/stock_transactions.rs:274`, `backend/src/adapters/repositories/stock_transactions.rs:808`). 프런트 `StockTransactionInput`과 `RentalPage`는 이미 필드를 전송하므로, 저장 후 상세/목록에서 값이 노출되는지 UI 테스트를 추가하면 된다(`lib/features/inventory/rental/presentation/pages/rental_page.dart:1651`). +- 마이그레이션 `migration/006_add_expected_return_date_to_stock_transactions.sql`을 반드시 적용해야 하며, 로컬/스테이징 DB에 컬럼이 없으면 500 에러가 발생한다. DevOps와 일정 합의 후 `diesel migration run`을 실행하고 `.env` DB URL을 재확인한다. +- 추가 확인: 고객 정보(`customers[].customer`), 거래 라인 메모, 템플릿명 등 선택 필드가 null일 때 키가 빠지지 않는지 샘플 데이터를 확보해 양쪽 DTO 직렬화/역직렬화 테스트를 보강한다. + +## 결재 단계 +- 목록 API가 `q`·`status_id` 필터를 처리하고 응답에 `transaction_no`를 포함한다(`backend/src/adapters/repositories/approval_steps.rs:176`). 프런트 검색 바(`lib/features/approvals/step/presentation/controllers/approval_step_controller.dart`)가 두 파라미터를 전달하는지, 리스트에서 거래번호를 표시하는지 확인한다. +- 도메인이 `status` 구조체(`{ id, name, code }`)를 반환한다(`backend/src/domain/approval_steps.rs:84`). 프런트 DTO는 `status_id` 입력과 `status` 응답을 모두 지원해야 하므로, 레거시 필드 제거와 단위 테스트(`test/features/approvals/step/domain/`) 성공 여부를 점검한다. +- 컨트롤러/위젯 테스트: 필터링, 상태 변경, 거래번호 표시 흐름을 추가해 회귀를 방지한다. +- 추가 확인: `histories[].action`에 레거시 데이터가 들어오는 경우(`id`, `name` 누락) 프런트가 안전하게 폴백 문자열을 표시하는지, 백엔드는 해당 케이스를 데이터 정제 로직으로 보완할지 정한다. + +## 보고서 (PDF) +- 백엔드가 PDF를 스트리밍으로 내려주고 파일명·Content-Length·ETag를 헤더에 포함한다(`backend/src/api/v1/reports.rs:94`). 프런트 `ReportingRepositoryRemote`는 `StreamedResponse` 처리를 유지하되, 새 메타데이터(`report_name`, `generated_at`)로 다운로드 UI를 업데이트한다(`lib/features/reporting/presentation/controllers/reporting_controller.dart`). +- 단위 테스트(`backend/tests/api_reports_pdf.rs`)가 계약을 고정하고 있으므로, 프런트에서도 PDF 다운로드 및 실패 경로(404/500 등)를 위젯 테스트에 반영한다. +- 추가 확인: PDF 다운로드 요청이 감사 로그에 기록되는지 스테이징에서 확인하고, 정책상 필요 시 프런트 다운로드 성공/실패 토스트에 감사 로그 연동 여부를 표시한다. + +## 권한/문서 +- 그룹-메뉴 권한 API가 `include_deleted=true` 시 삭제 항목을 함께 반환하고 각 항목에 `path`, `is_deleted`가 포함된다(`backend/src/domain/group_menu_permissions.rs:149`). 프런트 DTO(`lib/features/masters/group_permission/data/dtos/group_permission_dto.dart:49`)와 편집 UI가 새 필드를 사용하는지 확인한다. +- `doc/stock_approval_system_api_v4.md`가 갱신됐으므로, 프런트 문서는 `tool/sync_stock_docs.sh`로 재동기화한다. +- 추가 확인: 그룹-메뉴 배치 업데이트 API가 변경 이력을 남기는지 백엔드 로그로 점검하고, 프런트 편집 시 이력 누락에 대비한 사용자 안내를 준비한다. + +## 공동 액션 아이템 +| 구분 | 작업 내용 | 담당 | 상태 | 비고 | +| --- | --- | --- | --- | --- | +| DB | `006_add_expected_return_date_to_stock_transactions.sql` 적용 확인 | 백엔드 | 진행 예정 | 스테이징 DB 스키마 점검 후 공유 | +| 결재 | 단계 검색(`q`, `status_id`)·거래번호 노출 통합 테스트 | 프런트/백엔드 | 준비 | 계약 데이터 샘플 확보 필요 | +| 결재 | `histories.action` 레거시 데이터 폴백 처리 협의 | 프런트/백엔드 | 준비 | 데이터 정제 vs UI 폴백 선택 | +| 보고서 | Approvals/Transactions PDF 스트리밍 합동 점검 | 프런트/백엔드 | 준비 | 대용량 파일·감사 로그 확인 | +| 보고서 | 감사 로그 정책 준수 여부 재확인 | 백엔드 | 준비 | 정책 준수 결과 문서화 | +| QA | `flutter analyze`, `flutter test --coverage` 회귀 실행 후 공유 | 프런트 | 준비 | DTO/테스트 수정 후 `notify.py` 발송 | +| QA | `cargo test` + 통합 시나리오 스크립트 재실행 | 백엔드 | 준비 | 보고서/결재 단계 회귀 포함 | + +## 테스트 & 다음 단계 +- 백엔드 `cargo test` 통과 보고가 공유됐지만, 프런트 QA 관점에서는 다음을 진행한다. + - 새 마이그레이션(`006_add_expected_return_date_to_stock_transactions.sql`) 적용 → 스테이징 DB 반영 상태 확인. + - 결재 단계 검색(`q`, `status_id`), 거래번호 노출, 보고서 PDF 다운로드를 프런트/백엔드 합동 점검. + - `flutter analyze`, `flutter test --coverage`로 DTO·테스트 변경 이후 회귀 여부 확인. +- 모든 작업을 마치면 `notify.py` 워크플로를 통해 완료 알림을 발송한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index 736f9e2..55c0f0e 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -453,16 +453,20 @@ "transaction_no": "TXN-2025-0001", "transaction_type": { "id": 1, - "type_name": "입고" + "name": "입고" }, "transaction_status": { "id": 1, - "status_name": "초안" + "name": "초안" }, "warehouse": { "id": 1, "warehouse_code": "WH-001", - "warehouse_name": "1센터" + "warehouse_name": "1센터", + "zipcode": { + "zipcode": "06000", + "road_name": "테헤란로" + } }, "transaction_date": "2025-09-18", "created_by": { @@ -474,6 +478,7 @@ "is_active": true, "created_at": "2025-09-18T05:00:00Z", "updated_at": "2025-09-18T05:00:00Z", + "expected_return_date": "2025-09-30", "lines": [ { "id": 12001, @@ -499,46 +504,50 @@ "customers": [ { "id": 301, - "customer_code": "C001", - "customer_name": "ABC물류", - "contact_name": "박담당" + "customer": { + "id": 301, + "customer_code": "C001", + "customer_name": "ABC물류" + }, + "note": null } ], "approval": { "id": 5001, "approval_no": "APP-2025-0001", - "approval_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, "current_step": { "id": 7001, "step_order": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" - }, - "step_status": { - "id": 1, - "status_name": "대기", - "is_blocking_next": true, - "is_terminal": false + "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null }, - "requested_by": { + "requester": { "id": 7, "employee_no": "E2025001", - "employee_name": "김승인" + "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "입고 결재", + "template_name": "입고 결재 기본", "is_active": true, "created_at": "2025-09-18T06:00:00Z", "updated_at": "2025-09-18T06:05:00Z" @@ -560,11 +569,11 @@ "transaction_no": "TXN-2025-0001", "transaction_type": { "id": 1, - "type_name": "입고" + "name": "입고" }, "transaction_status": { "id": 1, - "status_name": "초안" + "name": "초안" }, "warehouse": { "id": 1, @@ -572,7 +581,8 @@ "warehouse_name": "1센터", "zipcode": { "zipcode": "06000", - "sido": "서울특별시" + "sido": "서울특별시", + "road_name": "테헤란로" } }, "transaction_date": "2025-09-18", @@ -585,6 +595,7 @@ "is_active": true, "created_at": "2025-09-18T05:00:00Z", "updated_at": "2025-09-18T05:00:00Z", + "expected_return_date": "2025-09-30", "lines": [ { "id": 12001, @@ -610,17 +621,21 @@ "customers": [ { "id": 301, - "customer_code": "C001", - "customer_name": "ABC물류", - "contact_name": "박담당" + "customer": { + "id": 301, + "customer_code": "C001", + "customer_name": "ABC물류" + }, + "note": null } ], "approval": { "id": 5001, "approval_no": "APP-2025-0001", - "approval_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", + "color": "#F97316", "is_blocking_next": true, "is_terminal": false }, @@ -630,11 +645,11 @@ "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" + "name": "박검토" }, - "step_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, @@ -642,14 +657,58 @@ "decided_at": null, "note": null }, - "requested_by": { + "requester": { "id": 7, "employee_no": "E2025001", - "employee_name": "김승인" + "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "입고 결재", + "template_name": "입고 결재 기본", + "steps": [ + { + "id": 7001, + "step_order": 1, + "approver": { + "id": 21, + "employee_no": "E2025002", + "name": "박검토" + }, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "assigned_at": "2025-09-18T06:05:00Z", + "decided_at": null, + "note": null + } + ], + "histories": [ + { + "id": 91001, + "action": { + "id": 1, + "name": "상신" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 7, + "employee_no": "E2025001", + "name": "김승인" + }, + "action_at": "2025-09-18T06:00:00Z", + "note": null + } + ], "is_active": true, "created_at": "2025-09-18T06:00:00Z", "updated_at": "2025-09-18T06:05:00Z" @@ -810,17 +869,17 @@ "approval": { "id": 5001, "approval_no": "APP-2025-0001", - "approval_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, "current_step": null, - "requested_by": { + "requester": { "id": 7, "employee_no": "E2025001", - "employee_name": "김승인" + "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, @@ -848,61 +907,86 @@ "id": 9001, "transaction_no": "TXN-2025-0001" }, - "approval_status": { + "status": { "id": 1, - "status_name": "대기", - "is_blocking_next": true + "name": "대기", + "color": "#F97316", + "is_blocking_next": true, + "is_terminal": false }, "current_step": { "id": 7001, "step_order": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" - }, - "step_status": { - "id": 1, - "status_name": "대기", - "is_blocking_next": true, - "is_terminal": false + "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null }, - "requested_by": { + "requester": { "id": 7, "employee_no": "E2025001", - "employee_name": "김승인" + "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "입고 결재", "is_active": true, + "is_deleted": false, "created_at": "2025-09-18T06:00:00Z", - "updated_at": "2025-09-18T06:00:00Z", + "updated_at": "2025-09-18T06:05:00Z", "steps": [ { "id": 7001, "step_order": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" - }, - "step_status": { - "id": 1, - "status_name": "대기", - "is_blocking_next": true, - "is_terminal": false + "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null } ], - "histories": [] + "histories": [ + { + "id": 91001, + "action": { + "id": 1, + "name": "상신" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 7, + "employee_no": "E2025001", + "name": "김승인" + }, + "action_at": "2025-09-18T06:00:00Z", + "note": null + } + ] } ], "page": 1, @@ -922,34 +1006,35 @@ "id": 9001, "transaction_no": "TXN-2025-0001" }, - "approval_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", + "color": "#F97316", "is_blocking_next": true, "is_terminal": false }, "current_step": { "id": 7001, "step_order": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" - }, - "step_status": { - "id": 1, - "status_name": "대기", - "is_blocking_next": true, - "is_terminal": false + "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null }, - "requested_by": { + "requester": { "id": 7, "employee_no": "E2025001", - "employee_name": "김승인" + "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, @@ -958,22 +1043,47 @@ { "id": 7001, "step_order": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" - }, - "step_status": { - "id": 1, - "status_name": "대기", - "is_blocking_next": true + "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null } ], - "histories": [], + "histories": [ + { + "id": 91001, + "action": { + "id": 1, + "name": "상신" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 7, + "employee_no": "E2025001", + "name": "김승인" + }, + "action_at": "2025-09-18T06:00:00Z", + "note": null + } + ], + "is_active": true, + "is_deleted": false, "created_at": "2025-09-18T06:00:00Z", "updated_at": "2025-09-18T06:05:00Z" } @@ -1011,6 +1121,12 @@ "step_order": 1, "approver_id": 21, "step_status_id": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null, @@ -1022,6 +1138,12 @@ "step_order": 2, "approver_id": 34, "step_status_id": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": "재무 확인", @@ -1030,9 +1152,9 @@ ], "approval": { "id": 5001, - "approval_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, @@ -1040,6 +1162,7 @@ "id": 7001, "step_order": 1 }, + "template_name": "입고 결재 기본", "updated_at": "2025-09-18T06:05:00Z" } } @@ -1077,6 +1200,12 @@ "step_order": 1, "approver_id": 21, "step_status_id": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": "서류 확인 중", @@ -1088,6 +1217,12 @@ "step_order": 2, "approver_id": 35, "step_status_id": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": "재무 확인", @@ -1096,9 +1231,9 @@ ], "approval": { "id": 5001, - "approval_status": { + "status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, @@ -1106,6 +1241,7 @@ "id": 7001, "step_order": 1 }, + "template_name": "입고 결재 기본", "updated_at": "2025-09-18T06:10:00Z" } } @@ -1127,9 +1263,9 @@ "data": { "approval": { "id": 5001, - "approval_status": { + "status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false }, @@ -1139,11 +1275,11 @@ "approver": { "id": 34, "employee_no": "E2025003", - "employee_name": "최검토" + "name": "최검토" }, - "step_status": { + "status": { "id": 3, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false }, @@ -1155,18 +1291,21 @@ "histories": [ { "id": 91001, - "approval_action_id": 1, + "action": { + "id": 1, + "name": "승인" + }, "action_at": "2025-09-18T08:05:00Z", "note": "승인합니다.", "from_status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, "to_status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false } @@ -1179,15 +1318,15 @@ "step_order": 1, "approver_id": 21, "step_status_id": 2, - "assigned_at": "2025-09-18T06:05:00Z", - "decided_at": "2025-09-18T08:05:00Z", - "note": "승인합니다.", - "step_status": { + "status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false - } + }, + "assigned_at": "2025-09-18T06:05:00Z", + "decided_at": "2025-09-18T08:05:00Z", + "note": "승인합니다." }, "next_step": { "id": 7002, @@ -1195,11 +1334,11 @@ "approver": { "id": 34, "employee_no": "E2025003", - "employee_name": "최검토" + "name": "최검토" }, - "step_status": { + "status": { "id": 3, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false }, @@ -1210,7 +1349,10 @@ "history": { "id": 91001, "approval_step_id": 7001, - "approval_action_id": 1, + "action": { + "id": 1, + "name": "승인" + }, "note": "승인합니다.", "action_at": "2025-09-18T08:05:00Z" } @@ -1247,9 +1389,9 @@ "approval": { "id": 5001, "approval_no": "APP-2025-0001", - "approval_status": { + "status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false }, @@ -1257,14 +1399,15 @@ "id": 7002, "step_order": 2 }, - "requested_by": { + "requester": { "id": 7, "employee_no": "E2025001", - "employee_name": "김승인" + "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "보류 처리", + "template_name": "입고 결재 기본", "updated_at": "2025-09-18T08:10:00Z" } } @@ -1282,7 +1425,10 @@ "id": 91001, "approval_id": 5001, "approval_step_id": 7001, - "approval_action_id": 3, + "action": { + "id": 3, + "name": "보류" + }, "action_at": "2025-09-18T08:05:00Z", "note": "보류 코멘트", "approver": { @@ -1292,22 +1438,22 @@ }, "from_status": { "id": 1, - "status_name": "대기", + "name": "대기", "is_blocking_next": true, "is_terminal": false }, "to_status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "approval": { "id": 5001, "approval_no": "APP-2025-0001", - "approval_status": { + "status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false } @@ -1319,7 +1465,13 @@ "approver": { "id": 21, "employee_no": "E2025002", - "employee_name": "박검토" + "name": "박검토" + }, + "status": { + "id": 2, + "name": "진행중", + "is_blocking_next": true, + "is_terminal": false } } } @@ -1331,8 +1483,8 @@ ``` ### 5.10 단계 개별 CRUD -- `GET /approval-steps?approval_id=5001&include=approver,step_status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 `approval`, `approver`, `step_status` 서브 오브젝트를 선택적으로 포함한다. -- `GET /approval-steps/7001?include=approval,approver,step_status` → `{ data: { ... } }`. +- `GET /approval-steps?approval_id=5001&include=approval,approver,status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 `approval`, `approver`, `status` 서브 오브젝트를 선택적으로 포함한다. +- `GET /approval-steps/7001?include=approval,approver,status` → `{ data: { ... } }`. - `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `step_status_id`를 생략하면 자동으로 `대기` 상태가 지정된다. - `PATCH /approval-steps/{id}` → 갱신된 단계 요약을 반환한다. - `DELETE /approval-steps/{id}` → `{ data: { id, deleted_at } }`. @@ -1352,7 +1504,10 @@ "id": 91001, "approval_id": 5001, "approval_step_id": 7001, - "approval_action_id": 3, + "action": { + "id": 3, + "name": "보류" + }, "action_at": "2025-09-18T08:05:00Z", "note": "보류 코멘트", "approver": { @@ -1363,16 +1518,16 @@ "from_status": null, "to_status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "approval": { "id": 5001, "approval_no": "APP-2025-0001", - "approval_status": { + "status": { "id": 2, - "status_name": "진행중", + "name": "진행중", "is_blocking_next": true, "is_terminal": false } @@ -1385,6 +1540,12 @@ "id": 21, "employee_no": "E2025002", "employee_name": "박검토" + }, + "status": { + "id": 2, + "name": "진행중", + "is_blocking_next": true, + "is_terminal": false } } } @@ -1512,36 +1673,132 @@ --- ## 7. 보고서 Export -- `format=xlsx|pdf` 파라미터를 지원한다. 현재 구현은 XLSX 다운로드만 제공하며, `format=pdf` 요청 시 400 Bad Request를 반환한다. -- `delivery=stream|metadata`(기본값 `stream`) 파라미터를 지원한다. - - `delivery=metadata` 요청 시 다운로드 메타데이터를 반환한다. - `GET /reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&delivery=metadata` - ```json - { - "data": { - "download_url": "/api/v1/reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&delivery=stream&exported_at=2025-09-30T12:00:00Z", - "filename": "transactions_export_20250930120000.xlsx", - "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "expires_at": "2025-09-30T12:15:00Z" - } +- 공통 쿼리 파라미터: `from`, `to`, `format=xlsx|pdf`, `transaction_status_id`, `approval_status_id`, `requested_by_id`. 필요 시 `delivery=metadata`를 전달하면 스트리밍 대신 다운로드 메타데이터(JSON)를 반환한다. +- 기본 응답(`delivery=stream` 또는 파라미터 생략)은 `Content-Type`을 포맷에 맞춰 설정하고 `Content-Disposition: attachment; filename="<파일명>"` 헤더를 포함한 바이트 스트림이다. +- `delivery=metadata` 응답 예: + `GET /reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&delivery=metadata` + ```json + { + "data": { + "download_url": "/api/v1/reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&format=xlsx", + "filename": "transactions_export_20250930120000.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "expires_at": "2025-09-30T12:15:00Z" } - ``` - `download_url`에는 `delivery=stream`과 `exported_at` 값이 포함되며, 해당 URL을 그대로 호출하면 동일한 파일명을 유지한 스트리밍 응답을 받을 수 있다. -- `delivery=stream` 요청(기본값)은 `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition: attachment` 헤더를 포함한 바이트 스트림을 반환한다. + } + ``` +- 감사 로그와 권한 검증은 모든 Export 호출에 공통 적용한다. ### 7.1 트랜잭션 Export `GET /reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&warehouse_id=1&requested_by_id=7&format=xlsx` -- 열 구성: `Transaction No`, `Transaction Date`, `Transaction Type`, `Status`, `Warehouse`, `Created By`, `Approval No`, `Approval Status` -- `approval_status_id`, `requested_by_id` 파라미터로 결재 상태·요청자 기준 필터링이 가능하다. +- `from`, `to`는 `yyyy-MM-dd` 형식으로 요청하며, 날짜는 트랜잭션 발생일 기준이다. +- 응답 열 구성: `Transaction No`, `Transaction Date`, `Transaction Type`, `Status`, `Warehouse`, `Created By`, `Approval No`, `Approval Status`. +- `approval_status_id`, `requested_by_id`, `transaction_status_id`로 필터링이 가능하다. ### 7.2 결재 Export -`GET /reports/approvals/export?approval_status_id=1&requested_by_id=7&from=2025-09-01T00:00:00Z&to=2025-09-30T23:59:59Z&format=xlsx` -- 열 구성: `Approval No`, `Approval Status`, `Transaction No`, `Requested By`, `Requested At`, `Decided At`, `Current Step Order`, `Current Step Approver` -- `from`, `to` 파라미터는 `requested_at` 기준 UTC 타임스탬프 필터로 동작한다. +`GET /reports/approvals/export?approval_status_id=1&requested_by_id=7&from=2025-09-01T00:00:00Z&to=2025-09-30T23:59:59Z&format=pdf` +- `from`, `to`는 ISO8601 UTC 문자열이며 `requested_at` 기준으로 필터링한다. +- 응답 열 구성: `Approval No`, `Approval Status`, `Transaction No`, `Requested By`, `Requested At`, `Decided At`, `Current Step Order`, `Current Step Approver`. --- -## 8. 구현 참고 +## 8. 인증 및 대시보드 API +- `POST /auth/login` + ```json + { + "identifier": "user@example.com", + "password": "Sup3rS3cret!", + "remember_me": true + } + ``` + 응답: + ```json + { + "data": { + "access_token": "", + "refresh_token": "", + "expires_at": "2025-09-18T09:00:00Z", + "user": { + "id": 7, + "name": "김승인", + "employee_no": "E2025001", + "email": "approver@example.com", + "primary_group": { + "id": 3, + "name": "물류팀" + } + }, + "permissions": [ + { + "resource": "/dashboard", + "actions": ["read"] + }, + { + "resource": "/approvals", + "actions": ["read", "update"] + } + ] + } + } + ``` +- `POST /auth/refresh` + ```json + { + "refresh_token": "" + } + ``` + 응답은 로그인과 동일한 스키마를 따른다. +- 인증 실패 시 `401`, 잠금·권한 오류 시 `403`을 반환하며 `{ "error": { "code": 401, "message": "...", "details": [...] } }` 형식을 유지한다. +- 토큰/계정 상태별 메시지 매핑 + - 잘못된 자격 증명: `invalid credentials` + - 비활성 계정 접근: `account is inactive` + - 만료된 토큰: `token expired` + - 재사용·서명 오류: `invalid token` + +### 8.1 대시보드 요약 +`GET /dashboard/summary` +```json +{ + "data": { + "generated_at": "2025-09-18T08:00:00Z", + "kpis": [ + { + "key": "inbound_today", + "label": "오늘 입고", + "value": 12, + "trend_label": "어제 대비", + "delta": 0.2 + }, + { + "key": "pending_approvals", + "label": "대기 결재", + "value": 5 + } + ], + "recent_transactions": [ + { + "transaction_no": "TXN-2025-0001", + "transaction_date": "2025-09-18", + "transaction_type": "입고", + "status_name": "상신", + "created_by": "김승인" + } + ], + "pending_approvals": [ + { + "approval_no": "APP-2025-0005", + "title": "출고 결재", + "step_summary": "2단계/3단계 진행중", + "requested_at": "2025-09-17T03:00:00Z" + } + ] + } +} +``` + +--- + +## 9. 구현 참고 - FK 요약 정보는 기본 응답에 포함하며, 상세 정보가 필요하면 `include` 파라미터를 활용해 확장한다. - 배열 기반 다건 작업은 전체를 트랜잭션 처리해야 한다. 실패 시 롤백하고 부분 처리 결과를 반환하지 않는다. - `is_active` 변경은 권한·결재 등의 즉시성 요구를 고려하여 관련 캐시를 무효화한다. diff --git a/lib/features/approvals/step/domain/entities/approval_step_input.dart b/lib/features/approvals/step/domain/entities/approval_step_input.dart index 5833f2b..1734162 100644 --- a/lib/features/approvals/step/domain/entities/approval_step_input.dart +++ b/lib/features/approvals/step/domain/entities/approval_step_input.dart @@ -27,7 +27,12 @@ class ApprovalStepInput { final payload = { 'step_order': stepOrder, 'approver_id': approverId, - if (statusId != null) 'status_id': statusId, + if (statusId != null) ...{ + 'status_id': statusId, + // 문서 계약은 `status_id`만 정의되어 있지만, 백엔드 운영 버전은 `step_status_id` + // 키도 필요하므로 두 키를 함께 전송해 상용·문서 계약을 모두 충족한다. + 'step_status_id': statusId, + }, if (assignedAt != null) 'assigned_at': assignedAt!.toUtc().toIso8601String(), if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(), diff --git a/test/features/approvals/step/domain/approval_step_input_test.dart b/test/features/approvals/step/domain/approval_step_input_test.dart new file mode 100644 index 0000000..51c0638 --- /dev/null +++ b/test/features/approvals/step/domain/approval_step_input_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart'; + +void main() { + group('ApprovalStepInput.toPayload', () { + test('statusId가 있으면 status_id와 step_status_id를 동시에 전송한다', () { + final assigned = DateTime.parse('2025-10-20T09:00:00Z'); + final decided = DateTime.parse('2025-10-21T10:30:00Z'); + final input = ApprovalStepInput( + approvalId: 42, + stepOrder: 1, + approverId: 7, + statusId: 3, + assignedAt: assigned, + decidedAt: decided, + note: '추가 확인 필요', + ); + + final payload = input.toPayload(); + + expect(payload['approval_id'], 42); + expect(payload['status_id'], 3); + expect(payload['step_status_id'], 3); + expect(payload['assigned_at'], assigned.toUtc().toIso8601String()); + expect(payload['decided_at'], decided.toUtc().toIso8601String()); + expect(payload['note'], '추가 확인 필요'); + }); + + test('선택 필드를 생략하면 페이로드에서 제외한다', () { + final input = ApprovalStepInput( + stepOrder: 2, + approverId: 11, + note: ' ', // 공백만 있는 노트는 제거된다. + ); + + final payload = input.toPayload(); + + expect(payload.containsKey('approval_id'), isFalse); + expect(payload.containsKey('status_id'), isFalse); + expect(payload.containsKey('step_status_id'), isFalse); + expect(payload.containsKey('assigned_at'), isFalse); + expect(payload.containsKey('decided_at'), isFalse); + expect(payload.containsKey('note'), isFalse); + expect(payload['step_order'], 2); + expect(payload['approver_id'], 11); + }); + }); +}