# 간단 입·출고 + 결재 시스템 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`
```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`, `/users`, `/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 /users?page=1`
```json
{
  "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`
```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
        }
      ],
      "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`
```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` 등 주소 구성 요소가 포함된다.

### 3.7 사용자 계정 전용 엔드포인트

#### 3.7.1 관리자 사용자 생성
`POST /users`
```json
{
  "employee_id": "E2025012",
  "name": "홍관리",
  "email": "admin@example.com",
  "phone": "+82-10-3333-4444",
  "group_id": 1,
  "password": "Admin!234"
}
```
응답:
```json
{
  "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`
```json
{
  "email": "approver@example.com",
  "phone": "+82-10-2222-1111",
  "password": "NewPass!234",
  "current_password": "TempPass1"
}
```
응답:
```json
{
  "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`, `password` 3개다. `password`를 변경할 때는 `current_password` 필드가 필수다.
- 비밀번호 변경이 성공하면 서버는 기존 액세스/리프레시 토큰을 모두 폐기하고, 클라이언트는 로그아웃 팝업을 띄운 뒤 재로그인을 요구해야 한다.

#### 3.7.3 관리자 사용자 수정
`PATCH /users/{id}`
```json
{
  "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자 영문 대소문자+숫자 조합으로 임시 비밀번호를 생성해 이메일 발송 큐에 넣고, 응답으로 상태를 전달한다.
```json
{
  "data": {
    "id": 7,
    "employee_id": "E2025001",
    "email": "approver@example.com",
    "force_password_change": true,
    "password_updated_at": "2025-03-11T02:05:00Z"
  }
}
```
- 임시 비밀번호 값은 응답에 포함하지 않는다. 메일 템플릿에서 안내하며, 관리 콘솔에는 마스킹된 값만 노출한다.
- 재설정 시 즉시 기존 세션을 무효화하고, 사용자 최초 로그인 시 비밀번호 변경 화면으로 리다이렉션한다.

---

## 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": {
    "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`) 건만 포함한다.
```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_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`
```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_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`
```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` 는 결재 상태가 승인된 건에 한해 완료 상태로 변경한다.

### 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_quantity`
  - `order`: `asc|desc` (기본 desc)
```json
{
  "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`(선택 — 특정 창고 히스토리만 반환).
- 응답은 제품 기본 정보와 최근 이벤트 배열을 포함한다.
```json
{
  "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`)
```json
{
  "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`):
```json
{
  "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` 없이도 반환된다.

응답 예시(요약 전용):
```json
{
  "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`가 존재할 때 예시는 다음과 같다:
```json
{
  "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`)
```json
{
  "id": 5001,
  "steps": [
    { "step_order": 1, "approver_id": 21 },
    { "step_order": 2, "approver_id": 34, "note": "재무 확인" }
  ]
}
```
응답(`ApprovalStepBatchResponse`):
```json
{
  "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`)
응답 예:
```json
{
  "data": {
    "id": 5001,
    "can_proceed": true,
    "reason": null
  }
}
```
- `can_proceed=false`일 경우 `reason`에 차단 사유(예: `blocking step pending`)가 채워진다.

### 5.6 승인/반려 처리 (`POST /approval/approve`, `POST /approval/reject`)
요청 공통 구조 (`ApprovalDecisionRequest`):
```json
{
  "approval_id": 5001,
  "actor_id": 21,
  "note": "검토 완료",
  "expected_updated_at": "2025-01-04T05:05:00Z"
}
```
- `actor_id`는 인증된 사용자 ID와 일치해야 한다.
- 성공 시 `ApprovalMutationResponse`가 반환된다:
```json
{
  "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` 요청은 다음 필드를 사용한다:
```json
{
  "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`은 회수/반려 상태에서만 호출 가능하며 단계 배열이 필수다:
```json
{
  "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` 값을 재사용해 식별 가능하도록 한다.
- 응답 예시:
```json
{
  "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`
```json
{
  "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`
```json
{
  "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`
```json
{
  "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`
```json
{
  "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`
```json
{
  "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`
```json
{
  "template_code": "AP_OUTBOUND",
  "template_name": "출고 결재 기본",
  "description": "출고 결재 3단계",
  "created_by_id": 7,
  "note": "표준 출고",
  "is_default": false
}
```

- `POST /approval/templates/3002/steps`
```json
{
  "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`
```json
{
  "id": 3002,
  "template_name": "출고 결재 확장",
  "note": "정기 출고용",
  "expected_version": 4
}
```

- `PATCH /approval/templates/3002/steps`
```json
{
  "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`
  ```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": "<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`
  ```json
  {
    "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`
```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_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 페이로드를 사용한다.
  ```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
    }
  }
  ```
