백엔드 계약 문서 동기화하고 DTO 파서 정합성 확장
This commit is contained in:
@@ -1,62 +1,73 @@
|
||||
# 백엔드 수정 요청서 (2025-10-16 갱신)
|
||||
|
||||
## 1. 배경
|
||||
- Flutter 프론트엔드(`superport_v2`)가 최신 백엔드(`superport_api_v2`)와 실연동을 준비하면서, 일부 엔드포인트가 미구현이거나 응답 스키마가 불완전해 화면 기능을 마무리하기 어렵다.
|
||||
- 프론트는 Clean Architecture 기반으로 이미 도메인/레포지토리 계층을 구성했으며, UI 스펙에 맞춰 API 계약을 준수해야 한다.
|
||||
- 본 문서는 백엔드 측 변경·추가 개발이 필요한 항목을 정리해 담당자에게 전달하기 위한 요청서이다.
|
||||
- Flutter 프런트엔드(`superport_v2`)와 최신 백엔드(`superport_api_v2`) 사이 계약을 점검한 결과, 다수의 엔드포인트가 미구현이거나 응답 스키마가 상이해 실사용 플로우를 마무리할 수 없다.
|
||||
- 프런트는 Clean Architecture 구조 및 DTO를 백엔드 스펙(v4)에 맞춰 구현한 상태이며, 실연동 전까지 계약 정합성을 확보해야 한다.
|
||||
- 본 문서는 백엔드 측 추가 개발/수정을 요청하기 위한 정리 문서이다.
|
||||
|
||||
## 2. 주요 이슈 요약
|
||||
- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 존재하지 않는다.
|
||||
- 결재 단계(`approval-steps`) 관련 API가 단계 CRUD 조회/생성 응답을 돌려주지 않아 프론트 단계 관리 화면을 구현할 수 없다.
|
||||
- 결재 단계 액션(`POST /approval-steps/{id}/actions`)과 승인 단계 일괄 등록/수정(`POST/PATCH /approvals/{id}/steps`)이 204만 반환해 프론트가 최신 결재 정보를 다시 받지 못한다.
|
||||
- 그룹-메뉴 권한 목록이 메뉴 `route_path` 정보를 제공하지 않아 권한 매니저가 라우트별 권한을 구성할 수 없다.
|
||||
- 로그인 및 대시보드 핵심 엔드포인트(`/api/v1/auth/**`, `/api/v1/dashboard/summary`)가 존재하지 않아 애플리케이션 초기 진입이 불가능하다.
|
||||
- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 미구현 상태다.
|
||||
- 결재·재고 API 응답 키가 프런트 DTO와 불일치하여 승인 상태, 요청자, 제품/벤더 정보 등이 전부 기본값으로 표시되며, 단계/상태 전환 이후 최신 데이터를 확보할 수 없다.
|
||||
- 결재 단계(`approval-steps`) API가 단계 CRUD/액션 수행 후 적절한 본문을 반환하지 않고, 목록 필터(승인자·상태·검색)도 지원하지 않는다.
|
||||
- 그룹-메뉴 권한 API가 라우팅 정보를 제공하지 않고, 삭제 항목 조회 파라미터가 프런트와 불일치해 권한 동기화가 깨진다.
|
||||
|
||||
## 3. 상세 요청
|
||||
|
||||
### 3.1 보고서 Export API 구현
|
||||
- 엔드포인트:
|
||||
### 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 반환.
|
||||
- 요구 사항
|
||||
- 응답 포맷은 `{ "data": { ... } }` 컨벤션을 따른다.
|
||||
- 인증 실패/세션 만료 케이스별 HTTP 코드(401/403)와 메시지 정책을 문서화한다.
|
||||
|
||||
### 3.2 보고서 Export API 구현
|
||||
- 엔드포인트
|
||||
- `GET /api/v1/reports/transactions/export`
|
||||
- `GET /api/v1/reports/approvals/export`
|
||||
- 요구 사항:
|
||||
- 쿼리 파라미터:
|
||||
- 트랜잭션: `from`, `to`(yyyy-MM-dd), `format`(xlsx|pdf), `transaction_status_id`, `approval_status_id`, `requested_by_id`
|
||||
- 결재: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `transaction_status_id`, `approval_status_id`, `requested_by_id`
|
||||
- 응답:
|
||||
- 파일 다운로드(바이트 스트림) 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at`을 포함한 JSON.
|
||||
- 인증/권한 정책 확정 후 문서화.
|
||||
- 요구 사항
|
||||
- 공통 쿼리: `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`
|
||||
- 권한/감사 로그 정책을 명확히 할 것.
|
||||
|
||||
### 3.2 결재 단계/행위 API 정합성 보강
|
||||
- `GET /api/v1/approval-steps` 및 단건/생성/수정/삭제/복구 API 구현이 필요하다. (프론트 `ApprovalStepRepository`가 CRUD 전체를 호출)
|
||||
- `POST /api/v1/approval-steps/{id}/actions`는 204 대신 갱신된 결재 본문 혹은 최소한 단계와 상태 변화를 반환해야 한다.
|
||||
- `POST|PATCH /api/v1/approvals/{id}/steps` 역시 204가 아닌
|
||||
- 변경된 `approval` 요약, 혹은
|
||||
- 새로 구성된 단계 리스트
|
||||
를 포함한 JSON 응답을 제공해 프론트가 재조회 없이 상태를 갱신할 수 있도록 해 달라.
|
||||
- 액션/단계 요청 본문은 `stock_approval_system_api_v4.md` 스펙과 동일하게 유지.
|
||||
### 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`을 포함.
|
||||
|
||||
### 3.3 그룹-메뉴 권한 응답 확장
|
||||
- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 다음 필드를 추가:
|
||||
- `route_path` (launched 메뉴일 경우 실제 라우트 경로)
|
||||
- 필요 시 `menu_code` 그대로 유지.
|
||||
- 선택적으로 `include=group` 파라미터를 지원해 그룹 요약을 함께 반환하면 Front 권한 동기화 시 재조회가 줄어든다.
|
||||
### 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`)을 포함.
|
||||
|
||||
### 3.4 응답/에러 문서화
|
||||
- 위 변경 사항이 반영되면 `stock_approval_system_api_v4.md`를 업데이트하고, 각 엔드포인트 예제 응답을 최신 상태로 반영한다.
|
||||
- 회귀 테스트(`cargo test` + 통합 시나리오 스크립트)가 변경된 계약을 검증하도록 보강한다.
|
||||
### 3.5 그룹-메뉴 권한 응답 확장
|
||||
- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`를 추가.
|
||||
- 삭제 항목 조회 시 `deleted=true` 파라미터 혹은 `include_deleted=true` 별칭을 허용하고, 응답에 `is_deleted`를 포함.
|
||||
- `include=group` 파라미터를 공식 문서화하여 그룹 정보를 함께 반환.
|
||||
|
||||
### 3.5 결재 생성/수정 API 정합성
|
||||
- `POST /api/v1/approvals`가 다음 요청 바디를 수용하도록 구현 필요:
|
||||
- 필수: `transaction_id`, `approval_no`, `approval_status_id`, `requested_by_id`
|
||||
- 선택: `note`
|
||||
- 응답에는 갱신된 결재 요약(`data.approval`)과 현재 단계/상태 정보가 포함돼야 프론트가 즉시 리스트를 재사용할 수 있다.
|
||||
- `PATCH /api/v1/approvals/{id}`는 본문에 `id`를 요구하고 `approval_status_id`, `note` 변경을 허용해야 한다. 응답은 최신 결재 정보를 반환해 상세 패널을 재조회 없이 갱신할 수 있도록 한다.
|
||||
- 결재번호(`approval_no`) 중복/포맷 검증과 기본 상태(예: 대기) 자동 할당 규칙을 API 스펙에 명시해 달라.
|
||||
### 3.6 결재 생성/수정 응답 보강
|
||||
- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답에 최신 결재 요약(`data.approval`)과 단계 리스트(`data.approval.steps`)를 포함.
|
||||
- `approval_status_id`가 요청에 없을 경우 기본 대기 상태를 할당하는 규칙을 문서화하고, 결재번호 중복/포맷 검증 로직을 명확히 한다.
|
||||
|
||||
### 3.7 응답/에러 문서화 및 테스트
|
||||
- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 반영.
|
||||
- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가.
|
||||
|
||||
## 4. 수용 기준
|
||||
- 상기 엔드포인트가 모두 구현되고, 요청/응답이 문서와 일치해야 한다.
|
||||
- 레거시 응답(204)에서 JSON 반환으로 변경될 경우, 클라이언트가 기대하는 키(`data.approval`, `data.steps` 등)를 포함해야 한다.
|
||||
- `cargo fmt`, `cargo check`, `cargo test` 및 기존 CI 파이프라인이 통과한다.
|
||||
- 상기 엔드포인트 및 스키마 변경이 구현되고, 요청/응답이 문서와 일치해야 한다.
|
||||
- 기존 204 응답은 JSON 응답으로 교체되고, 키(`data.approval`, `data.transaction` 등)가 프런트 기대와 동일해야 한다.
|
||||
- `cargo fmt`, `cargo check`, `cargo test` 및 CI 파이프라인이 통과한다.
|
||||
|
||||
## 5. 후속 조치
|
||||
- 백엔드 담당자가 일정/우선순위를 산출해 프론트 팀과 공유.
|
||||
- 구현 완료 후 샌드박스 환경에서 API 계약 검증 → 프론트엔드 실연동 착수.
|
||||
- 백엔드 담당자가 개발 일정·우선순위를 산출해 프런트 팀과 공유.
|
||||
- 구현 완료 후 샌드박스 환경에서 계약 검증 → 프런트엔드 실연동 검증 착수.
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
- 단계 4: 재고 트랜잭션 컨트롤러와 submit/complete 플로우가 API 호출로 전환됐고, 고객 필터/위젯에서 사용하던 정적 카탈로그를 제거하여 전 구간이 실데이터를 사용한다. 보고서 기능은 `ReportingRepositoryRemote` 기반으로 API에 연결돼 다운로드 링크/바이너리 응답을 모두 처리하며, UI는 진행 상태·에러·다운로드 액션(열기/URL 복사)을 제공한다.
|
||||
- 단계 5: 테이블 spec 분리는 완료됐고, 권한 경로 통일·Failure 파서 고도화·실패 메시지 통합·실제 API 플로우 검증이 잔여 과제로 남아 있다.
|
||||
|
||||
## 문서 동기화 규칙
|
||||
1. `superport_api_v2` 리포지터리의 `stock_approval_system_*.md` 문서를 단일 소스로 간주하고, 수정은 반드시 백엔드 리포지터리에서 먼저 수행한다.
|
||||
2. 백엔드 문서 변경 후 프론트 리포지터리 루트에서 `tool/sync_stock_docs.sh`를 실행해 `doc/` 경로를 갱신한다. CI 또는 로컬 검증 시에는 `tool/sync_stock_docs.sh --check`로 차이를 확인한다.
|
||||
3. 문서 차이가 감지되면 동기화 커밋을 생성하고 PR 본문에 백엔드 커밋 링크를 포함해 리뷰어가 출처를 추적할 수 있도록 한다.
|
||||
|
||||
## 0. 사전 준비 및 브랜치 전략
|
||||
1. 현재 백엔드 서버는 아직 기동되지 않았지만, 모든 기능은 실제 API 계약(`stock_approval_system_api_v4.md`)을 기준으로 구현한다.
|
||||
2. 프론트엔드 작업용 브랜치를 `feature/api-integration` 형태로 생성하고, 단계별 작업이 끝난 뒤 스쿼시 머지한다.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
## 0. 구현 현황 요약 (2025-09-18 기준)
|
||||
- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes`
|
||||
- 각 자원은 `/api/v1/<resource>` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다.
|
||||
- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`가 포함되며, FE는 이 값을 사용해 라우트별 권한 매핑을 완성해야 한다. `include=group` 쿼리를 추가하면 그룹 요약이 함께 반환돼 권한 매트릭스를 단일 호출로 구축할 수 있다.
|
||||
- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`가 포함되며, `include=group` 쿼리로 그룹 요약을 함께 받을 수 있다.
|
||||
- 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다.
|
||||
|
||||
---
|
||||
@@ -442,7 +442,9 @@
|
||||
블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다.
|
||||
|
||||
### 4.2 목록 조회
|
||||
`GET /stock-transactions?include=lines,customers,approval`
|
||||
`GET /stock-transactions?customer_id=301&include=lines,customers,approval`
|
||||
|
||||
- `customer_id` (optional, number): 지정한 고객이 연결된 트랜잭션만 반환한다. 다른 검색 파라미터와 조합 가능하며, `include=customers` 사용 시 선택 고객 정보가 응답에 유지된다.
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
@@ -801,7 +803,38 @@
|
||||
"note": "입고 결재"
|
||||
}
|
||||
```
|
||||
응답에는 `id`와 현재 단계 정보가 포함된다.
|
||||
응답:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"approval": {
|
||||
"id": 5001,
|
||||
"approval_no": "APP-2025-0001",
|
||||
"approval_status": {
|
||||
"id": 1,
|
||||
"status_name": "대기",
|
||||
"is_blocking_next": true,
|
||||
"is_terminal": false
|
||||
},
|
||||
"current_step": null,
|
||||
"requested_by": {
|
||||
"id": 7,
|
||||
"employee_no": "E2025001",
|
||||
"employee_name": "김승인"
|
||||
},
|
||||
"requested_at": "2025-09-18T06:00:00Z",
|
||||
"decided_at": null,
|
||||
"note": "입고 결재",
|
||||
"is_active": true,
|
||||
"created_at": "2025-09-18T06:00:00Z",
|
||||
"updated_at": "2025-09-18T06:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- `approval_no`는 활성 결재 기준으로 중복 불가하며(409 Conflict), 길이는 1~30자다.
|
||||
- 최초 생성 시 `approval_status_id`에는 `대기` 상태 ID를 전달하고, 서버는 동일 상태로 저장한다.
|
||||
- 단계나 이력이 존재하면 `data.approval.steps`, `data.approval.histories`가 함께 반환된다.
|
||||
|
||||
### 5.2 목록 조회
|
||||
`GET /approvals?include=steps,histories`
|
||||
@@ -1185,7 +1218,6 @@
|
||||
}
|
||||
```
|
||||
응답에는 전후 상태(`from_status`, `to_status`), 차기 단계 정보가 포함되며, `approval_histories`에 기록된다.
|
||||
프론트엔드는 204 응답이 아닌 위의 `{ "data": { ... } }` 본문을 소비해 화면 상태를 즉시 갱신해야 한다.
|
||||
|
||||
### 5.7 결재 상태 확인
|
||||
`GET /approvals/5001/can-proceed`
|
||||
@@ -1207,6 +1239,36 @@
|
||||
"approval_status_id": 2,
|
||||
"note": "보류 처리"
|
||||
}
|
||||
```
|
||||
응답은 `data.approval` 구조로 최신 요약을 반환한다.
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"approval": {
|
||||
"id": 5001,
|
||||
"approval_no": "APP-2025-0001",
|
||||
"approval_status": {
|
||||
"id": 2,
|
||||
"status_name": "진행중",
|
||||
"is_blocking_next": true,
|
||||
"is_terminal": false
|
||||
},
|
||||
"current_step": {
|
||||
"id": 7002,
|
||||
"step_order": 2
|
||||
},
|
||||
"requested_by": {
|
||||
"id": 7,
|
||||
"employee_no": "E2025001",
|
||||
"employee_name": "김승인"
|
||||
},
|
||||
"requested_at": "2025-09-18T06:00:00Z",
|
||||
"decided_at": null,
|
||||
"note": "보류 처리",
|
||||
"updated_at": "2025-09-18T08:10:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- `DELETE /approvals/5001`
|
||||
- `POST /approvals/5001/restore`
|
||||
@@ -1269,19 +1331,19 @@
|
||||
```
|
||||
|
||||
### 5.10 단계 개별 CRUD
|
||||
- `GET /approval-steps?approval_id=5001&include=approval,approver,step_status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 요청한 `include` 토큰에 따라 관련 결재/결재자/상태 요약을 포함한다.
|
||||
- `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: { ... } }`.
|
||||
- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { step, approval } }` 형태로 생성된 요약과 결재 상태를 반환한다. `step_status_id`를 생략하면 자동으로 `대기` 상태가 지정된다.
|
||||
- `POST /approval-steps/batch` → 여러 단계를 한 번에 생성·재정렬하며, 응답은 `{ data: { approval, steps, histories } }` 구조로 최신 결재 상태와 정렬된 단계 목록, 신규 이력 요약을 포함한다.
|
||||
- `PATCH /approval-steps/batch` → 다건 단계 수정/비활성화를 처리하고 `{ data: { approval, steps, histories } }` 본문으로 변경 결과를 반환한다.
|
||||
- `PATCH /approval-steps/{id}` → 갱신된 단계 요약과 함께 `{ data: { step, approval } }`를 반환한다.
|
||||
- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `step_status_id`를 생략하면 자동으로 `대기` 상태가 지정된다.
|
||||
- `PATCH /approval-steps/{id}` → 갱신된 단계 요약을 반환한다.
|
||||
- `DELETE /approval-steps/{id}` → `{ data: { id, deleted_at } }`.
|
||||
- `POST /approval-steps/{id}/restore` → `{ data: { id, restored_at } }`.
|
||||
|
||||
주요 필터 및 확장 파라미터(approval-steps):
|
||||
주요 필터 및 확장 파라미터:
|
||||
|
||||
- `approval_id`, `approver_id`, `step_status_id`
|
||||
- `include=approval,approver,step_status`
|
||||
- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`
|
||||
- `action_from`, `action_to` (ISO8601)
|
||||
- `sort=action_at|created_at|updated_at`, `order=asc|desc`
|
||||
- `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step` 토큰으로 확장
|
||||
|
||||
`GET /approval-histories/91001?include=approval,step`
|
||||
```json
|
||||
@@ -1329,8 +1391,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
`approval-histories` 엔드포인트는 `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`, `action_from`, `action_to`, `sort=action_at|created_at|updated_at`, `order=asc|desc` 파라미터와 `include=approval,step,approver,from_status,to_status` 확장을 지원한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 결재 템플릿 API
|
||||
@@ -1452,19 +1512,32 @@
|
||||
---
|
||||
|
||||
## 7. 보고서 Export
|
||||
- `format=xlsx|pdf` 파라미터를 지원한다. 현재는 `format=xlsx`만 성공하며, `format=pdf`를 지정하면 400 Bad Request가 반환된다.
|
||||
- 응답은 즉시 다운로드 스트림으로 전달되며 `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`와 `Content-Disposition: attachment` 헤더가 포함된다. 프론트엔드는 받은 바이트 스트림을 그대로 파일로 저장해야 한다.
|
||||
- `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"
|
||||
}
|
||||
}
|
||||
```
|
||||
`download_url`에는 `delivery=stream`과 `exported_at` 값이 포함되며, 해당 URL을 그대로 호출하면 동일한 파일명을 유지한 스트리밍 응답을 받을 수 있다.
|
||||
- `delivery=stream` 요청(기본값)은 `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition: attachment` 헤더를 포함한 바이트 스트림을 반환한다.
|
||||
|
||||
### 7.1 트랜잭션 Export
|
||||
`GET /api/v1/reports/transactions/export?from=2025-09-01&to=2025-09-30&transaction_status_id=2&approval_status_id=3&requested_by_id=7&format=xlsx`
|
||||
- 지원 쿼리: `from`, `to`, `transaction_status_id`, `approval_status_id`, `requested_by_id`, `format`.
|
||||
- 열 구성: `Transaction No`, `Transaction Date`, `Transaction Type`, `Status`, `Warehouse`, `Created By`, `Approval No`, `Approval Status`.
|
||||
`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` 파라미터로 결재 상태·요청자 기준 필터링이 가능하다.
|
||||
|
||||
### 7.2 결재 Export
|
||||
`GET /api/v1/reports/approvals/export?from=2025-09-01T00:00:00Z&to=2025-09-30T23:59:59Z&transaction_status_id=1&approval_status_id=1&requested_by_id=7&format=xlsx`
|
||||
- 지원 쿼리: `from`, `to`, `transaction_status_id`, `approval_status_id`, `requested_by_id`, `format`.
|
||||
- 열 구성: `Approval No`, `Approval Status`, `Transaction No`, `Requested By`, `Requested At`, `Decided At`, `Current Step Order`, `Current Step Approver`.
|
||||
- `from`, `to` 파라미터는 `requested_at` 기준 UTC 타임스탬프 범위 필터다.
|
||||
`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 타임스탬프 필터로 동작한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음).
|
||||
- 모든 직원은 **그룹**에 속하며(`employees.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
|
||||
- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false).
|
||||
- 고객사는 담당자 이름(`contact_name`)을 별도 관리하며 고객 응답에 항상 포함.
|
||||
- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능.
|
||||
- 모든 삭제는 **소프트 삭제**(`is_deleted=true`)이며, 삭제 시 `is_active=false`로 내림.
|
||||
|
||||
@@ -100,6 +101,8 @@ zipcodes ||--o{ customers : addressed
|
||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||
|
||||
> `zipcodes` 테이블이 우편번호 마스터를 보유하며, 참조 측 테이블은 `zipcode_id` → `zipcodes.id` FK만 저장한다. 응답 시에는 FK를 통해 조회한 우편번호 요약 정보를 포함한다.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `warehouses` (창고)
|
||||
@@ -112,7 +115,7 @@ zipcodes ||--o{ customers : addressed
|
||||
| id | 창고ID | bigint | - | identity | Y | Y | Y | - |
|
||||
| warehouse_code | 창고코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
||||
| warehouse_name | 창고명 | varchar | 100 | - | Y | | | |
|
||||
| zipcode | 우편번호 | varchar | 5 | - | N | | | zipcodes.zipcode |
|
||||
| zipcode_id | 우편번호ID | bigint | - | - | N | | | zipcodes.id |
|
||||
| address_detail | 상세주소 | varchar | 200 | - | N | | | - |
|
||||
| note | 비고 | text | - | - | N | | | - |
|
||||
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
||||
@@ -132,11 +135,12 @@ zipcodes ||--o{ customers : addressed
|
||||
| id | 고객사ID | bigint | - | identity | Y | Y | Y | - |
|
||||
| customer_code | 고객사코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
|
||||
| customer_name | 고객사명 | varchar | 100 | - | Y | | | |
|
||||
| contact_name | 담당자명 | varchar | 100 | - | N | | | - |
|
||||
| is_partner | 파트너여부 | boolean | - | false | Y | | | - |
|
||||
| is_general | 일반여부 | boolean | - | true | Y | | | - |
|
||||
| email | 이메일 | varchar | 100 | - | N | | | - |
|
||||
| mobile_no | 모바일번호 | varchar | 20 | - | N | | | - |
|
||||
| zipcode | 우편번호 | varchar | 5 | - | N | | | zipcodes.zipcode |
|
||||
| zipcode_id | 우편번호ID | bigint | - | - | N | | | zipcodes.id |
|
||||
| address_detail | 상세주소 | varchar | 200 | - | N | | | - |
|
||||
| note | 비고 | text | - | - | N | | | - |
|
||||
| is_active | 사용여부 | boolean | - | true | Y | | | |
|
||||
@@ -144,6 +148,10 @@ zipcodes ||--o{ customers : addressed
|
||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||
|
||||
> `contact_name`은 고객사의 대표 연락 창구로 사용하는 담당자 실명. 필수는 아니며 미입력 시 `null` 저장.
|
||||
|
||||
> 고객/창고 모두 `zipcode_id`를 통해 `zipcodes.id`와 연결하며, API 응답은 FK가 가리키는 `zipcodes` 행에서 필요한 우편번호 메타 정보를 추출해 제공한다.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `employees` (사원)
|
||||
@@ -174,7 +182,8 @@ zipcodes ||--o{ customers : addressed
|
||||
|
||||
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| zipcode | 우편번호 | varchar | 5 | - | Y | Y | Y | - |
|
||||
| id | 우편번호ID | bigint | - | identity | Y | Y | Y | - |
|
||||
| zipcode | 우편번호 | varchar | 5 | - | Y | | N | - |
|
||||
| sido | 시도 | varchar | 50 | - | Y | | | - |
|
||||
| sido_eng | 시도영문 | varchar | 100 | - | N | | | - |
|
||||
| sigungu | 시군구 | varchar | 100 | - | Y | | | - |
|
||||
@@ -207,7 +216,7 @@ zipcodes ||--o{ customers : addressed
|
||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||
|
||||
> 도로명 주소 데이터와 매핑되는 5자리 우편번호 기준. `zipcode`가 PK이며 외부 데이터 동기화를 위한 `zipcode_serial_no`를 포함.
|
||||
> 도로명 주소 데이터와 매핑되는 5자리 우편번호 기준. `id`는 내부용 서러겟 PK이며, `zipcode`는 동일 코드가 여러 주소 행에 등장할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
@@ -380,6 +389,7 @@ zipcodes ||--o{ customers : addressed
|
||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||
|
||||
> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생.
|
||||
> 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신)
|
||||
|
||||
---
|
||||
|
||||
@@ -583,8 +593,6 @@ zipcodes ||--o{ customers : addressed
|
||||
- `employees.group_id` → `groups.id`
|
||||
- `group_menu_permissions.group_id` → `groups.id`
|
||||
- `group_menu_permissions.menu_id` → `menus.id`
|
||||
- `warehouses.zipcode` → `zipcodes.zipcode`
|
||||
- `customers.zipcode` → `zipcodes.zipcode`
|
||||
- `products.vendor_id` → `vendors.id`
|
||||
- `products.uom_id` → `uoms.id`
|
||||
- `stock_transactions.warehouse_id` → `warehouses.id`
|
||||
@@ -628,7 +636,7 @@ zipcodes ||--o{ customers : addressed
|
||||
|
||||
## 6) 인덱스/유니크 권장
|
||||
- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존:
|
||||
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `employees(employee_no)`, `menus(menu_code)`, `groups(group_name)`, `zipcodes(zipcode)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
|
||||
- `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `employees(employee_no)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`
|
||||
- `group_menu_permissions(group_id, menu_id, is_deleted)`
|
||||
- `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장
|
||||
- `transaction_lines(transaction_id, line_no, is_deleted)`
|
||||
|
||||
@@ -44,38 +44,83 @@ class ApprovalDto {
|
||||
|
||||
/// API 응답 JSON을 [ApprovalDto]로 변환한다.
|
||||
factory ApprovalDto.fromJson(Map<String, dynamic> json) {
|
||||
final approvalEnvelope = _mapOrEmpty(json['approval']);
|
||||
final statusMap = _firstNonEmptyMap([
|
||||
json['status'],
|
||||
json['approval_status'],
|
||||
approvalEnvelope['status'],
|
||||
approvalEnvelope['approval_status'],
|
||||
]);
|
||||
final requesterMap = _firstNonEmptyMap([
|
||||
json['requester'],
|
||||
json['requested_by'],
|
||||
approvalEnvelope['requester'],
|
||||
approvalEnvelope['requested_by'],
|
||||
]);
|
||||
final currentStepMap = _firstNonEmptyMap([
|
||||
json['current_step'],
|
||||
json['currentStep'],
|
||||
approvalEnvelope['current_step'],
|
||||
]);
|
||||
final transactionMap = _mapOrEmpty(json['transaction']);
|
||||
final envelopeTransactionMap = _mapOrEmpty(approvalEnvelope['transaction']);
|
||||
var stepsSource = _asListOfMap(json['steps']);
|
||||
if (stepsSource.isEmpty) {
|
||||
stepsSource = _asListOfMap(approvalEnvelope['steps']);
|
||||
}
|
||||
var historiesSource = _asListOfMap(json['histories']);
|
||||
if (historiesSource.isEmpty) {
|
||||
historiesSource = _asListOfMap(approvalEnvelope['histories']);
|
||||
}
|
||||
final currentStepDto = currentStepMap.isEmpty
|
||||
? null
|
||||
: ApprovalStepDto.fromJson(currentStepMap);
|
||||
|
||||
final approvalNo =
|
||||
_pickString(
|
||||
[json, approvalEnvelope],
|
||||
const ['approval_no', 'approvalNo'],
|
||||
) ??
|
||||
'-';
|
||||
final transactionNo = _pickString(
|
||||
[json, transactionMap, approvalEnvelope, envelopeTransactionMap],
|
||||
const ['transaction_no', 'transactionNo'],
|
||||
);
|
||||
|
||||
return ApprovalDto(
|
||||
id: json['id'] as int?,
|
||||
approvalNo: json['approval_no'] as String,
|
||||
transactionNo: json['transaction'] is Map<String, dynamic>
|
||||
? (json['transaction']['transaction_no'] as String?)
|
||||
: json['transaction_no'] as String?,
|
||||
status: ApprovalStatusDto.fromJson(
|
||||
(json['status'] as Map<String, dynamic>? ?? const {}),
|
||||
id: json['id'] as int? ?? approvalEnvelope['id'] as int?,
|
||||
approvalNo: approvalNo,
|
||||
transactionNo: transactionNo,
|
||||
status: ApprovalStatusDto.fromJson(statusMap),
|
||||
currentStep: currentStepDto,
|
||||
requester: ApprovalRequesterDto.fromJson(requesterMap),
|
||||
requestedAt:
|
||||
_parseDate(
|
||||
json['requested_at'] ?? approvalEnvelope['requested_at'],
|
||||
) ??
|
||||
DateTime.now(),
|
||||
decidedAt: _parseDate(
|
||||
json['decided_at'] ?? approvalEnvelope['decided_at'],
|
||||
),
|
||||
currentStep: json['current_step'] is Map<String, dynamic>
|
||||
? ApprovalStepDto.fromJson(
|
||||
json['current_step'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
requester: ApprovalRequesterDto.fromJson(
|
||||
(json['requester'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
requestedAt: _parseDate(json['requested_at']) ?? DateTime.now(),
|
||||
decidedAt: _parseDate(json['decided_at']),
|
||||
note: json['note'] as String?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
isDeleted: (json['is_deleted'] as bool?) ?? false,
|
||||
steps: (json['steps'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalStepDto.fromJson)
|
||||
.toList(),
|
||||
histories: (json['histories'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
note: json['note'] as String? ?? approvalEnvelope['note'] as String?,
|
||||
isActive:
|
||||
(json['is_active'] as bool?) ??
|
||||
(approvalEnvelope['is_active'] as bool?) ??
|
||||
true,
|
||||
isDeleted:
|
||||
(json['is_deleted'] as bool?) ??
|
||||
(approvalEnvelope['is_deleted'] as bool?) ??
|
||||
false,
|
||||
steps: stepsSource.map(ApprovalStepDto.fromJson).toList(growable: false),
|
||||
histories: historiesSource
|
||||
.map(ApprovalHistoryDto.fromJson)
|
||||
.toList(),
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
.toList(growable: false),
|
||||
createdAt: _parseDate(
|
||||
json['created_at'] ?? approvalEnvelope['created_at'],
|
||||
),
|
||||
updatedAt: _parseDate(
|
||||
json['updated_at'] ?? approvalEnvelope['updated_at'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,9 +168,21 @@ class ApprovalStatusDto {
|
||||
final String? color;
|
||||
|
||||
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
|
||||
if (json['status'] is Map<String, dynamic>) {
|
||||
return ApprovalStatusDto.fromJson(json['status'] as Map<String, dynamic>);
|
||||
}
|
||||
return ApprovalStatusDto(
|
||||
id: json['id'] as int? ?? json['status_id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['status_name'] as String? ?? '-',
|
||||
id:
|
||||
json['id'] as int? ??
|
||||
json['status_id'] as int? ??
|
||||
json['approval_status_id'] as int? ??
|
||||
0,
|
||||
name:
|
||||
json['name'] as String? ??
|
||||
json['status_name'] as String? ??
|
||||
json['approval_status_name'] as String? ??
|
||||
(json['status'] as String?) ??
|
||||
'-',
|
||||
color: json['color'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -216,6 +273,7 @@ class ApprovalStepDto {
|
||||
status: ApprovalStatusDto.fromJson(
|
||||
(json['status'] as Map<String, dynamic>? ??
|
||||
json['step_status'] as Map<String, dynamic>? ??
|
||||
json['approval_status'] as Map<String, dynamic>? ??
|
||||
const {}),
|
||||
),
|
||||
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
|
||||
@@ -262,28 +320,41 @@ class ApprovalHistoryDto {
|
||||
final String? note;
|
||||
|
||||
factory ApprovalHistoryDto.fromJson(Map<String, dynamic> json) {
|
||||
final actionMap = _firstNonEmptyMap([
|
||||
json['action'],
|
||||
json['approval_action'],
|
||||
json['step_action'],
|
||||
]);
|
||||
final fromStatusMap = _firstNonEmptyMap([
|
||||
json['from_status'],
|
||||
json['fromStatus'],
|
||||
]);
|
||||
final toStatusMap = _firstNonEmptyMap([
|
||||
json['to_status'],
|
||||
json['toStatus'],
|
||||
]);
|
||||
final approverMap = _firstNonEmptyMap([json['approver'], json['employee']]);
|
||||
final fallbackAction = {
|
||||
'id': json['approval_action_id'] ?? json['action_id'],
|
||||
'name':
|
||||
json['approval_action_name'] ??
|
||||
json['action_name'] ??
|
||||
(json['action'] as String?) ??
|
||||
'-',
|
||||
};
|
||||
|
||||
return ApprovalHistoryDto(
|
||||
id: json['id'] as int?,
|
||||
action: ApprovalActionDto.fromJson(
|
||||
json['action'] is Map<String, dynamic>
|
||||
? json['action'] as Map<String, dynamic>
|
||||
: {
|
||||
'id': json['approval_action_id'],
|
||||
'name': json['approval_action_name'],
|
||||
},
|
||||
actionMap.isEmpty ? fallbackAction : actionMap,
|
||||
),
|
||||
fromStatus: json['from_status'] is Map<String, dynamic>
|
||||
? ApprovalStatusDto.fromJson(
|
||||
json['from_status'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
toStatus: ApprovalStatusDto.fromJson(
|
||||
(json['to_status'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
approver: ApprovalApproverDto.fromJson(
|
||||
(json['approver'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
actionAt: _parseDate(json['action_at']) ?? DateTime.now(),
|
||||
fromStatus: fromStatusMap.isEmpty
|
||||
? null
|
||||
: ApprovalStatusDto.fromJson(fromStatusMap),
|
||||
toStatus: ApprovalStatusDto.fromJson(toStatusMap),
|
||||
approver: ApprovalApproverDto.fromJson(approverMap),
|
||||
actionAt:
|
||||
_parseDate(json['action_at'] ?? json['actionAt']) ?? DateTime.now(),
|
||||
note: json['note'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -308,9 +379,21 @@ class ApprovalActionDto {
|
||||
final String name;
|
||||
|
||||
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
|
||||
if (json['action'] is Map<String, dynamic>) {
|
||||
return ApprovalActionDto.fromJson(json['action'] as Map<String, dynamic>);
|
||||
}
|
||||
return ApprovalActionDto(
|
||||
id: json['id'] as int? ?? json['action_id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['action_name'] as String? ?? '-',
|
||||
id:
|
||||
json['id'] as int? ??
|
||||
json['action_id'] as int? ??
|
||||
json['approval_action_id'] as int? ??
|
||||
0,
|
||||
name:
|
||||
json['name'] as String? ??
|
||||
json['action_name'] as String? ??
|
||||
json['approval_action_name'] as String? ??
|
||||
(json['action'] as String?) ??
|
||||
'-',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,6 +401,39 @@ class ApprovalActionDto {
|
||||
ApprovalAction toEntity() => ApprovalAction(id: id, name: name);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _asListOfMap(dynamic value) {
|
||||
if (value is List) {
|
||||
return value.whereType<Map<String, dynamic>>().toList(growable: false);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapOrEmpty(dynamic value) =>
|
||||
value is Map<String, dynamic> ? value : const {};
|
||||
|
||||
Map<String, dynamic> _firstNonEmptyMap(List<dynamic> candidates) {
|
||||
for (final candidate in candidates) {
|
||||
if (candidate is Map<String, dynamic> && candidate.isNotEmpty) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
String? _pickString(List<dynamic> sources, List<String> keys) {
|
||||
for (final source in sources) {
|
||||
if (source is Map<String, dynamic>) {
|
||||
for (final key in keys) {
|
||||
final value = source[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 문자열/DateTime 입력을 DateTime으로 변환한다.
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value == null) return null;
|
||||
|
||||
@@ -33,9 +33,13 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (statusId != null) 'status_id': statusId,
|
||||
if (statusId != null) ...{
|
||||
'status_id': statusId,
|
||||
'step_status_id': statusId,
|
||||
},
|
||||
if (approverId != null) 'approver_id': approverId,
|
||||
if (approvalId != null) 'approval_id': approvalId,
|
||||
'include': 'approval,approver,step_status',
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
@@ -52,10 +52,10 @@ class StockTransactionDto {
|
||||
id: json['id'] as int?,
|
||||
transactionNo: json['transaction_no'] as String? ?? '',
|
||||
transactionDate: _parseDate(json['transaction_date']) ?? DateTime.now(),
|
||||
type: _parseType(typeJson),
|
||||
status: _parseStatus(statusJson),
|
||||
warehouse: _parseWarehouse(warehouseJson),
|
||||
createdBy: _parseEmployee(createdByJson),
|
||||
type: _parseType(typeJson, json),
|
||||
status: _parseStatus(statusJson, json),
|
||||
warehouse: _parseWarehouse(warehouseJson, json),
|
||||
createdBy: _parseEmployee(createdByJson, json),
|
||||
note: json['note'] as String?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
createdAt: _parseDateTime(json['created_at']),
|
||||
@@ -117,36 +117,86 @@ class StockTransactionDto {
|
||||
}
|
||||
}
|
||||
|
||||
StockTransactionType _parseType(Map<String, dynamic>? json) {
|
||||
return StockTransactionType(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
name: json?['type_name'] as String? ?? '',
|
||||
);
|
||||
StockTransactionType _parseType(
|
||||
Map<String, dynamic>? json,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = json ?? const <String, dynamic>{};
|
||||
final id = map['id'] as int? ?? fallback['transaction_type_id'] as int? ?? 0;
|
||||
final name =
|
||||
map['name'] as String? ??
|
||||
map['type_name'] as String? ??
|
||||
fallback['transaction_type_name'] as String? ??
|
||||
fallback['transaction_type'] as String? ??
|
||||
'';
|
||||
return StockTransactionType(id: id, name: name);
|
||||
}
|
||||
|
||||
StockTransactionStatus _parseStatus(Map<String, dynamic>? json) {
|
||||
return StockTransactionStatus(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
name: json?['status_name'] as String? ?? '',
|
||||
);
|
||||
StockTransactionStatus _parseStatus(
|
||||
Map<String, dynamic>? json,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = json ?? const <String, dynamic>{};
|
||||
final id =
|
||||
map['id'] as int? ?? fallback['transaction_status_id'] as int? ?? 0;
|
||||
final name =
|
||||
map['name'] as String? ??
|
||||
map['status_name'] as String? ??
|
||||
fallback['transaction_status_name'] as String? ??
|
||||
fallback['transaction_status'] as String? ??
|
||||
'';
|
||||
return StockTransactionStatus(id: id, name: name);
|
||||
}
|
||||
|
||||
StockTransactionWarehouse _parseWarehouse(Map<String, dynamic>? json) {
|
||||
final zipcode = json?['zipcode'] as Map<String, dynamic>?;
|
||||
StockTransactionWarehouse _parseWarehouse(
|
||||
Map<String, dynamic>? json,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = json ?? const <String, dynamic>{};
|
||||
final zipcodeMap = _mapOrEmpty(map['zipcode']);
|
||||
final fallbackZipcode = _mapOrEmpty(fallback['zipcode']);
|
||||
final mergedZipcode = zipcodeMap.isNotEmpty ? zipcodeMap : fallbackZipcode;
|
||||
return StockTransactionWarehouse(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['warehouse_code'] as String? ?? '',
|
||||
name: json?['warehouse_name'] as String? ?? '',
|
||||
zipcode: zipcode?['zipcode'] as String?,
|
||||
addressLine: zipcode?['road_name'] as String?,
|
||||
id: map['id'] as int? ?? fallback['warehouse_id'] as int? ?? 0,
|
||||
code:
|
||||
map['warehouse_code'] as String? ??
|
||||
fallback['warehouse_code'] as String? ??
|
||||
fallback['warehouseCode'] as String? ??
|
||||
'',
|
||||
name:
|
||||
map['warehouse_name'] as String? ??
|
||||
fallback['warehouse_name'] as String? ??
|
||||
fallback['warehouseName'] as String? ??
|
||||
'',
|
||||
zipcode:
|
||||
mergedZipcode['zipcode'] as String? ?? fallback['zipcode'] as String?,
|
||||
addressLine:
|
||||
mergedZipcode['road_name'] as String? ??
|
||||
mergedZipcode['roadName'] as String? ??
|
||||
fallback['address_line'] as String? ??
|
||||
fallback['addressLine'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionEmployee _parseEmployee(Map<String, dynamic>? json) {
|
||||
StockTransactionEmployee _parseEmployee(
|
||||
Map<String, dynamic>? json,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = json ?? const <String, dynamic>{};
|
||||
return StockTransactionEmployee(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
employeeNo: json?['employee_no'] as String? ?? '',
|
||||
name: json?['employee_name'] as String? ?? '',
|
||||
id:
|
||||
map['id'] as int? ??
|
||||
fallback['created_by_id'] as int? ??
|
||||
fallback['created_by'] as int? ??
|
||||
0,
|
||||
employeeNo:
|
||||
map['employee_no'] as String? ??
|
||||
fallback['created_by_employee_no'] as String? ??
|
||||
'',
|
||||
name:
|
||||
map['employee_name'] as String? ??
|
||||
fallback['created_by_name'] as String? ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,83 +207,184 @@ List<StockTransactionLine> _parseLines(Map<String, dynamic> json) {
|
||||
StockTransactionLine(
|
||||
id: item['id'] as int?,
|
||||
lineNo: JsonUtils.readInt(item, 'line_no', fallback: 1),
|
||||
product: _parseProduct(item['product'] as Map<String, dynamic>?),
|
||||
quantity: JsonUtils.readInt(item, 'quantity', fallback: 0),
|
||||
product: _parseProduct(item['product'] as Map<String, dynamic>?, item),
|
||||
quantity: _readQuantity(item['quantity']),
|
||||
unitPrice: _readDouble(item['unit_price']),
|
||||
note: item['note'] as String?,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
StockTransactionProduct _parseProduct(Map<String, dynamic>? json) {
|
||||
final vendorJson = json?['vendor'] as Map<String, dynamic>?;
|
||||
final uomJson = json?['uom'] as Map<String, dynamic>?;
|
||||
StockTransactionProduct _parseProduct(
|
||||
Map<String, dynamic>? json,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = json ?? const <String, dynamic>{};
|
||||
return StockTransactionProduct(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['product_code'] as String? ?? json?['code'] as String? ?? '',
|
||||
name: json?['product_name'] as String? ?? json?['name'] as String? ?? '',
|
||||
vendor: vendorJson == null
|
||||
? null
|
||||
: StockTransactionVendorSummary(
|
||||
id: vendorJson['id'] as int? ?? 0,
|
||||
name:
|
||||
vendorJson['vendor_name'] as String? ??
|
||||
vendorJson['name'] as String? ??
|
||||
'',
|
||||
),
|
||||
uom: uomJson == null
|
||||
? null
|
||||
: StockTransactionUomSummary(
|
||||
id: uomJson['id'] as int? ?? 0,
|
||||
name:
|
||||
uomJson['uom_name'] as String? ??
|
||||
uomJson['name'] as String? ??
|
||||
'',
|
||||
),
|
||||
id:
|
||||
map['id'] as int? ??
|
||||
map['product_id'] as int? ??
|
||||
fallback['product_id'] as int? ??
|
||||
0,
|
||||
code:
|
||||
map['product_code'] as String? ??
|
||||
map['code'] as String? ??
|
||||
fallback['product_code'] as String? ??
|
||||
fallback['code'] as String? ??
|
||||
'',
|
||||
name:
|
||||
map['product_name'] as String? ??
|
||||
map['name'] as String? ??
|
||||
fallback['product_name'] as String? ??
|
||||
fallback['name'] as String? ??
|
||||
'',
|
||||
vendor: _parseVendor(map['vendor'], fallback),
|
||||
uom: _parseUom(map['uom'], fallback),
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionVendorSummary? _parseVendor(
|
||||
dynamic source,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = _mapOrEmpty(source);
|
||||
final name =
|
||||
map['vendor_name'] as String? ??
|
||||
map['name'] as String? ??
|
||||
fallback['vendor_name'] as String? ??
|
||||
fallback['vendorName'] as String? ??
|
||||
fallback['manufacturer'] as String?;
|
||||
if (name == null || name.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final id =
|
||||
map['id'] as int? ??
|
||||
map['vendor_id'] as int? ??
|
||||
fallback['vendor_id'] as int? ??
|
||||
0;
|
||||
return StockTransactionVendorSummary(id: id, name: name);
|
||||
}
|
||||
|
||||
StockTransactionUomSummary? _parseUom(
|
||||
dynamic source,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = _mapOrEmpty(source);
|
||||
final name =
|
||||
map['uom_name'] as String? ??
|
||||
map['name'] as String? ??
|
||||
fallback['uom_name'] as String? ??
|
||||
fallback['uomName'] as String? ??
|
||||
fallback['unit'] as String?;
|
||||
if (name == null || name.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final id =
|
||||
map['id'] as int? ??
|
||||
map['uom_id'] as int? ??
|
||||
fallback['uom_id'] as int? ??
|
||||
0;
|
||||
return StockTransactionUomSummary(id: id, name: name);
|
||||
}
|
||||
|
||||
List<StockTransactionCustomer> _parseCustomers(Map<String, dynamic> json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['customers']);
|
||||
return [
|
||||
for (final item in raw)
|
||||
StockTransactionCustomer(
|
||||
id: item['id'] as int?,
|
||||
customer: _parseCustomer(item['customer'] as Map<String, dynamic>?),
|
||||
customer: _parseCustomer(
|
||||
item['customer'] as Map<String, dynamic>?,
|
||||
item,
|
||||
),
|
||||
note: item['note'] as String?,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
StockTransactionCustomerSummary _parseCustomer(Map<String, dynamic>? json) {
|
||||
StockTransactionCustomerSummary _parseCustomer(
|
||||
Map<String, dynamic>? json,
|
||||
Map<String, dynamic> fallback,
|
||||
) {
|
||||
final map = json ?? const <String, dynamic>{};
|
||||
return StockTransactionCustomerSummary(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['customer_code'] as String? ?? json?['code'] as String? ?? '',
|
||||
name: json?['customer_name'] as String? ?? json?['name'] as String? ?? '',
|
||||
id:
|
||||
map['id'] as int? ??
|
||||
map['customer_id'] as int? ??
|
||||
fallback['customer_id'] as int? ??
|
||||
0,
|
||||
code:
|
||||
map['customer_code'] as String? ??
|
||||
map['code'] as String? ??
|
||||
fallback['customer_code'] as String? ??
|
||||
fallback['code'] as String? ??
|
||||
'',
|
||||
name:
|
||||
map['customer_name'] as String? ??
|
||||
map['name'] as String? ??
|
||||
fallback['customer_name'] as String? ??
|
||||
fallback['name'] as String? ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionApprovalSummary? _parseApproval(dynamic raw) {
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
final map = _mapOrEmpty(raw);
|
||||
if (map.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final status = raw['approval_status'] as Map<String, dynamic>?;
|
||||
final statusMap = _firstNonEmptyMap([map['approval_status'], map['status']]);
|
||||
return StockTransactionApprovalSummary(
|
||||
id: raw['id'] as int? ?? 0,
|
||||
approvalNo: raw['approval_no'] as String? ?? '',
|
||||
status: status == null
|
||||
id: map['id'] as int? ?? 0,
|
||||
approvalNo:
|
||||
map['approval_no'] as String? ?? map['approvalNo'] as String? ?? '',
|
||||
status: statusMap.isEmpty
|
||||
? null
|
||||
: StockTransactionApprovalStatusSummary(
|
||||
id: status['id'] as int? ?? 0,
|
||||
id: statusMap['id'] as int? ?? statusMap['status_id'] as int? ?? 0,
|
||||
name:
|
||||
status['status_name'] as String? ??
|
||||
status['name'] as String? ??
|
||||
'',
|
||||
isBlocking: status['is_blocking_next'] as bool?,
|
||||
statusMap['name'] as String? ??
|
||||
statusMap['status_name'] as String? ??
|
||||
'-',
|
||||
isBlocking:
|
||||
statusMap['is_blocking_next'] as bool? ??
|
||||
statusMap['isBlocking'] as bool?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapOrEmpty(dynamic value) =>
|
||||
value is Map<String, dynamic> ? value : const <String, dynamic>{};
|
||||
|
||||
Map<String, dynamic> _firstNonEmptyMap(List<dynamic> candidates) {
|
||||
for (final candidate in candidates) {
|
||||
if (candidate is Map<String, dynamic> && candidate.isNotEmpty) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
int _readQuantity(Object? value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is double) {
|
||||
return value.round();
|
||||
}
|
||||
if (value is String) {
|
||||
final parsedInt = int.tryParse(value);
|
||||
if (parsedInt != null) {
|
||||
return parsedInt;
|
||||
}
|
||||
final parsedDouble = double.tryParse(value);
|
||||
if (parsedDouble != null) {
|
||||
return parsedDouble.round();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
@@ -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) 'include_deleted': true,
|
||||
if (includeDeleted) 'deleted': true,
|
||||
'include': 'group,menu',
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
|
||||
@@ -42,7 +42,7 @@ class MenuDto {
|
||||
: json['parent'] is Map<String, dynamic>
|
||||
? MenuSummaryDto.fromJson(json['parent'] as Map<String, dynamic>)
|
||||
: null,
|
||||
path: json['path'] as String?,
|
||||
path: json['path'] as String? ?? json['route_path'] as String?,
|
||||
displayOrder: json['display_order'] as int?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
isDeleted: (json['is_deleted'] as bool?) ?? false,
|
||||
|
||||
98
tool/sync_stock_docs.sh
Executable file
98
tool/sync_stock_docs.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 이 스크립트는 결재 문서를 백엔드(superport_api_v2)와 동기화하거나 차이를 점검한다.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BACKEND_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)/superport_api_v2"
|
||||
|
||||
FILES=(
|
||||
"stock_approval_system_api_v4.md"
|
||||
"stock_approval_system_spec_v4.md"
|
||||
)
|
||||
|
||||
show_usage() {
|
||||
cat <<'EOF'
|
||||
사용법: tool/sync_stock_docs.sh [--check]
|
||||
|
||||
--check 백엔드 문서와 현재 문서의 차이를 검사한다.
|
||||
차이가 있으면 diff를 출력하고 종료 코드 1을 반환한다.
|
||||
|
||||
인자가 없으면 백엔드 문서를 복사하여 현재 리포지터리 doc/ 경로로 동기화한다.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_files() {
|
||||
local missing=0
|
||||
for file in "${FILES[@]}"; do
|
||||
local source_path="$BACKEND_ROOT/$file"
|
||||
if [[ ! -f "$source_path" ]]; then
|
||||
echo "오류: 백엔드 문서를 찾을 수 없습니다 -> $source_path" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $missing -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
sync_files() {
|
||||
require_files
|
||||
for file in "${FILES[@]}"; do
|
||||
local source_path="$BACKEND_ROOT/$file"
|
||||
local target_path="$PROJECT_ROOT/doc/$file"
|
||||
mkdir -p "$(dirname "$target_path")"
|
||||
cp "$source_path" "$target_path"
|
||||
echo "동기화 완료: $file"
|
||||
done
|
||||
}
|
||||
|
||||
check_diff() {
|
||||
require_files
|
||||
local diff_found=0
|
||||
for file in "${FILES[@]}"; do
|
||||
local source_path="$BACKEND_ROOT/$file"
|
||||
local target_path="$PROJECT_ROOT/doc/$file"
|
||||
if ! diff -u "$target_path" "$source_path" >/dev/null; then
|
||||
echo "차이 발견: $file"
|
||||
diff -u "$target_path" "$source_path" || true
|
||||
diff_found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $diff_found -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "모든 문서가 일치합니다."
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -gt 1 ]]; then
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
case "$1" in
|
||||
--check)
|
||||
check_diff
|
||||
return
|
||||
;;
|
||||
-*)
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
sync_files
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user