- frontend_backend_alignment_report.md에 사이드바/그룹 권한 TODO와 테스트 계획을 정리 - stock_approval_system_api_v4.md에 메뉴/그룹 권한 API 규칙과 응답 예시를 추가 - stock_approval_system_spec_v4.md에 공식 메뉴 표와 기본 그룹 권한 케이스를 기재
62 KiB
간단 입·출고 + 결재 시스템 API 규격 (v4)
기준 버전: 2025-01-04 15:00:00Z (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,/users,/groups,/menus,/group-menu-permissions,/permission-scopes,/group-permission-scopes,/zipcodes - 결재 플로우:
/approvals,/approval(제출·전이·이력),/approval-steps,/approval/history,/approval-drafts - 재고 현황:
/inventory/summary,/inventory/summary/{product_id}— 재고 변동 이벤트 기반 Read-only 목록/단건 조회 - 각 자원은
/api/v1/<resource>패턴을 따르며, 목록 필터·페이지네이션·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,users등) 선택 가능. 포함 대상은 FK 요약 정보를 이미 반환하므로include는 상세 컬렉션을 불러올 때 사용. - 배열 입력: 트랜잭션 라인, 트랜잭션 고객, 결재 단계, 그룹 메뉴 권한 등 다건 작업은 항상 배열(
[]) 기반으로 요청한다. - Primary Key 규칙: Create 요청 바디에는 PK를 포함하지 않는다. Create 응답 및 나머지 모든 요청·응답에는 PK가 포함돼야 한다(경로에 이미 포함된 경우라도 바디 내
id를 명시). - 에러 규격:
400 BAD_REQUEST— 검증 오류, 필수값 누락.404 NOT_FOUND— 리소스 없음 또는 삭제됨.409 CONFLICT— 유니크 제약, 결재 단계 상태 충돌.422 UNPROCESSABLE_ENTITY— 비즈니스 규칙 위반(출고 고객 누락, blocking 상태 전이 등).403 FORBIDDEN— 권한 부족. 결재 열람 제한 시APPROVAL_ACCESS_DENIED코드를 사용한다.- 에러 응답 예:
{ "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가 응답된다. - 권한 스코프: 메뉴 기반 권한과 별도로
permission_scopes와group_permission_scopes가 기능 권한을 관리한다. 로그인 응답의permissions배열에는scope:<code>형식의 항목이 추가되며,permission_codes필드에는 스코프 코드가 그대로 채워진다. 결재 관련 전역 권한은approval.manage,approval.view_all,approval.approve세 스코프로 제어하며, 재고 현황 조회는 읽기 전용 스코프inventory.view를 요구한다. 프런트엔드는 해당 스코프를 기준으로 결재/재고 화면 접근 및 전이·표시 여부를 결정해야 한다.
2. 타입(룩업) API
대상: /uoms, /transaction-types, /transaction-statuses, /approval-statuses, /approval-actions
2.1 목록 조회
GET /{type}?page=1&page_size=50&active=true
{
"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}
{
"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}
{
"name": "진행중",
"is_default": false,
"is_blocking_next": true,
"is_terminal": false,
"is_active": true,
"note": null
}
응답:
{
"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}
{
"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, /users, /products, /menus, /groups, /zipcodes
기본 정렬: 별도
sort파라미터가 없으면 항상id오름차순으로 응답을 정렬한다. (order기본값도asc)
3.1 목록 조회
GET /vendors?page=1&q=한빛
{
"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
{
"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
{
"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
{
"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 /users?page=1
{
"items": [
{
"id": 7,
"employee_id": "E2025001",
"name": "김승인",
"email": "approver@example.com",
"phone": "+82-10-2222-1111",
"group": {
"id": 2,
"group_name": "창고 관리자"
},
"force_password_change": false,
"password_updated_at": "2025-01-10T09:00:00Z",
"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,users
{
"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
}
],
"users": [
{
"id": 7,
"employee_id": "E2025001",
"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
{
"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
{
"vendor_code": "V002",
"vendor_name": "미래상사",
"note": "부산 공급처",
"is_active": true
}
응답:
{
"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
{
"id": 101,
"product_name": "샘플 A",
"note": "재고 우선순위 변경"
}
3.5 삭제 & 복구
DELETE /products/101POST /products/101/restore
3.6 그룹 메뉴 권한 일괄 갱신
POST /groups/2/permissions
{
"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등 주소 구성 요소가 포함된다.
3.7 사용자 계정 전용 엔드포인트
3.7.1 관리자 사용자 생성
POST /users
{
"employee_id": "E2025012",
"name": "홍관리",
"email": "admin@example.com",
"phone": "+82-10-3333-4444",
"group_id": 1,
"password": "Admin!234"
}
응답:
{
"data": {
"id": 15,
"employee_id": "E2025012",
"name": "홍관리",
"email": "admin@example.com",
"phone": "+82-10-3333-4444",
"group": {
"id": 1,
"group_name": "전사 관리자"
},
"force_password_change": true,
"password_updated_at": "2025-03-10T00:00:00Z",
"is_active": true,
"created_at": "2025-03-10T00:00:00Z",
"updated_at": "2025-03-10T00:00:00Z"
}
}
employee_id는 영문/숫자 4~32자(^[A-Za-z0-9]{4,32}$)이고 대소문자 구분 없이 유니크해야 한다. 저장 시 대문자로 정규화한다.password는 길이 8~24자, 대/소문자·숫자·특수문자 각 1자 이상을 포함해야 하며 평문은 응답에 포함되지 않는다.- 생성 즉시
force_password_change=true로 설정하고 임시 비밀번호 안내 메일을 발송 큐에 적재한다. - 최고 관리자 계정
terabits는 삭제하거나 비활성화하지 않으며, 비밀번호 재설정만 허용한다.
3.7.2 자기 정보 수정
PATCH /users/me
{
"email": "approver@example.com",
"phone": "+82-10-2222-1111",
"password": "NewPass!234",
"current_password": "TempPass1"
}
응답:
{
"data": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인",
"email": "approver@example.com",
"phone": "+82-10-2222-1111",
"force_password_change": false,
"password_updated_at": "2025-03-11T02:00:00Z",
"is_active": true,
"created_at": "2025-01-02T09:00:00Z",
"updated_at": "2025-03-11T02:00:00Z"
}
}
- 변경 가능한 필드는
email,phone,password3개다.password를 변경할 때는current_password필드가 필수다. - 비밀번호 변경이 성공하면 서버는 기존 액세스/리프레시 토큰을 모두 폐기하고, 클라이언트는 로그아웃 팝업을 띄운 뒤 재로그인을 요구해야 한다.
3.7.3 관리자 사용자 수정
PATCH /users/{id}
{
"id": 7,
"email": "approver@example.com",
"phone": "+82-10-2222-1111",
"group_id": 2,
"is_active": true
}
응답은 갱신된 사용자 단건을 반환한다. 관리자는 group_id, email, phone, is_active, force_password_change를 변경할 수 있다(비밀번호 직접 수정 불가).
3.7.4 관리자 비밀번호 재설정
POST /users/{id}/reset-password
요청 바디는 비워둔다. 서버는 8자 영문 대소문자+숫자 조합으로 임시 비밀번호를 생성해 이메일 발송 큐에 넣고, 응답으로 상태를 전달한다.
{
"data": {
"id": 7,
"employee_id": "E2025001",
"email": "approver@example.com",
"force_password_change": true,
"password_updated_at": "2025-03-11T02:05:00Z"
}
}
- 임시 비밀번호 값은 응답에 포함하지 않는다. 메일 템플릿에서 안내하며, 관리 콘솔에는 마스킹된 값만 노출한다.
- 재설정 시 즉시 기존 세션을 무효화하고, 사용자 최초 로그인 시 비밀번호 변경 화면으로 리다이렉션한다.
3.9 메뉴 & 그룹 권한
- 목적: 프런트 사이드바/권한 편집 화면에서 동일한 메뉴 목록을 재사용하기 위한 단일 소스.
- 엔드포인트:
GET /menus,POST /menus,PATCH /menus/{id},DELETE /menus/{id},GET /group-menu-permissions. - 정렬 규칙:
parent_menu_id ASC,display_order ASC. - 필수 필드:
menu_code,menu_name,parent_menu_id,route_path,display_order. - 삭제 정책: UI에서 제거된 메뉴는
is_deleted=true로만 남겨두며 기본 응답에서는 제외한다. 필요 시include_deleted=true로 조회해 비활성 메뉴를 회색 처리한다.
GET /menus?active=true&include=parent&order=asc
{
"items": [
{
"id": 10,
"menu_code": "inventory.receipts",
"menu_name": "입고",
"parent_menu_id": 2,
"route_path": "/inventory/receipts",
"display_order": 10,
"note": "입고 전표/입고 처리",
"is_active": true,
"created_at": "2025-11-11T12:00:00Z",
"updated_at": "2025-11-11T12:00:00Z",
"parent": {
"id": 2,
"menu_code": "inventory",
"menu_name": "재고/입출고",
"route_path": "/inventory"
}
}
],
"page": 1,
"page_size": 50,
"total": 18
}
권한 편집 시 group_menu_permissions의 menu 객체는 항상 menu_code, menu_name, route_path, is_deleted를 포함하며, UI는 이 값을 그대로 드롭다운/트리 항목으로 사용해야 한다. 메뉴 추가/삭제는 먼저 menus에 반영한 뒤 각 그룹 권한을 업데이트해야 하며, menu_code는 프런트 라우트 키와 반드시 동일해야 한다.
4. 트랜잭션/재고 API
리소스: /stock-transactions, 보조 리소스: /transaction-lines, /transaction-customers
4.1 생성 (헤더 + 라인 + 고객 다건)
POST /stock-transactions
{
"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": {
"status_id": 2,
"requested_by_id": 7,
"note": "입고 결재",
"config": {
"template_id": 1201,
"steps": [
{ "step_order": 1, "approver_id": 21 },
{ "step_order": 2, "approver_id": 34, "note": "재무 확인" }
]
}
}
}
응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. transaction_no
및 approval.approval_no는 요청 시 생략하며, 서버가 각각 TRX-YYYYMMDDNNNN,
APP-YYYYMMDDNNNN 패턴으로 생성한 값을 응답에서 확인한다. approval
블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없고, config에는 템플릿 ID 또는 단계 배열 중 하나 이상이 반드시 포함돼야 한다.
기본 목록(
status미지정,include_pending미사용)과 대시보드recent_transactions카드는 최종 승인 완료된 전표만 노출한다. 초안·상신 단계 전표는status=draft,submitted또는include_pending=true로 별도 조회하거나 Approval Flow 화면에서 확인한다.
4.2 목록 조회
GET /stock-transactions?customer_id=301&include=lines,customers,approval
customer_id(optional, number): 지정한 고객이 연결된 트랜잭션만 반환한다. 다른 검색 파라미터와 조합 가능하며,include=customers사용 시 선택 고객 정보가 응답에 유지된다.status(optional, string):draft,submitted,approved,completed,rejected,recalled값을 콤마로 조합한다. 기본값은approved,completed이며, 초안/상신 건을 조회하려면status=draft,submitted을 명시해야 한다.include_pending=true(optional, boolean):true로 설정 시 기본 필터를 무시하고 모든 상태(초안·상신 포함)를 반환한다. 기본 응답은 최종 승인(approved,completed) 건만 포함한다.
{
"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_id": "E2025001",
"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,
"current_step_id": 7001,
"requester_id": 7,
"final_approver_id": 34,
"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_id": "E2025002",
"name": "박검토"
},
"assigned_at": "2025-09-18T06:05:00Z",
"decided_at": null,
"note": null
},
"requester": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"final_approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"requested_at": "2025-09-18T06:00:00Z",
"decided_at": null,
"note": "입고 결재",
"template_name": "입고 결재 기본",
"metadata": {
"flow_version": "v2"
},
"last_action_at": "2025-09-18T06:05:00Z",
"is_active": true,
"is_deleted": false,
"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
{
"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_id": "E2025001",
"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,
"current_step_id": 7001,
"requester_id": 7,
"final_approver_id": 34,
"status": {
"id": 1,
"name": "대기",
"color": "#F97316",
"is_blocking_next": true,
"is_terminal": false
},
"current_step": {
"id": 7001,
"request_id": 5001,
"step_order": 1,
"approver": {
"id": 21,
"employee_id": "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,
"is_optional": false
},
"requester": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"final_approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"summary": "11월 2주차 입고",
"note": "입고 결재",
"requested_at": "2025-09-18T06:00:00Z",
"decided_at": null,
"template_name": "입고 결재 기본",
"metadata": {
"flow_version": "v2"
},
"steps": [
{
"id": 7001,
"request_id": 5001,
"step_order": 1,
"template_step_id": 41001,
"approver_role": "창고장",
"approver": {
"id": 21,
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": "2025-09-18T06:05:00Z",
"decided_at": null,
"action_at": null,
"note": null,
"is_optional": false,
"escalation_minutes": null,
"metadata": null
},
{
"id": 7002,
"request_id": 5001,
"step_order": 2,
"template_step_id": 41002,
"approver_role": "재무",
"approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": null,
"decided_at": null,
"action_at": null,
"note": "재무 확인",
"is_optional": false,
"escalation_minutes": 120,
"metadata": {
"reminder": "sms"
}
}
],
"histories": [
{
"id": 91001,
"request_id": 5001,
"step_id": 7001,
"actor": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"action": {
"id": 1,
"name": "상신",
"code": "submit"
},
"from_status": null,
"to_status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"action_at": "2025-09-18T06:00:00Z",
"action_code": "submit",
"note": null
}
],
"last_action_at": "2025-09-18T06:05:00Z",
"is_active": true,
"is_deleted": false,
"created_at": "2025-09-18T06:00:00Z",
"updated_at": "2025-09-18T06:05:00Z"
}
}
}
4.4 헤더 수정
PATCH /stock-transactions/9001
{
"id": 9001,
"transaction_status_id": 2,
"note": "상신 준비"
}
4.5 라인 다건 추가/수정/삭제
- 추가:
POST /stock-transactions/9001/lines
{
"id": 9001,
"lines": [
{
"line_no": 2,
"product_id": 102,
"quantity": 20,
"unit_price": 900
}
]
}
- 일괄 수정:
PATCH /stock-transactions/9001/lines
{
"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
{
"id": 9100,
"customers": [
{
"customer_id": 301,
"note": "1차 납품"
},
{
"customer_id": 302,
"note": "2차 납품"
}
]
}
- 수정:
PATCH /stock-transactions/9100/customers
{
"id": 9100,
"customers": [
{
"id": 33001,
"note": "수량 조정"
}
]
}
- 삭제:
DELETE /transaction-customers/33001
4.7 상태 전이 권장 API
POST /stock-transactions/9001/submit
{
"id": 9001,
"note": "승인 요청"
}
POST /stock-transactions/9001/complete
{
"id": 9001,
"note": "처리 완료"
}
POST /stock-transactions/9001/approve
{
"id": 9001,
"note": "최종 승인"
}
POST /stock-transactions/9001/reject
{
"id": 9001,
"note": "재작업 필요"
}
POST /stock-transactions/9001/cancel
{
"id": 9001,
"note": "상신 취소"
}
모든 액션은 { "data": { "id": 9001, "transaction_status": { ... }, "updated_at": "..." } } 구조를 반환한다. submit은 초안 상태의 트랜잭션을 상신 상태로, 결재 현재 단계를 진행중으로 전환한다. approve는 결재 상태가 이미 승인(approval_status_id = 승인)으로 확정된 건을 재고 상태 승인으로 승격한다. reject는 상신/승인 상태의 건을 반려 상태로 내리고 결재 레코드도 반려로 남긴다. cancel은 상신된 건을 다시 초안 상태(또는 취소 상태가 존재할 경우 해당 상태)로 되돌리며, 결재 단계와 상태를 초기화한다. complete 는 결재 상태가 승인된 건에 한해 완료 상태로 변경한다.
4.8 재고 현황 목록 (GET /inventory/summary)
- 요구 권한:
scope:inventory.view+group_menu_permissions에서menu_code=inventory의can_read=true. - 데이터 출처:
inventory_balance_snapshots마테뷰(5분 주기 리프레시). API는 동일 요청 내에서 2초 TTL 캐시를 적용한다. - 쿼리 파라미터
page,page_size(기본 50)q: 제품 코드/명칭 부분 일치product_name,vendor_name: 개별 필터warehouse_id: 특정 창고 재고만 노출. 지정 시warehouse_balances는 해당 창고 1건만 반환updated_since: 증분 조회 (inventory_balance_snapshots.updated_at기준)include_empty:true일 때 수량 0 창고도 반환 (기본 false)sort:last_event_at(기본),product_name,vendor_name,total_quantityorder:asc|desc(기본 desc)
{
"items": [
{
"product": {
"id": 101,
"product_code": "P100",
"product_name": "샘플",
"vendor": {
"id": 10,
"vendor_name": "한빛상사"
}
},
"total_quantity": 120,
"warehouse_balances": [
{
"warehouse": {
"id": 1,
"warehouse_code": "WH-001",
"warehouse_name": "1센터"
},
"quantity": 80
},
{
"warehouse": {
"id": 2,
"warehouse_code": "WH-002",
"warehouse_name": "2센터"
},
"quantity": 40
}
],
"recent_event": {
"event_id": 15001,
"event_kind": "issue",
"event_label": "출고",
"delta_quantity": -20,
"counterparty": {
"type": "customer",
"name": "ABC물류"
},
"warehouse": {
"id": 1,
"warehouse_code": "WH-001",
"warehouse_name": "1센터"
},
"transaction": {
"id": 9100,
"transaction_no": "TRX-202511100001"
},
"occurred_at": "2025-10-24T02:58:00Z"
},
"updated_at": "2025-10-24T03:12:00Z"
}
],
"page": 1,
"page_size": 50,
"total": 1
}
warehouse_balances는 수량 내림차순으로 정렬된 객체 배열이며, 잔량이 0인 창고는include_empty=true를 지정하지 않는 이상 숨긴다.recent_event.event_kind값은receipt|issue|rental_out|rental_return. 프런트는event_label을 그대로 UI에 표시하면 된다.recent_event.counterparty.type은vendor|customer|unknown. 거래처가 없을 경우unknown이며name=null.- 감사 로그: 조회가 성공하면
inventory.summary.viewed이벤트를 발행하고{ actor_id, filters, result_count, request_id }페이로드를 남긴다.filters에는page,page_size,warehouse_id,include_empty,sort,order,updated_since가 포함된다. - 오류 코드
403 FORBIDDEN—inventory.view스코프 또는menu_code=inventory의 읽기 권한이 없으면INVENTORY_SCOPE_REQUIRED.409 CONFLICT— 마테뷰가 아직 준비되지 않았거나 갱신이 지연되면INVENTORY_SNAPSHOT_NOT_READY.
4.9 재고 현황 단건 (GET /inventory/summary/{product_id})
- 요구 권한:
scope:inventory.view. - 쿼리 파라미터:
event_limit(기본 20, 최대 100),warehouse_id(선택 — 특정 창고 히스토리만 반환). - 응답은 제품 기본 정보와 최근 이벤트 배열을 포함한다.
{
"data": {
"product": {
"id": 101,
"product_code": "P100",
"product_name": "샘플",
"vendor": {
"id": 10,
"vendor_name": "한빛상사"
}
},
"total_quantity": 120,
"warehouse_balances": [
{
"warehouse": {
"id": 1,
"warehouse_code": "WH-001",
"warehouse_name": "1센터"
},
"quantity": 80
}
],
"recent_events": [
{
"event_id": 15001,
"event_kind": "issue",
"event_label": "출고",
"delta_quantity": -20,
"counterparty": {
"type": "customer",
"name": "ABC물류"
},
"warehouse": {
"id": 1,
"warehouse_code": "WH-001",
"warehouse_name": "1센터"
},
"transaction": {
"id": 9100,
"transaction_no": "TRX-202511100001"
},
"line": {
"id": 12001,
"line_no": 1,
"quantity": 20
},
"occurred_at": "2025-10-24T02:58:00Z"
},
{
"event_id": 14990,
"event_kind": "receipt",
"event_label": "입고",
"delta_quantity": 50,
"counterparty": {
"type": "vendor",
"name": "한빛상사"
},
"warehouse": {
"id": 1,
"warehouse_code": "WH-001",
"warehouse_name": "1센터"
},
"transaction": {
"id": 9050,
"transaction_no": "TRX-202511050010"
},
"line": {
"id": 11990,
"line_no": 1,
"quantity": 50
},
"occurred_at": "2025-10-23T23:12:00Z"
}
],
"updated_at": "2025-10-24T03:12:00Z",
"last_refreshed_at": "2025-10-24T03:10:00Z"
}
}
recent_events는event_occurred_at DESC로 정렬된다.line객체는 원본transaction_lines스냅샷을 제공한다. 삭제된 라인은is_deleted=true인 경우 제외된다.last_refreshed_at은 뷰가 마지막으로 리프레시된 시각(UTC)이며, UI에서 새로고침 표시를 위해 사용한다.- 감사 로그: 단건 조회 역시
inventory.summary.viewed이벤트를 남기며filters에는product_id,warehouse_id,event_limit가 포함된다. - 오류 코드
403 FORBIDDEN—inventory.view스코프 또는 메뉴 권한이 없으면INVENTORY_SCOPE_REQUIRED.409 CONFLICT— 제품별 스냅샷이 아직 생성되지 않았거나 리프레시되지 않은 경우INVENTORY_SNAPSHOT_NOT_READY.404 NOT_FOUND— 존재하지 않는 제품
5. 결재 API
경로 요약: /api/v1/approvals(조회·단계 관리), /api/v1/approval(제출·상태 전이·이력), /api/v1/approval-drafts(임시 저장).
- 결재는 항상 트랜잭션과 연결되며,
approval_no는APP-YYYYMMDDNNNN형식으로 서버가 발급한다. - 템플릿 기반 제출 시 단계는
대기상태로 복제된다. 템플릿을 이후 수정해도 기존 결재에는 영향을 주지 않는다. - 모든 전이 엔드포인트는 낙관적 잠금을 위해
expected_updated_at(필수)을 요구하며, 트랜잭션과 동기화가 필요한 경우transaction_expected_updated_at을 함께 전달한다. 일치하지 않으면409 CONFLICT가 발생하며 결재 버전이 어긋난 경우APPROVAL_VERSION_MISMATCH, 전표 버전이 다를 때는TRANSACTION_VERSION_MISMATCH코드를 반환한다. 연결된 전표가 삭제됐거나 찾지 못하면409 CONFLICT(TRANSACTION_NOT_FOUND)가 응답된다. - 열람 권한: 상신자, 현재 단계 승인자, 이미 승인/반려를 완료한 승인자,
approval.manage보유자만 결재 상세와 이력에 접근할 수 있다. 조건을 충족하지 못하면403(APPROVAL_ACCESS_DENIED).
5.1 결재 제출 (POST /approval/submit)
{
"approval": {
"transaction_id": 91001,
"template_id": 1201,
"approval_status_id": 2,
"requested_by_id": 7,
"final_approver_id": 34,
"title": "입고 전표 결재",
"summary": "2025년 11월 2주차 입고",
"note": "선입고 재고 확인 필요",
"metadata": {
"flow_version": "v2",
"channel": "web"
}
},
"steps": [
{ "step_order": 1, "approver_id": 21, "note": null },
{ "step_order": 2, "approver_id": 34, "note": "재무 확인" }
]
}
응답 (ApprovalDetailResponse):
{
"data": {
"id": 5001,
"approval_no": "APP-202501040001",
"transaction_id": 91001,
"template_id": 1201,
"status_id": 2,
"current_step_id": 73001,
"requester_id": 7,
"final_approver_id": 34,
"transaction": {
"id": 91001,
"transaction_no": "TRX-202501040015",
"updated_at": "2025-01-04T05:05:00Z"
},
"template": {
"id": 1201,
"template_code": "WH_IN_DEFAULT",
"template_name": "입고 결재 기본",
"version": 3
},
"status": {
"id": 2,
"name": "상신",
"color": "#F97316",
"is_blocking_next": true,
"is_terminal": false
},
"current_step": {
"id": 73001,
"request_id": 5001,
"step_order": 1,
"approver": {
"id": 21,
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
"id": 2,
"name": "진행",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": "2025-01-04T05:05:00Z",
"decided_at": null,
"note": null,
"is_optional": false
},
"requester": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"final_approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"title": "입고 전표 결재",
"summary": "2025년 11월 2주차 입고",
"note": "선입고 재고 확인 필요",
"requested_at": "2025-01-04T05:00:00Z",
"decided_at": null,
"cancelled_at": null,
"last_action_at": "2025-01-04T05:05:00Z",
"metadata": {
"flow_version": "v2",
"channel": "web"
},
"template_name": "입고 결재 기본",
"is_active": true,
"is_deleted": false,
"created_at": "2025-01-04T05:00:00Z",
"updated_at": "2025-01-04T05:05:00Z",
"steps": [
{
"id": 73001,
"request_id": 5001,
"step_order": 1,
"template_step_id": 42001,
"approver_role": "창고장",
"approver": {
"id": 21,
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
"id": 2,
"name": "진행",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": "2025-01-04T05:05:00Z",
"decided_at": null,
"action_at": null,
"note": null,
"is_optional": false,
"escalation_minutes": null,
"metadata": null
},
{
"id": 73002,
"request_id": 5001,
"step_order": 2,
"template_step_id": 42002,
"approver_role": "재무",
"approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": null,
"decided_at": null,
"action_at": null,
"note": "재무 확인",
"is_optional": false,
"escalation_minutes": 120,
"metadata": {
"reminder": "sms"
}
}
],
"histories": [
{
"id": 98001,
"step_id": 73001,
"actor": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"action": {
"id": 1,
"name": "상신",
"code": "submit"
},
"from_status": null,
"to_status": {
"id": 2,
"name": "상신",
"is_blocking_next": true,
"is_terminal": false
},
"action_at": "2025-01-04T05:00:00Z",
"action_code": "submit",
"note": null
}
],
"draft": null
}
}
steps[].status는 결재 단계 상태 마스터(approval_statuses)를 따른다.draft필드는 결재 재개 시 사용된 초안이 존재할 때에만 객체로 채워진다.
5.2 결재 목록 (GET /approvals)
쿼리 파라미터:
status:draft,submitted,in_progress,approved,completed,rejected,recalled,cancelled중 콤마 구분. 기본값은approved,completed. 한글 별칭(승인,반려,임시)과 영문 슬러그(approved,rejected,submitted)를 혼용해도 서버가 매핑한다.include:transaction,template,steps,histories,draft를 조합한다.steps/histories는 비용이 크므로 필요한 경우에만 사용.include_pending=true설정 시 기본 상태 필터에draft,submitted,in_progress가 추가된다.transaction과requester요약은 기본 응답에 항상 포함되므로 별도include없이도 반환된다.
응답 예시(요약 전용):
{
"items": [
{
"id": 5001,
"approval_no": "APP-202501040001",
"transaction_id": 91001,
"template_id": 1201,
"status_id": 2,
"current_step_id": 73001,
"requester_id": 7,
"final_approver_id": 34,
"transaction": {
"id": 91001,
"transaction_no": "TRX-202501040015",
"updated_at": "2025-01-04T05:05:00Z"
},
"template": {
"id": 1201,
"template_code": "WH_IN_DEFAULT",
"template_name": "입고 결재 기본",
"version": 3
},
"status": {
"id": 2,
"name": "상신",
"color": "#F97316",
"is_blocking_next": true,
"is_terminal": false
},
"current_step": {
"id": 73001,
"step_order": 1,
"status": {
"id": 2,
"name": "진행",
"is_blocking_next": true,
"is_terminal": false
},
"approver": {
"id": 21,
"employee_id": "E2025002",
"name": "박검토"
}
},
"requester": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"final_approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"summary": "2025년 11월 2주차 입고",
"note": "선입고 재고 확인 필요",
"requested_at": "2025-01-04T05:00:00Z",
"decided_at": null,
"last_action_at": "2025-01-04T05:05:00Z",
"is_active": true,
"is_deleted": false,
"created_at": "2025-01-04T05:00:00Z",
"updated_at": "2025-01-04T05:05:00Z"
}
],
"page": 1,
"page_size": 50,
"total": 1
}
transaction.updated_at은 전표 낙관적 잠금(재조회 시 버전 확인)에 활용된다.
5.3 결재 상세 (GET /approvals/{id})
include=steps,histories,transaction,template,draft조합으로 세부 정보를 요청한다.- 상신자·승인자가 아닌 사용자가 접근하면
403. 응답 구조는 5.1과 동일하며,draft가 존재할 때 예시는 다음과 같다:
{
"data": {
"id": 5002,
"approval_no": "APP-202501040002",
"draft": {
"id": 88001,
"request_id": null,
"transaction_id": 91005,
"requester_id": 7,
"template_id": 1201,
"title": "입고 결재 초안",
"summary": "서류 미완료",
"status": "draft",
"saved_at": "2025-01-04T05:10:00Z",
"expires_at": "2025-01-06T05:10:00Z",
"session_key": "draft-session-123",
"step_count": 2
}
}
}
5.4 결재 단계 일괄 구성 (POST /approvals/{id}/steps)
{
"id": 5001,
"steps": [
{ "step_order": 1, "approver_id": 21 },
{ "step_order": 2, "approver_id": 34, "note": "재무 확인" }
]
}
응답(ApprovalStepBatchResponse):
{
"data": {
"approval_id": 5001,
"steps": [
{
"id": 73001,
"request_id": 5001,
"step_order": 1,
"approver": {
"id": 21,
"employee_id": "E2025002",
"name": "박검토"
},
"status": {
"id": 2,
"name": "진행",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": "2025-01-04T05:05:00Z",
"decided_at": null,
"note": null,
"is_optional": false
},
{
"id": 73002,
"request_id": 5001,
"step_order": 2,
"approver": {
"id": 34,
"employee_id": "E2025020",
"name": "최최종"
},
"status": {
"id": 1,
"name": "대기",
"is_blocking_next": true,
"is_terminal": false
},
"assigned_at": null,
"decided_at": null,
"note": "재무 확인",
"is_optional": false
}
],
"approval": {
"id": 5001,
"transaction_no": "TRX-202501040015",
"status": {
"id": 2,
"name": "상신",
"is_blocking_next": true,
"is_terminal": false
},
"current_step": {
"id": 73001,
"step_order": 1
},
"template_name": "입고 결재 기본",
"updated_at": "2025-01-04T05:05:00Z"
}
}
}
PATCH /approvals/{id}/steps는 동일한 응답 구조를 반환하며, 요청에는 id와 수정할 필드만 포함하면 된다 (steps[].approver_id, step_order, note, is_optional 등).
5.5 결재 진행 가능 여부 (GET /approvals/{id}/can-proceed)
응답 예:
{
"data": {
"id": 5001,
"can_proceed": true,
"reason": null
}
}
can_proceed=false일 경우reason에 차단 사유(예:blocking step pending)가 채워진다.
5.6 승인/반려 처리 (POST /approval/approve, POST /approval/reject)
요청 공통 구조 (ApprovalDecisionRequest):
{
"approval_id": 5001,
"actor_id": 21,
"note": "검토 완료",
"expected_updated_at": "2025-01-04T05:05:00Z"
}
actor_id는 인증된 사용자 ID와 일치해야 한다.- 성공 시
ApprovalMutationResponse가 반환된다:
{
"data": {
"approval": {
"id": 5001,
"status": {
"id": 3,
"name": "승인",
"is_blocking_next": false,
"is_terminal": false
},
"current_step": {
"id": 73002,
"step_order": 2
},
"updated_at": "2025-01-04T05:06:30Z"
}
}
}
5.7 회수 및 재상신 (POST /approval/recall, POST /approval/resubmit)
recall요청은 다음 필드를 사용한다:
{
"approval_id": 5001,
"actor_id": 7,
"note": "자료 재정비",
"expected_updated_at": "2025-01-04T05:06:30Z",
"transaction_expected_updated_at": "2025-01-04T05:06:30Z"
}
성공 시 ApprovalMutationResponse가 반환되고 결재 상태는 recalled로 변경된다.
resubmit은 회수/반려 상태에서만 호출 가능하며 단계 배열이 필수다:
{
"approval_id": 5001,
"actor_id": 7,
"steps": [
{ "step_order": 1, "approver_id": 21 },
{ "step_order": 2, "approver_id": 34 }
],
"note": "자료 보완 후 재상신",
"expected_updated_at": "2025-01-04T05:07:10Z",
"transaction_expected_updated_at": "2025-01-04T05:07:10Z"
}
응답은 5.1과 동일한 ApprovalDetailResponse 구조로 최신 결재 상태를 반환한다.
5.8 결재 이력 (GET /approval/history, GET /approval/history/{id})
- 목록은 페이지네이션을 지원하며
approval_id,step_id,action_code,from,to등을 필터로 받을 수 있다. action.code는 참조 행위가 남아 있을 때approval_actions.action_name을 반환하고, 참조가 누락된 경우에도action_code값을 재사용해 식별 가능하도록 한다.- 응답 예시:
{
"items": [
{
"id": 98001,
"request_id": 5001,
"step_id": 73001,
"actor": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"action": {
"id": 1,
"name": "상신",
"code": "submit"
},
"from_status": null,
"to_status": {
"id": 2,
"name": "상신",
"is_blocking_next": true,
"is_terminal": false
},
"action_at": "2025-01-04T05:00:00Z",
"action_code": "submit",
"note": null
}
],
"page": 1,
"page_size": 50,
"total": 1
}
- 단건 조회는
GET /approval/history/{id}로 동일 필드를 반환한다. 권한 규칙은 결재 상세와 동일하다.
5.9 결재 초안 API (/approval-drafts)
- 초안은 상신자가 결재 편집을 중단했을 때 복구 지점을 제공한다. 만료 시각이 지나면 상태가
expired로 표시되며,include_expired=true를 지정하지 않으면 목록에서 제외된다.
GET /approval-drafts?requester_id=7
{
"items": [
{
"id": 88001,
"request_id": null,
"transaction_id": 91005,
"requester_id": 7,
"template_id": 1201,
"title": "입고 결재 초안",
"summary": "서류 미완료",
"status": "active",
"saved_at": "2025-01-04T05:10:00Z",
"expires_at": "2025-01-06T05:10:00Z",
"session_key": "draft-session-123",
"step_count": 2
}
],
"page": 1,
"page_size": 50,
"total": 1
}
GET /approval-drafts/88001?requester_id=7
{
"id": 88001,
"requester_id": 7,
"transaction_id": 91005,
"template_id": 1201,
"payload": {
"title": "입고 결재 초안",
"summary": "서류 미완료",
"note": "재고 파악 필요",
"status": "draft",
"template_id": 1201,
"metadata": {"channel": "web"},
"steps": [
{ "step_order": 1, "approver_id": 21, "is_optional": false },
{ "step_order": 2, "approver_id": 34, "is_optional": false, "note": "재무 확인" }
]
},
"saved_at": "2025-01-04T05:10:00Z",
"expires_at": "2025-01-06T05:10:00Z",
"session_key": "draft-session-123"
}
POST /approval-drafts
{
"requester_id": 7,
"transaction_id": 91005,
"template_id": 1201,
"title": "입고 결재 초안",
"summary": "서류 미완료",
"note": "재고 파악 필요",
"session_key": "draft-session-123",
"steps": [
{ "step_order": 1, "approver_id": 21, "is_optional": false },
{ "step_order": 2, "approver_id": 34, "is_optional": false }
]
}
응답은 저장된 초안 상세(ApprovalDraftDetail)이며, DELETE /approval-drafts/{id}?requester_id=7는 204 No Content를 반환한다.
5.10 순응성 체크리스트
- 결재 API는 모든 응답에서
is_deleted를 포함하지만 값은 읽기 전용이다. 소프트 삭제 복원은POST /approvals/{id}/restore. - 경로
/approval/...엔드포인트는 행위(action)를 표현하므로 HTTP 동사를 분리하지 않는다. 대신 요청 본문에note,expected_updated_at등을 포함해 전이 정보를 전달한다. - Prometheus 메트릭
approval_*계열은/metrics에서 노출되며, 승인/반려/회수 호출 시approval_failure_total가치가 증가하면 운영팀 알람 정책(B6-2/B6-3)을 따른다.
6. 결재 템플릿 API
리소스: /approval/templates
6.1 목록 조회
GET /approval/templates?page=1
{
"items": [
{
"id": 3001,
"template_code": "AP_INBOUND",
"template_name": "입고 결재 기본",
"description": "입고 결재 2단계",
"version": 3,
"created_by": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"is_default": false,
"is_active": true,
"created_at": "2025-01-20T00:00:00Z",
"updated_at": "2025-01-25T00:00:00Z"
}
],
"page": 1,
"page_size": 50,
"total": 1
}
created_by는 작성자의id,employee_id,name을 포함하며include=파라미터 없이도 기본 반환된다.
6.2 단건 조회
GET /approval/templates/3001?include=steps
{
"data": {
"id": 3001,
"template_code": "AP_INBOUND",
"template_name": "입고 결재 기본",
"description": "입고 결재 2단계",
"version": 3,
"created_by": {
"id": 7,
"employee_id": "E2025001",
"name": "김승인"
},
"is_default": false,
"steps": [
{
"id": 9101,
"step_order": 1,
"approver": {
"id": 21,
"employee_id": "E2025002",
"name": "박검토"
},
"approver_role": null,
"escalation_minutes": null,
"note": null,
"is_optional": false
}
],
"is_active": true,
"created_at": "2025-01-20T00:00:00Z",
"updated_at": "2025-01-25T00:00:00Z"
}
}
6.3 생성·수정
POST /approval/templates
{
"template_code": "AP_OUTBOUND",
"template_name": "출고 결재 기본",
"description": "출고 결재 3단계",
"created_by_id": 7,
"note": "표준 출고",
"is_default": false
}
POST /approval/templates/3002/steps
{
"id": 3002,
"expected_version": 3,
"steps": [
{
"step_order": 1,
"approver_id": 34,
"approver_role": null,
"escalation_minutes": null,
"is_optional": false
},
{
"step_order": 2,
"approver_id": 55,
"is_optional": false
}
]
}
PATCH /approval/templates/3002
{
"id": 3002,
"template_name": "출고 결재 확장",
"note": "정기 출고용",
"expected_version": 4
}
PATCH /approval/templates/3002/steps
{
"id": 3002,
"expected_version": 4,
"steps": [
{
"id": 9105,
"step_order": 1,
"approver_id": 36,
"is_optional": false
}
]
}
- 삭제/복구:
DELETE /approval/templates/{id},POST /approval/templates/{id}/restore - 템플릿/단계 수정 시에는
expected_version을 전달해 낙관적 잠금을 적용하며, 불일치 시409 Conflict(approval template version mismatch)를 반환한다.
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{ "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응답:{ "identifier": "user@example.com", "password": "Sup3rS3cret!", "remember_me": true }{ "data": { "access_token": "<jwt>", "refresh_token": "<jwt>", "expires_at": "2025-09-18T09:00:00Z", "user": { "id": 7, "name": "김승인", "employee_id": "E2025001", "email": "approver@example.com", "primary_group": { "id": 3, "name": "물류팀" } }, "permissions": [ { "resource": "/dashboard", "actions": ["read"] }, { "resource": "/approvals", "actions": ["read", "update"] } ] } }POST /auth/refresh응답은 로그인과 동일한 스키마를 따른다.{ "refresh_token": "<jwt>" }- 인증 실패 시
401, 잠금·권한 오류 시403을 반환하며{ "error": { "code": 401, "message": "...", "details": [...] } }형식을 유지한다. - 토큰/계정 상태별 메시지 매핑
- 잘못된 자격 증명:
invalid credentials - 비활성 계정 접근:
account is inactive - 만료된 토큰:
token expired - 비밀번호 변경 등으로 무효화된 토큰:
token revoked - 재사용·서명 오류:
invalid token
- 잘못된 자격 증명:
8.1 대시보드 요약
GET /dashboard/summary
{
"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_id": 5001,
"approval_no": "APP-202511100005",
"title": "출고 결재",
"step_summary": "2단계/3단계 진행중",
"requested_at": "2025-09-17T03:00:00Z"
}
]
}
}
recent_transactions[]는 최종 승인 상태(승인,완료)의 전표만 포함하며, 결재 진행 중 건은 제외된다. 대기·임시 전표는GET /stock-transactions?status=draft,submitted또는include_pending=true로 별도 조회한다.
9. 구현 참고
- FK 요약 정보는 기본 응답에 포함하며, 상세 정보가 필요하면
include파라미터를 활용해 확장한다. - 배열 기반 다건 작업은 전체를 트랜잭션 처리해야 한다. 실패 시 롤백하고 부분 처리 결과를 반환하지 않는다.
is_active변경은 권한·결재 등의 즉시성 요구를 고려하여 관련 캐시를 무효화한다.- 결재 단계 상태 전이는
approval_statuses.is_blocking_next규칙을 준수해야 하며, 반려(is_terminal=true) 상태 시 결재를 종료한다. - 감사 로그가 생성되면
approval.audit.recorded이벤트를 Kafka(event_bus.kafka.*설정)와 WebSocket 브로드캐스트(event_bus.websocket.*) 채널로 동시에 발행한다. 메시지는 아래와 같은 JSON 페이로드를 사용한다.{ "event": "approval.audit.recorded", "version": "1.0", "emitted_at": "2025-09-18T06:01:12Z", "request_id": 5001, "audit_id": 91001, "summary": { "id": 91001, "request_id": 5001, "step_id": 7001, "actor": { "id": 7, "employee_id": "E2025001", "name": "김승인" }, "action": { "id": 1, "name": "상신", "code": "submit" }, "from_status": null, "to_status": { "id": 1, "name": "대기", "is_blocking_next": true, "is_terminal": false }, "action_at": "2025-09-18T06:00:00Z", "action_code": "submit", "note": null, "payload": null } }