# 간단 입·출고 + 결재 시스템 API 규격 (v4) **기준 버전:** 2025-09-18 16:22:30Z (UTC) 본 문서는 `stock_approval_system_spec_full_v4.md`의 데이터 모델과 비즈니스 규칙을 기반으로 한 REST API 구성을 정의한다. 기본 CRUD를 제공하며, 목록·상세 조회 시 FK로 연결된 주요 엔터티 정보를 함께 반환한다. 모든 엔드포인트는 소프트 삭제 컬럼(`is_deleted`)을 노출하지 않는다. --- ## 0. 구현 현황 요약 (2025-09-18 기준) - 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes` - 각 자원은 `/api/v1/` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다. - 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`와 동일 값을 가진 `path`가 포함되며 각 항목은 `is_deleted`를 노출한다. `include=group,menu` 확장과 `include_deleted=true` 파라미터로 삭제 권한을 함께 조회할 수 있다. - 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다. --- ## 1. 공통 규칙 - **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`. - **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`. - **헬스 체크:** `GET /health`는 `{ status, build_version, error? }` 구조를 반환하며 `build_version` 값은 `config/default.toml`의 `[app].build_version`에서 로딩된다. 원격 배포 시 `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 해당 값을 갱신한다. - **시간대:** 모든 날짜·시간은 ISO8601 UTC 문자열. - **소프트 삭제:** `DELETE /{res}/{id}` 호출 시 서버는 `is_deleted=true`, `is_active=false`로 처리하고 응답 바디는 `{ data: { id, deleted_at } }` 형식을 사용. - **복구:** `POST /{res}/{id}/restore`. - **공통 컬럼:** `note`, `is_active`, `created_at`, `updated_at`는 요청·응답에 필요 시 노출하되 `is_deleted`는 절대 노출하지 않는다. - **기본 필터:** 목록 조회 시 기본 쿼리 `active=true`, `deleted=false`. `deleted` 파라미터가 `true`일 때에만 삭제된 항목을 반환. - **증분 조회:** `updated_since=ISO8601`. - **정렬:** `sort`(기본 `updated_at`), `order=asc|desc`(기본 desc). - **검색:** `q` 파라미터로 코드/명칭 부분 일치. 필요한 경우 컬럼별 필터 지원. - **Include 확장:** `include` 쿼리로 추가 데이터(`lines`, `customers`, `approval`, `steps`, `histories`, `permissions`, `employees` 등) 선택 가능. 포함 대상은 FK 요약 정보를 이미 반환하므로 `include`는 상세 컬렉션을 불러올 때 사용. - **배열 입력:** 트랜잭션 라인, 트랜잭션 고객, 결재 단계, 그룹 메뉴 권한 등 다건 작업은 항상 배열(`[]`) 기반으로 요청한다. - **Primary Key 규칙:** Create 요청 바디에는 PK를 포함하지 않는다. Create 응답 및 나머지 모든 요청·응답에는 PK가 포함돼야 한다(경로에 이미 포함된 경우라도 바디 내 `id`를 명시). - **에러 규격:** - `400 BAD_REQUEST` — 검증 오류, 필수값 누락. - `404 NOT_FOUND` — 리소스 없음 또는 삭제됨. - `409 CONFLICT` — 유니크 제약, 결재 단계 상태 충돌. - `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고 고객 누락, blocking 상태 전이 등). - 에러 응답 예: `{ "error": { "code": 422, "message": "출고 트랜잭션에는 고객이 최소 1건 필요합니다.", "details": [...] } }`. - **CORS 정책:** 서버는 `config/default.toml`의 `[cors]` 설정을 사용해 허용 오리진을 제어한다. `allowed_origins`가 비어 있으면 모든 오리진을 허용하고, 값에 `http://localhost` 또는 `https://web.example.com:*`처럼 포트 와일드카드(`:*`)를 포함하면 동적 포트 환경에서도 `Access-Control-Allow-Origin`이 요청 오리진과 동일하게 반환된다. 허용 오리진에 일치하지 않으면 `400 BAD_REQUEST`가 응답된다. --- ## 2. 타입(룩업) API 대상: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions` ### 2.1 목록 조회 `GET /{type}?page=1&page_size=50&active=true` ```json { "items": [ { "id": 1, "name": "EA", "is_default": true, "is_active": true, "note": null, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-02-01T03:00:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` - `delta` 값은 전일 대비 증감률(비율)로 반환되며 `1.0`은 100% 증가, `-0.5`는 50% 감소를 의미한다. 값이 계산되지 않는 KPI는 `delta`를 생략한다. ### 2.2 단건 조회 `GET /{type}/{id}` ```json { "data": { "id": 3, "name": "반려", "is_default": false, "is_blocking_next": true, "is_terminal": true, "is_active": true, "note": "최종 거절", "created_at": "2025-01-10T09:00:00Z", "updated_at": "2025-02-01T10:00:00Z" } } ``` ### 2.3 생성 `POST /{type}` ```json { "name": "진행중", "is_default": false, "is_blocking_next": true, "is_terminal": false, "is_active": true, "note": null } ``` 응답: ```json { "data": { "id": 4, "name": "진행중", "is_default": false, "is_blocking_next": true, "is_terminal": false, "is_active": true, "note": null, "created_at": "2025-03-01T00:00:00Z", "updated_at": "2025-03-01T00:00:00Z" } } ``` ### 2.4 수정 `PATCH /{type}/{id}` ```json { "id": 4, "is_blocking_next": false, "note": "임시 승인 허용" } ``` ### 2.5 삭제 & 복구 - `DELETE /{type}/{id}` → `{ "data": { "id": 4, "deleted_at": "2025-03-05T09:00:00Z" } }` - `POST /{type}/{id}/restore` → `{ "data": { "id": 4, "restored_at": "2025-03-06T01:00:00Z" } }` > `approval-statuses`는 추가 속성(`is_blocking_next`, `is_terminal`)을 사용하며, 다른 타입 테이블은 `name`, `is_default`, `is_active`, `note` 중심으로 작동한다. --- ## 3. 마스터 데이터 API 리소스: `/vendors`, `/warehouses`, `/customers`, `/employees`, `/products`, `/menus`, `/groups`, `/zipcodes` > 기본 정렬: 별도 `sort` 파라미터가 없으면 항상 `id` 오름차순으로 응답을 정렬한다. (`order` 기본값도 `asc`) ### 3.1 목록 조회 `GET /vendors?page=1&q=한빛` ```json { "items": [ { "id": 10, "vendor_code": "V001", "vendor_name": "한빛상사", "note": "서울/경기 공급처", "is_active": true, "created_at": "2025-01-01T12:00:00Z", "updated_at": "2025-01-03T09:00:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` `GET /products?page=1&include=vendor` ```json { "items": [ { "id": 101, "product_code": "P100", "product_name": "샘플", "vendor": { "id": 10, "vendor_code": "V001", "vendor_name": "한빛상사" }, "uom": { "id": 1, "uom_name": "EA", "is_default": true }, "note": "출고 우선 재고", "is_active": true, "created_at": "2025-02-01T12:00:00Z", "updated_at": "2025-02-03T09:00:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` `GET /warehouses?page=1` ```json { "items": [ { "id": 20, "warehouse_code": "WH-001", "warehouse_name": "1센터", "zipcode": { "zipcode": "06000", "sido": "서울특별시", "sigungu": "강남구", "road_name": "테헤란로" }, "address_detail": "강남파이낸스센터 10층", "is_active": true, "created_at": "2025-01-05T08:00:00Z", "updated_at": "2025-01-10T09:30:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` `GET /customers?page=1` ```json { "items": [ { "id": 301, "customer_code": "C001", "customer_name": "ABC물류", "contact_name": "박담당", "is_partner": true, "is_general": false, "email": "contact@abc.com", "mobile_no": "010-1234-5678", "zipcode": { "zipcode": "06000", "sido": "서울특별시", "sigungu": "강남구", "road_name": "테헤란로" }, "address_detail": "10층", "note": "VIP 고객", "is_active": true, "created_at": "2025-01-15T11:00:00Z", "updated_at": "2025-01-20T08:10:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` > `contact_name`은 고객사 담당자 실명. 선택 입력이며 미입력 시 `null`. `GET /employees?page=1` ```json { "items": [ { "id": 7, "employee_no": "E2025001", "employee_name": "김승인", "email": "approver@example.com", "mobile_no": "010-2222-1111", "group": { "id": 2, "group_name": "창고 관리자" }, "is_active": true, "created_at": "2025-01-02T09:00:00Z", "updated_at": "2025-01-10T11:00:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` `GET /groups?include=permissions,employees` ```json { "items": [ { "id": 2, "group_name": "창고 관리자", "group_description": "창고 및 재고 관리", "is_default": false, "is_active": true, "permissions": [ { "id": 8, "menu": { "id": 12, "menu_code": "STOCK_MGMT", "menu_name": "입출고 관리", "route_path": "/inventory/transactions" }, "can_create": true, "can_read": true, "can_update": true, "can_delete": false } ], "employees": [ { "id": 7, "employee_no": "E2025001", "employee_name": "김승인" } ], "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-15T00:00:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` ### 3.2 단건 조회 `GET /products/101` ```json { "data": { "id": 101, "product_code": "P100", "product_name": "샘플", "vendor": { "id": 10, "vendor_code": "V001", "vendor_name": "한빛상사", "note": "서울/경기 공급처" }, "uom": { "id": 1, "uom_name": "EA", "is_default": true }, "note": "출고 우선 재고", "is_active": true, "created_at": "2025-02-01T12:00:00Z", "updated_at": "2025-02-03T09:00:00Z" } } ``` ### 3.3 생성 `POST /vendors` ```json { "vendor_code": "V002", "vendor_name": "미래상사", "note": "부산 공급처", "is_active": true } ``` 응답: ```json { "data": { "id": 11, "vendor_code": "V002", "vendor_name": "미래상사", "note": "부산 공급처", "is_active": true, "created_at": "2025-03-01T00:00:00Z", "updated_at": "2025-03-01T00:00:00Z" } } ``` ### 3.4 수정 `PATCH /products/101` ```json { "id": 101, "product_name": "샘플 A", "note": "재고 우선순위 변경" } ``` ### 3.5 삭제 & 복구 - `DELETE /products/101` - `POST /products/101/restore` ### 3.6 그룹 메뉴 권한 일괄 갱신 `POST /groups/2/permissions` ```json { "id": 2, "entries": [ { "menu_id": 12, "can_create": true, "can_read": true, "can_update": true, "can_delete": false }, { "menu_id": 13, "can_create": false, "can_read": true, "can_update": false, "can_delete": false } ] } ``` 응답은 갱신된 권한 목록을 반환. > `zipcodes`는 대량 데이터 특성상 `GET /zipcodes?zipcode=06000&road_name=세종대로` 형태로 조회하며, 응답 항목에는 `zipcode`, `sido`, `sigungu`, `road_name`, `building_main_no` 등 주소 구성 요소가 포함된다. --- ## 4. 트랜잭션 API 리소스: `/stock-transactions`, 보조 리소스: `/transaction-lines`, `/transaction-customers` ### 4.1 생성 (헤더 + 라인 + 고객 다건) `POST /stock-transactions` ```json { "transaction_type_id": 1, "transaction_status_id": 1, "warehouse_id": 1, "transaction_date": "2025-09-18", "created_by_id": 7, "note": "창고 입고", "lines": [ { "line_no": 1, "product_id": 101, "quantity": 50, "unit_price": 1200 }, { "line_no": 2, "product_id": 102, "quantity": 20, "unit_price": 0 } ], "customers": [], "approval": { "requested_by_id": 7, "note": "입고 결재" } } ``` 응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. `transaction_no` 및 `approval.approval_no`는 요청 시 생략하며, 서버가 각각 `TRX-YYYYMMDDNNNN`, `APP-YYYYMMDDNNNN` 패턴으로 생성한 값을 응답에서 확인한다. `approval` 블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다. ### 4.2 목록 조회 `GET /stock-transactions?customer_id=301&include=lines,customers,approval` - `customer_id` (optional, number): 지정한 고객이 연결된 트랜잭션만 반환한다. 다른 검색 파라미터와 조합 가능하며, `include=customers` 사용 시 선택 고객 정보가 응답에 유지된다. ```json { "items": [ { "id": 9001, "transaction_no": "TRX-202511100001", "transaction_type": { "id": 1, "name": "입고" }, "transaction_status": { "id": 1, "name": "초안" }, "warehouse": { "id": 1, "warehouse_code": "WH-001", "warehouse_name": "1센터", "zipcode": { "zipcode": "06000", "road_name": "테헤란로" } }, "transaction_date": "2025-09-18", "created_by": { "id": 7, "employee_no": "E2025001", "employee_name": "김승인" }, "note": "창고 입고", "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, "line_no": 1, "product": { "id": 101, "product_code": "P100", "product_name": "샘플", "vendor": { "id": 10, "vendor_name": "한빛상사" }, "uom": { "id": 1, "uom_name": "EA" } }, "quantity": 50, "unit_price": 1200, "note": null } ], "customers": [ { "id": 301, "customer": { "id": 301, "customer_code": "C001", "customer_name": "ABC물류" }, "note": null } ], "approval": { "id": 5001, "approval_no": "APP-202511100001", "status": { "id": 1, "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", "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null }, "requester": { "id": 7, "employee_no": "E2025001", "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" } } ], "page": 1, "page_size": 50, "total": 1 } ``` ### 4.3 단건 조회 `GET /stock-transactions/9001?include=lines,customers,approval,approval.steps` ```json { "data": { "id": 9001, "transaction_no": "TRX-202511100001", "transaction_type": { "id": 1, "name": "입고" }, "transaction_status": { "id": 1, "name": "초안" }, "warehouse": { "id": 1, "warehouse_code": "WH-001", "warehouse_name": "1센터", "zipcode": { "zipcode": "06000", "sido": "서울특별시", "road_name": "테헤란로" } }, "transaction_date": "2025-09-18", "created_by": { "id": 7, "employee_no": "E2025001", "employee_name": "김승인" }, "note": "창고 입고", "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, "line_no": 1, "product": { "id": 101, "product_code": "P100", "product_name": "샘플", "vendor": { "id": 10, "vendor_name": "한빛상사" }, "uom": { "id": 1, "uom_name": "EA" } }, "quantity": 50, "unit_price": 1200, "note": null } ], "customers": [ { "id": 301, "customer": { "id": 301, "customer_code": "C001", "customer_name": "ABC물류" }, "note": null } ], "approval": { "id": 5001, "approval_no": "APP-202511100001", "status": { "id": 1, "name": "대기", "color": "#F97316", "is_blocking_next": true, "is_terminal": false }, "current_step": { "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 }, "requester": { "id": 7, "employee_no": "E2025001", "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" } } } ``` ### 4.4 헤더 수정 `PATCH /stock-transactions/9001` ```json { "id": 9001, "transaction_status_id": 2, "note": "상신 준비" } ``` ### 4.5 라인 다건 추가/수정/삭제 - **추가:** `POST /stock-transactions/9001/lines` ```json { "id": 9001, "lines": [ { "line_no": 2, "product_id": 102, "quantity": 20, "unit_price": 900 } ] } ``` - **일괄 수정:** `PATCH /stock-transactions/9001/lines` ```json { "id": 9001, "lines": [ { "id": 12001, "line_no": 1, "quantity": 60, "note": "추가 입고" }, { "id": 12002, "line_no": 2, "unit_price": 950 } ] } ``` - **삭제:** `DELETE /transaction-lines/12002` - **복구:** `POST /transaction-lines/12002/restore` ### 4.6 고객 연결 다건 관리 - **추가:** `POST /stock-transactions/9100/customers` ```json { "id": 9100, "customers": [ { "customer_id": 301, "note": "1차 납품" }, { "customer_id": 302, "note": "2차 납품" } ] } ``` - **수정:** `PATCH /stock-transactions/9100/customers` ```json { "id": 9100, "customers": [ { "id": 33001, "note": "수량 조정" } ] } ``` - **삭제:** `DELETE /transaction-customers/33001` ### 4.7 상태 전이 권장 API - `POST /stock-transactions/9001/submit` ```json { "id": 9001, "note": "승인 요청" } ``` - `POST /stock-transactions/9001/complete` ```json { "id": 9001, "note": "처리 완료" } ``` - `POST /stock-transactions/9001/approve` ```json { "id": 9001, "note": "최종 승인" } ``` - `POST /stock-transactions/9001/reject` ```json { "id": 9001, "note": "재작업 필요" } ``` - `POST /stock-transactions/9001/cancel` ```json { "id": 9001, "note": "상신 취소" } ``` 모든 액션은 `{ "data": { "id": 9001, "transaction_status": { ... }, "updated_at": "..." } }` 구조를 반환한다. `submit`은 초안 상태의 트랜잭션을 상신 상태로, 결재 현재 단계를 진행중으로 전환한다. `approve`는 결재 상태가 이미 승인(`approval_status_id = 승인`)으로 확정된 건을 재고 상태 `승인`으로 승격한다. `reject`는 상신/승인 상태의 건을 `반려` 상태로 내리고 결재 레코드도 반려로 남긴다. `cancel`은 상신된 건을 다시 초안 상태(또는 `취소` 상태가 존재할 경우 해당 상태)로 되돌리며, 결재 단계와 상태를 초기화한다. `complete` 는 결재 상태가 승인된 건에 한해 완료 상태로 변경한다. --- ## 5. 결재 API 리소스: `/approvals`, 보조 리소스: `/approval-steps`, `/approval-histories` - 단계 상태가 바뀔 때마다 `approvals.current_step_id`는 차기 단계의 ID로 갱신되고, 전체 결재 상태(`approval_status_id`) 역시 해당 단계 상태로 업데이트된다. - 템플릿에서 복제된 단계는 모두 `대기` 상태로 저장되며 템플릿이 이후 수정돼도 기존 결재에는 반영되지 않는다. - `GET /approvals/{id}/can-proceed`는 현재 단계의 상태에 매핑된 `is_blocking_next` 값이 `false`일 때 `true`를 반환한다. ### 5.1 결재 생성 `POST /approvals` ```json { "transaction_id": 9001, "approval_status_id": 1, "requested_by_id": 7, "note": "입고 결재" } ``` 응답: ```json { "data": { "approval": { "id": 5001, "approval_no": "APP-202511100001", "status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "current_step": null, "requester": { "id": 7, "employee_no": "E2025001", "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`는 서버가 자동 발급하는 읽기 전용 필드로 `APP-YYYYMMDDNNNN` 형식을 따른다. 클라이언트는 필드를 전송하지 않으며, 중복 방지는 서버에서 처리된다. - 최초 생성 시 `approval_status_id`에는 `대기` 상태 ID를 전달하고, 서버는 동일 상태로 저장한다. - 단계나 이력이 존재하면 `data.approval.steps`, `data.approval.histories`가 함께 반환된다. ### 5.2 목록 조회 `GET /approvals?include=steps,histories` ```json { "items": [ { "id": 5001, "approval_no": "APP-202511100001", "transaction": { "id": 9001, "transaction_no": "TRX-202511100001" }, "status": { "id": 1, "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", "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null }, "requester": { "id": 7, "employee_no": "E2025001", "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: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", "name": "박검토" }, "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 } ] } ], "page": 1, "page_size": 50, "total": 1 } ``` ### 5.3 단건 조회 `GET /approvals/5001?include=steps,histories` ```json { "data": { "id": 5001, "approval_no": "APP-202511100001", "transaction": { "id": 9001, "transaction_no": "TRX-202511100001" }, "status": { "id": 1, "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", "name": "박검토" }, "assigned_at": "2025-09-18T06:05:00Z", "decided_at": null, "note": null }, "requester": { "id": 7, "employee_no": "E2025001", "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "입고 결재", "steps": [ { "id": 7001, "step_order": 1, "status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "approver": { "id": 21, "employee_no": "E2025002", "name": "박검토" }, "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, "is_deleted": false, "created_at": "2025-09-18T06:00:00Z", "updated_at": "2025-09-18T06:05:00Z" } } ``` ### 5.4 단계 구성 (배치 생성) `POST /approvals/5001/steps` ```json { "id": 5001, "steps": [ { "step_order": 1, "approver_id": 21, "note": null }, { "step_order": 2, "approver_id": 34, "note": "재무 확인" } ] } ``` 응답: ```json { "data": { "approval_id": 5001, "steps": [ { "id": 7001, "approval_id": 5001, "step_order": 1, "approver_id": 21, "status_id": 1, "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, "is_active": true }, { "id": 7002, "approval_id": 5001, "step_order": 2, "approver_id": 34, "status_id": 1, "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": "재무 확인", "is_active": true } ], "approval": { "id": 5001, "transaction_no": "TRX-202511100001", "status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "current_step": { "id": 7001, "step_order": 1 }, "template_name": "입고 결재 기본", "updated_at": "2025-09-18T06:05:00Z" } } } ``` ### 5.5 단계 일괄 수정/재배치 `PATCH /approvals/5001/steps` ```json { "id": 5001, "steps": [ { "id": 7001, "step_order": 1, "note": "서류 확인 중" }, { "id": 7002, "step_order": 2, "approver_id": 35 } ] } ``` 응답: ```json { "data": { "approval_id": 5001, "steps": [ { "id": 7001, "approval_id": 5001, "step_order": 1, "approver_id": 21, "status_id": 1, "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": "서류 확인 중", "is_active": true }, { "id": 7002, "approval_id": 5001, "step_order": 2, "approver_id": 35, "status_id": 1, "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": "재무 확인", "is_active": true } ], "approval": { "id": 5001, "transaction_no": "TRX-202511100001", "status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "current_step": { "id": 7001, "step_order": 1 }, "template_name": "입고 결재 기본", "updated_at": "2025-09-18T06:10:00Z" } } } ``` ### 5.6 단계 행위 `POST /approval-steps/7001/actions` ```json { "id": 7001, "approval_action_id": 1, "note": "승인합니다." } ``` 응답: ```json { "data": { "approval": { "id": 5001, "transaction_no": "TRX-202511100001", "status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "current_step": { "id": 7002, "step_order": 2, "approver": { "id": 34, "employee_no": "E2025003", "name": "최검토" }, "status": { "id": 3, "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "assigned_at": "2025-09-18T08:05:00Z", "decided_at": null, "note": "재무 확인" }, "updated_at": "2025-09-18T08:05:00Z", "histories": [ { "id": 91001, "action": { "id": 1, "name": "승인" }, "action_at": "2025-09-18T08:05:00Z", "note": "승인합니다.", "from_status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "to_status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false } } ] }, "step": { "id": 7001, "approval_id": 5001, "step_order": 1, "approver_id": 21, "status_id": 2, "step_status_id": 2, "status": { "id": 2, "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, "step_order": 2, "approver": { "id": 34, "employee_no": "E2025003", "name": "최검토" }, "status": { "id": 3, "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "assigned_at": "2025-09-18T08:05:00Z", "decided_at": null, "note": "재무 확인" }, "history": { "id": 91001, "approval_step_id": 7001, "action": { "id": 1, "name": "승인" }, "note": "승인합니다.", "action_at": "2025-09-18T08:05:00Z" } } } ``` 응답에는 전후 상태(`from_status`, `to_status`), 차기 단계 정보가 포함되며, `approval_histories`에 기록된다. ### 5.7 결재 상태 확인 `GET /approvals/5001/can-proceed` ```json { "data": { "id": 5001, "can_proceed": true, "reason": null } } ``` ### 5.8 결재 수정·삭제·복구 - `PATCH /approvals/5001` ```json { "id": 5001, "approval_status_id": 2, "note": "보류 처리" } ``` 응답은 `data.approval` 구조로 최신 요약을 반환한다. ```json { "data": { "approval": { "id": 5001, "approval_no": "APP-202511100001", "status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "current_step": { "id": 7002, "step_order": 2 }, "requester": { "id": 7, "employee_no": "E2025001", "name": "김승인" }, "requested_at": "2025-09-18T06:00:00Z", "decided_at": null, "note": "보류 처리", "template_name": "입고 결재 기본", "updated_at": "2025-09-18T08:10:00Z" } } } ``` - `DELETE /approvals/5001` - `POST /approvals/5001/restore` ### 5.9 결재 이력 조회 `GET /approval-histories?approval_id=5001&include=approval,step,approver` ```json { "items": [ { "id": 91001, "approval_id": 5001, "approval_step_id": 7001, "action": { "id": 3, "name": "보류" }, "action_at": "2025-09-18T08:05:00Z", "note": "보류 코멘트", "approver": { "id": 21, "employee_no": "E2025002", "employee_name": "박검토" }, "from_status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "to_status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "approval": { "id": 5001, "approval_no": "APP-202511100001", "status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false } }, "step": { "id": 7001, "approval_id": 5001, "step_order": 1, "approver": { "id": 21, "employee_no": "E2025002", "name": "박검토" }, "status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false } } } ], "page": 1, "page_size": 50, "total": 2 } ``` ### 5.10 단계 개별 CRUD - `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: { ... } }` 형태로 생성된 요약을 반환한다. `status_id`(구 버전 호환용 `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_id`, `approval_step_id`, `approver_id`, `approval_action_id`, `status_id` - `q`(결재번호·승인자 검색), `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`, `status` 토큰으로 확장 `GET /approval-histories/91001?include=approval,step` ```json { "data": { "id": 91001, "approval_id": 5001, "approval_step_id": 7001, "action": { "id": 3, "name": "보류" }, "action_at": "2025-09-18T08:05:00Z", "note": "보류 코멘트", "approver": { "id": 21, "employee_no": "E2025002", "employee_name": "박검토" }, "from_status": null, "to_status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false }, "approval": { "id": 5001, "approval_no": "APP-202511100001", "status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false } }, "step": { "id": 7001, "approval_id": 5001, "step_order": 1, "approver": { "id": 21, "employee_no": "E2025002", "employee_name": "박검토" }, "status": { "id": 2, "name": "진행중", "is_blocking_next": true, "is_terminal": false } } } } ``` --- ## 6. 결재 템플릿 API 리소스: `/approval-templates` ### 6.1 목록 조회 `GET /approval-templates?page=1` ```json { "items": [ { "id": 3001, "template_code": "AP_INBOUND", "template_name": "입고 결재 기본", "description": "입고 결재 2단계", "created_by": { "id": 7, "employee_no": "E2025001", "employee_name": "김승인" }, "is_active": true, "created_at": "2025-01-20T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z" } ], "page": 1, "page_size": 50, "total": 1 } ``` ### 6.2 단건 조회 `GET /approval-templates/3001?include=steps` ```json { "data": { "id": 3001, "template_code": "AP_INBOUND", "template_name": "입고 결재 기본", "description": "입고 결재 2단계", "created_by": { "id": 7, "employee_no": "E2025001", "employee_name": "김승인" }, "steps": [ { "id": 9101, "step_order": 1, "approver": { "id": 21, "employee_no": "E2025002", "employee_name": "박검토" }, "note": null } ], "is_active": true, "created_at": "2025-01-20T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z" } } ``` ### 6.3 생성·수정 - `POST /approval-templates` ```json { "template_code": "AP_OUTBOUND", "template_name": "출고 결재 기본", "description": "출고 결재 3단계", "created_by_id": 7, "note": "표준 출고" } ``` - `POST /approval-templates/3002/steps` ```json { "id": 3002, "steps": [ { "step_order": 1, "approver_id": 34 }, { "step_order": 2, "approver_id": 55 } ] } ``` - `PATCH /approval-templates/3002` ```json { "id": 3002, "template_name": "출고 결재 확장", "note": "정기 출고용" } ``` - `PATCH /approval-templates/3002/steps` ```json { "id": 3002, "steps": [ { "id": 9105, "step_order": 1, "approver_id": 36 } ] } ``` - 삭제/복구: `DELETE /approval-templates/{id}`, `POST /approval-templates/{id}/restore` --- ## 7. 보고서 Export - 공통 쿼리 파라미터: `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" } } ``` - 감사 로그와 권한 검증은 모든 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` - `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=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. 인증 및 대시보드 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": "TRX-202511100001", "transaction_date": "2025-09-18", "transaction_type": "입고", "status_name": "상신", "created_by": "김승인" } ], "pending_approvals": [ { "approval_no": "APP-202511100005", "title": "출고 결재", "step_summary": "2단계/3단계 진행중", "requested_at": "2025-09-17T03:00:00Z" } ] } } ``` --- ## 9. 구현 참고 - FK 요약 정보는 기본 응답에 포함하며, 상세 정보가 필요하면 `include` 파라미터를 활용해 확장한다. - 배열 기반 다건 작업은 전체를 트랜잭션 처리해야 한다. 실패 시 롤백하고 부분 처리 결과를 반환하지 않는다. - `is_active` 변경은 권한·결재 등의 즉시성 요구를 고려하여 관련 캐시를 무효화한다. - 결재 단계 상태 전이는 `approval_statuses.is_blocking_next` 규칙을 준수해야 하며, 반려(`is_terminal=true`) 상태 시 결재를 종료한다.