
# 간단 입·출고 + 결재 시스템 설계서 (최종 v4)

**버전:** 2025-09-18 16:22:30Z (UTC)  
**요약:** 벤더 ↔ 창고 ↔ 고객사 간 물품 이동(입고/출고)을 관리하는 최소구성 시스템.  
- 트랜잭션당 1개의 결재(1:1), **승인자 순서 기반의 순차 결재** 지원.  
- 상신자는 결재 초안을 임시저장할 수 있으며, 브라우저를 닫아도 `결재 관리` 목록에서 다시 불러와 편집/상신할 수 있다.
- **다음 승인자로 넘어가면 안 되는 상태**를 `approval_statuses.is_blocking_next`로 제어.  
- 모든 테이블(타입/코드 테이블 포함)에 **공통 컬럼** 적용: `is_active`, `is_deleted`, `created_at`, `updated_at`.  
- **벤더는 트랜잭션 헤더에 연결하지 않음**(벤더는 제품을 통해서만 추적).  
- **customer_roles 제거**: 트랜잭션-고객은 역할 없이 다수 연결만 허용.  
- 타입값은 **별도 테이블**로 분리하며 `*_code`/정렬순서 미사용, ID 기반 참조.
- 메뉴 접근은 `groups`와 `group_menu_permissions`를 통해 제어되며, 모든 직원은 정확히 하나의 그룹에 속함.

---

## 0) 핵심 비즈니스 규칙
- 제품 1개는 반드시 1개의 벤더에 소속 (`products.vendor_id` 필수).
- **트랜잭션 1건당 결재 1건**(1:1, 소프트삭제 제외).
- 결재는 **승인자 순서(`approval_steps.step_order`)대로**만 진행.
- 대시보드 대기 결재 요약은 상세 조회 연계를 위해 각 항목의 `approval_id`(= `approvals.id`)를 포함한다.
- 결재 목록 응답은 각 항목의 `id`(= `approvals.id`)를 항상 노출하여 상세 조회 트리거로 사용한다.
- 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가.
- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음).
- 입고/출고/대여 트랜잭션은 최종 결재자가 승인 완료하기 전까지 기본 목록/완료 카드에 노출되지 않으며, 대기/임시 영역에서만 조회된다.
- 결재 문서는 상신자와 이미 결재를 완료한 승인자만 열람할 수 있고, 향후 단계 승인자는 자신의 순서가 도달하기 전까지 목록/상세 접근이 차단된다. (예: 상신→중간 승인 완료, 최종 승인 대기 시 상신자·중간 승인자만 열람 가능)
- 모든 로그인 사용자는 **users** 테이블 행과 매핑되며(`users.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨.
- `users.employee_id`는 영문/숫자 4~32자 정규식(`^[A-Za-z0-9]{4,32}$`)을 따라야 하며, 대소문자 구분 없이 유니크하다.
- 신규 사용자는 생성 직후 `force_password_change=true`로 저장하고, 최초 로그인에서 비밀번호를 변경하지 않으면 다른 기능을 사용할 수 없다.
- 비밀번호는 길이 8~24자에 대문자·소문자·숫자·일반 특수문자를 각각 1자 이상 포함해야 하며, 성공적으로 변경되면 기존 세션을 즉시 만료한다.
- 최고 관리자 계정 `terabits`는 삭제하지 않고 최상위 권한을 유지하며, 관리자 비밀번호 재설정은 임시 비밀번호 이메일 발송과 `force_password_change=true` 설정을 동시에 수행한다.
- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false).
- 고객사는 담당자 이름(`contact_name`)을 별도 관리하며 고객 응답에 항상 포함.
- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능.
- 모든 삭제는 **소프트 삭제**(`is_deleted=true`)이며, 삭제 시 `is_active=false`로 내림.

---

## 1) 개념 ERD

```mermaid
erDiagram
vendors ||--o{ products : supplies
uoms ||--o{ products : measured_in

warehouses ||--o{ stock_transactions : occurs_in
transaction_types ||--o{ stock_transactions : typed_as
transaction_statuses ||--o{ stock_transactions : has_status

stock_transactions ||--o{ transaction_lines : has
products ||--o{ transaction_lines : item

stock_transactions ||--o{ transaction_customers : serves
customers ||--o{ transaction_customers : party

stock_transactions ||--|| approvals : has_one
approval_statuses ||--o{ approvals : overall_status
approvals ||--o{ approval_steps : has_sequence
approval_statuses ||--o{ approval_steps : step_status
approval_steps ||--o{ approval_histories : logs
approval_actions ||--o{ approval_histories : acted_as

approval_templates ||--o{ approval_template_steps : has_sequence

users ||--o{ approvals : requested_by
users ||--o{ approval_steps : assigned_to
users ||--o{ approval_histories : actor
users ||--o{ stock_transactions : created_by
users ||--o{ approval_templates : authored
users ||--o{ approval_template_steps : template_approver
groups ||--o{ users : members
groups ||--o{ group_menu_permissions : controls
menus ||--o{ group_menu_permissions : target
zipcodes ||--o{ warehouses : located
zipcodes ||--o{ customers : addressed
```

---

## 2) 공통 컬럼 (모든 테이블 공통 적용)
| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| note | 비고 | text | - | - | N | N | N | - |
| is_active | 사용여부 | boolean | - | true | Y | N | N | - |
| is_deleted | 삭제여부(소프트) | boolean | - | false | Y | N | N | - |
| created_at | 생성일시 | timestamp | - | now() | Y | N | N | - |
| updated_at | 변경일시 | timestamp | - | now() | Y | N | N | - |

> 모든 테이블(타입/코드 테이블 포함)에 위 4개 컬럼을 **명시적으로 포함**.  
> `note`는 테이블별 메모/추가 설명을 저장하는 자유 텍스트 필드.  
> `updated_at`은 UPDATE 시 자동 갱신(트리거/생성 컬럼 권장). 삭제 시 `is_deleted=true`, `is_active=false` 처리.

---

## 3) 테이블 정의

### 3.1 `vendors` (벤더)
| 영문테이블명 | 한글테이블명 |
|---|---|
| vendors | 벤더 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 벤더ID | bigint | - | identity | Y | Y | Y | - |
| vendor_code | 벤더코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| vendor_name | 벤더명 | varchar | 100 | - | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> `zipcodes` 테이블이 우편번호 마스터를 보유하며, 참조 측 테이블은 `zipcode_id` → `zipcodes.id` FK만 저장한다. 응답 시에는 FK를 통해 조회한 우편번호 요약 정보를 포함한다.

---

### 3.2 `warehouses` (창고)
| 영문테이블명 | 한글테이블명 |
|---|---|
| warehouses | 창고 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 창고ID | bigint | - | identity | Y | Y | Y | - |
| warehouse_code | 창고코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| warehouse_name | 창고명 | varchar | 100 | - | Y |  |  |  |
| zipcode_id | 우편번호ID | bigint | - | - | N |  |  | zipcodes.id |
| address_detail | 상세주소 | varchar | 200 | - | N |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.

---

### 3.3 `customers` (고객사)
| 영문테이블명 | 한글테이블명 |
|---|---|
| customers | 고객사 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 고객사ID | bigint | - | identity | Y | Y | Y | - |
| customer_code | 고객사코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| customer_name | 고객사명 | varchar | 100 | - | Y |  |  |  |
| contact_name | 담당자명 | varchar | 100 | - | N |  |  | - |
| is_partner | 파트너여부 | boolean | - | false | Y |  |  | - |
| is_general | 일반여부 | boolean | - | true | Y |  |  | - |
| email | 이메일 | varchar | 100 | - | N |  |  | - |
| mobile_no | 모바일번호 | varchar | 20 | - | N |  |  | - |
| zipcode_id | 우편번호ID | bigint | - | - | N |  |  | zipcodes.id |
| address_detail | 상세주소 | varchar | 200 | - | N |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> `contact_name`은 고객사의 대표 연락 창구로 사용하는 담당자 실명. 필수는 아니며 미입력 시 `null` 저장.

> 고객/창고 모두 `zipcode_id`를 통해 `zipcodes.id`와 연결하며, API 응답은 FK가 가리키는 `zipcodes` 행에서 필요한 우편번호 메타 정보를 추출해 제공한다.

---

### 3.4 `users` (사용자)
| 영문테이블명 | 한글테이블명 |
|---|---|
| users | 사용자 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 사용자ID | bigint | - | identity | Y | Y | Y | - |
| employee_id | 사번 | varchar | 32 | - | Y | (부분유니크: is_deleted=false) | N | - |
| name | 이름 | varchar | 100 | - | Y |  |  |  |
| email | 이메일 | varchar | 150 | - | Y | Y |  |  |
| phone | 연락처 | varchar | 30 | - | N |  |  |  |
| group_id | 그룹ID | bigint | - | - | Y |  |  | groups.id |
| password_hash | 비밀번호해시 | varchar | 255 | - | Y |  |  | - |
| password_updated_at | 비밀번호변경일시 | timestamp | - | now() | Y |  |  | - |
| force_password_change | 비밀번호강제변경 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> `employee_id`는 영문/숫자 4~32자(`^[A-Za-z0-9]{4,32}$`)로 제한하며 대소문자를 구분하지 않는 유니크 인덱스를 적용한다.  
> 이메일은 소문자로 저장하고 유니크해야 하며, 연락처는 국제 전화번호 포맷을 허용한다.  
> 신규 생성 시 `force_password_change=true`로 저장하고, 최초 로그인 후 비밀번호 변경 시 `force_password_change=false` 및 `password_updated_at`을 현재 시각으로 갱신한다.  
> 최고 관리자 계정 `terabits`는 삭제하지 않고 유지하며, 비밀번호 재설정 시 이메일 발송과 즉시 비밀번호 강제 변경 상태로 전환한다.

---

### 3.5 `zipcodes` (우편번호)
| 영문테이블명 | 한글테이블명 |
|---|---|
| zipcodes | 우편번호 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 우편번호ID | bigint | - | identity | Y | Y | Y | - |
| zipcode | 우편번호 | varchar | 5 | - | Y |  | N | - |
| sido | 시도 | varchar | 50 | - | Y |  |  | - |
| sido_eng | 시도영문 | varchar | 100 | - | N |  |  | - |
| sigungu | 시군구 | varchar | 100 | - | Y |  |  | - |
| sigungu_eng | 시군구영문 | varchar | 100 | - | N |  |  | - |
| eupmyeon | 읍면 | varchar | 100 | - | N |  |  | - |
| eupmyeon_eng | 읍면영문 | varchar | 100 | - | N |  |  | - |
| road_code | 도로명코드 | varchar | 12 | - | Y |  |  | - |
| road_name | 도로명 | varchar | 200 | - | Y |  |  | - |
| road_name_eng | 도로명영문 | varchar | 200 | - | N |  |  | - |
| underground_flag | 지하여부 | varchar | 1 | 'N' | Y |  |  | - |
| building_main_no | 건물번호본번 | integer | - | 0 | Y |  |  | - |
| building_sub_no | 건물번호부번 | integer | - | 0 | N |  |  | - |
| building_mgmt_no | 건물관리번호 | varchar | 25 | - | Y |  |  | - |
| bulk_receiver | 다량배달처명 | varchar | 200 | - | N |  |  | - |
| sigungu_building_name | 시군구용건물명 | varchar | 200 | - | N |  |  | - |
| legal_dong_code | 법정동코드 | varchar | 10 | - | Y |  |  | - |
| legal_dong_name | 법정동명 | varchar | 100 | - | Y |  |  | - |
| ri_name | 리명 | varchar | 100 | - | N |  |  | - |
| admin_dong_name | 행정동명 | varchar | 100 | - | N |  |  | - |
| mountain_flag | 산여부 | varchar | 1 | 'N' | Y |  |  | - |
| land_main_no | 지번본번 | integer | - | 0 | Y |  |  | - |
| town_serial_no | 읍면동일련번호 | integer | - | 0 | N |  |  | - |
| land_sub_no | 지번부번 | integer | - | 0 | N |  |  | - |
| old_zipcode | 구우편번호 | varchar | 6 | - | N |  |  | - |
| zipcode_serial_no | 우편번호일련번호 | integer | - | 0 | Y |  |  | - |
| search_text | 검색텍스트 | text | - | - | N |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 도로명 주소 데이터와 매핑되는 5자리 우편번호 기준. `id`는 내부용 서러겟 PK이며, `zipcode`는 동일 코드가 여러 주소 행에 등장할 수 있다.

---

### 3.6 `menus` (메뉴)
| 영문테이블명 | 한글테이블명 |
|---|---|
| menus | 메뉴 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 메뉴ID | bigint | - | identity | Y | Y | Y | - |
| menu_code | 메뉴코드 | varchar | 50 | - | Y | (부분유니크: is_deleted=false) | N | - |
| menu_name | 메뉴명 | varchar | 100 | - | Y |  |  |  |
| parent_menu_id | 상위메뉴ID | bigint | - | - | N |  |  | menus.id |
| route_path | 경로 | varchar | 255 | - | N |  |  | - |
| display_order | 표시순서 | integer | - | 0 | Y |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 메뉴는 계층 구조를 지원하며 `parent_menu_id`가 NULL이면 1차 메뉴로 간주.

---

### 3.8 `groups` (그룹)
| 영문테이블명 | 한글테이블명 |
|---|---|
| groups | 그룹 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 그룹ID | bigint | - | identity | Y | Y | Y | - |
| group_name | 그룹명 | varchar | 100 | - | Y | (부분유니크: is_deleted=false) | N | - |
| group_description | 그룹설명 | varchar | 255 | - | N |  |  | - |
| is_default | 기본그룹여부 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사용자는 `users.group_id`로 그룹에 연결된다.

---

### 3.9 `group_menu_permissions` (그룹_메뉴_권한)
| 영문테이블명 | 한글테이블명 |
|---|---|
| group_menu_permissions | 그룹_메뉴_권한 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 메뉴그룹권한ID | bigint | - | identity | Y | Y | Y | - |
| group_id | 그룹ID | bigint | - | - | Y | (복합유니크: group_id, menu_id, is_deleted) | N | groups.id |
| menu_id | 메뉴ID | bigint | - | - | Y | (복합유니크: group_id, menu_id, is_deleted) | N | menus.id |
| can_create | 생성권한 | boolean | - | false | Y |  |  | - |
| can_read | 조회권한 | boolean | - | true | Y |  |  | - |
| can_update | 수정권한 | boolean | - | false | Y |  |  | - |
| can_delete | 삭제권한 | boolean | - | false | Y |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 각 메뉴에 대한 CRUD 권한을 그룹 단위로 정의하며, 권한 미설정 시 기본적으로 조회만 허용.

---

### 3.10 `uoms` (단위) — 타입 테이블
| 영문테이블명 | 한글테이블명 |
|---|---|
| uoms | 단위 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 단위ID | bigint | - | identity | Y | Y | Y | - |
| uom_name | 단위명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - |
| is_default | 기본여부 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 예시 값: `EA`(기본 단위), `BOX`, `KG`, `LITER` 등.

---

### 3.11 `products` (제품)
| 영문테이블명 | 한글테이블명 |
|---|---|
| products | 제품 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 제품ID | bigint | - | identity | Y | Y | Y | - |
| product_code | 제품코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| product_name | 제품명 | varchar | 100 | - | Y |  |  |  |
| vendor_id | 벤더ID | bigint | - | - | Y |  |  | vendors.id |
| uom_id | 단위ID | bigint | - | - | Y |  |  | uoms.id |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

---

### 3.12 `transaction_types` (입출고_유형) — 타입 테이블
| 영문테이블명 | 한글테이블명 |
|---|---|
| transaction_types | 입출고_유형 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 유형ID | bigint | - | identity | Y | Y | Y | - |
| type_name | 유형명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - |
| is_default | 기본여부 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 예시 값: `입고`(is_default=true), `출고`.

---

### 3.13 `transaction_statuses` (트랜잭션_상태) — 타입 테이블
| 영문테이블명 | 한글테이블명 |
|---|---|
| transaction_statuses | 트랜잭션_상태 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 상태ID | bigint | - | identity | Y | Y | Y | - |
| status_name | 상태명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - |
| is_default | 기본여부 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 예시 값: `정상`(is_default), `반품`, `폐기`.

---

### 3.14 `stock_transactions` (입출고_트랜잭션)
| 영문테이블명 | 한글테이블명 |
|---|---|
| stock_transactions | 입출고_트랜잭션 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 트랜잭션ID | bigint | - | identity | Y | Y | Y | - |
| transaction_no | 트랜잭션번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| transaction_type_id | 입출고유형ID | bigint | - | - | Y |  |  | transaction_types.id |
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y |  |  | transaction_statuses.id |
| warehouse_id | 창고ID | bigint | - | - | Y |  |  | warehouses.id |
| transaction_date | 처리일자 | date | - | current_date | Y |  |  | - |
| created_by_id | 작성자ID | bigint | - | - | N |  |  | users.id |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생.
> 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
> 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신)
> 작성자(`created_by_id`)는 로그인 세션의 사용자 ID를 사용하며, API 요청 본문에 전달된 다른 값은 무시하거나 검증 오류로 처리한다.
> 결재 최종 승인 완료 전에는 `transaction_statuses`가 `초안` 또는 `상신` 단계로 유지되며, 기본 입·출·대여 목록과 완료 카드에서는 제외한다. 대기/임시 전용 목록(필터 `status=draft|submitted`)에서만 확인 가능하다.

---

### 3.15 `transaction_lines` (트랜잭션_라인)
| 영문테이블명 | 한글테이블명 |
|---|---|
| transaction_lines | 트랜잭션_라인 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 라인ID | bigint | - | identity | Y | Y | Y | - |
| transaction_id | 트랜잭션ID | bigint | - | - | Y |  |  | stock_transactions.id |
| line_no | 라인번호 | integer | - | 1 | Y | (복합유니크: transaction_id, line_no, is_deleted) | N | - |
| product_id | 제품ID | bigint | - | - | Y |  |  | products.id |
| quantity | 수량 | numeric | 20,6 | 0 | Y |  |  | - |
| unit_price | 단가 | numeric | 20,6 | 0 | N |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

---

### 3.16 `transaction_customers` (트랜잭션_고객사)
| 영문테이블명 | 한글테이블명 |
|---|---|
| transaction_customers | 트랜잭션_고객사 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 키 | bigint | - | identity | Y | Y | Y | - |
| transaction_id | 트랜잭션ID | bigint | - | - | Y |  |  | stock_transactions.id |
| customer_id | 고객사ID | bigint | - | - | Y | (복합유니크: transaction_id, customer_id, is_deleted) | N | customers.id |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

---

### 3.17 `approval_statuses` (결재_상태) — 타입 테이블
| 영문테이블명 | 한글테이블명 |
|---|---|
| approval_statuses | 결재_상태 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 상태ID | bigint | - | identity | Y | Y | Y | - |
| status_name | 상태명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - |
| is_default | 기본여부 | boolean | - | false | Y |  |  |  |
| is_blocking_next | 차기이동차단 | boolean | - | true | Y |  |  |  |
| is_terminal | 종결여부 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 예시 값:
> - `대기`(`is_default=true`, `is_blocking_next=true`, `is_terminal=false`)
> - `진행중`(`is_blocking_next=true`, `is_terminal=false`)
> - `보류`(`is_blocking_next=true`, `is_terminal=false`)
> - `승인`(`is_blocking_next=false`, `is_terminal=false`)
> - `반려`(`is_blocking_next=true`, `is_terminal=true`).

---

### 3.18 `approval_actions` (결재_행위) — 타입 테이블
| 영문테이블명 | 한글테이블명 |
|---|---|
| approval_actions | 결재_행위 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 행위ID | bigint | - | identity | Y | Y | Y | - |
| action_name | 행위명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - |
| is_default | 기본여부 | boolean | - | false | Y |  |  |  |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 예시 값: `approve`(승인), `reject`(반려), `comment`(코멘트).

---

### 3.19 `approvals` (결재)
| 영문테이블명 | 한글테이블명 |
|---|---|
| approvals | 결재 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 결재ID | bigint | - | identity | Y | Y | Y | - |
| transaction_id | 트랜잭션ID | bigint | - | - | Y | (부분유니크: is_deleted=false) | N | stock_transactions.id |
| approval_no | 결재번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| approval_status_id | 전체결재상태ID | bigint | - | - | Y |  |  | approval_statuses.id |
| current_step_id | 현재단계ID | bigint | - | - | N |  |  | approval_steps.id |
| requested_by_id | 상신자ID | bigint | - | - | Y |  |  | users.id |
| requested_at | 상신일시 | timestamp | - | now() | Y |  |  | - |
| decided_at | 최종결정일시 | timestamp | - | - | N |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 번호 발급: 서버가 `APP-YYYYMMDDNNNN` 형식으로 `approval_no`를 생성하며 클라이언트 입력을 허용하지 않는다.
> 상신자는 결재 초안을 저장하고 추후 재개할 수 있으며, 초안은 `결재 관리` 목록의 "임시저장" 필터로 조회한다.
> 열람 권한은 상신자와 이미 결재를 수행한 승인자에게만 부여되며, 도달하지 않은 단계의 승인자는 목록/상세/이력 API에서 403 또는 비노출로 처리한다. (예: 상신→중간 승인 완료, 최종 승인 대기 시 상신자·중간 승인자만 열람 가능)

---

### 3.20 `approval_steps` (결재_단계)
| 영문테이블명 | 한글테이블명 |
|---|---|
| approval_steps | 결재_단계 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 단계ID | bigint | - | identity | Y | Y | Y | - |
| approval_id | 결재ID | bigint | - | - | Y |  |  | approvals.id |
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: approval_id, step_order, is_deleted) | N | - |
| approver_id | 승인자ID | bigint | - | - | Y |  |  | users.id |
| step_status_id | 단계상태ID | bigint | - | - | Y |  |  | approval_statuses.id |
| assigned_at | 배정일시 | timestamp | - | now() | Y |  |  | - |
| decided_at | 결정일시 | timestamp | - | - | N |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

---

### 3.21 `approval_histories` (결재_승인이력)
| 영문테이블명 | 한글테이블명 |
|---|---|
| approval_histories | 결재_승인이력 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 이력ID | bigint | - | identity | Y | Y | Y | - |
| approval_id | 결재ID | bigint | - | - | Y |  |  | approvals.id |
| approval_step_id | 결재단계ID | bigint | - | - | Y |  |  | approval_steps.id |
| approver_id | 승인자ID | bigint | - | - | Y |  |  | users.id |
| approval_action_id | 결재행위ID | bigint | - | - | Y |  |  | approval_actions.id |
| action_code | 행위코드 | varchar | 30 | - | Y |  |  | - |
| from_status_id | 변경전상태ID | bigint | - | - | N |  |  | approval_statuses.id |
| to_status_id | 변경후상태ID | bigint | - | - | Y |  |  | approval_statuses.id |
| action_at | 작업일시 | timestamp | - | now() | Y |  |  | - |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

---

- `action_code`는 `submit`, `approve`, `reject`, `comment`, `recall`, `resubmit` 등 표준 문자열을 저장해 참조 행위 레코드가 없어도 이력 복원이 가능하도록 한다.

### 3.22 `approval_templates` (결재_템플릿)
| 영문테이블명 | 한글테이블명 |
|---|---|
| approval_templates | 결재_템플릿 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 템플릿ID | bigint | - | identity | Y | Y | Y | - |
| template_code | 템플릿코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - |
| template_name | 템플릿명 | varchar | 100 | - | Y |  |  |  |
| description | 설명 | varchar | 255 | - | N |  |  | - |
| created_by_id | 작성자ID | bigint | - | - | Y |  |  | users.id |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

---

### 3.23 `approval_template_steps` (결재_템플릿_단계)
| 영문테이블명 | 한글테이블명 |
|---|---|
| approval_template_steps | 결재_템플릿_단계 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| id | 템플릿단계ID | bigint | - | identity | Y | Y | Y | - |
| template_id | 템플릿ID | bigint | - | - | Y | (복합유니크: template_id, step_order, is_deleted) | N | approval_templates.id |
| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: template_id, step_order, is_deleted) | N | - |
| approver_id | 승인자ID | bigint | - | - | Y |  |  | users.id |
| note | 비고 | text | - | - | N |  |  | - |
| is_active | 사용여부 | boolean | - | true | Y |  |  |  |
| is_deleted | 삭제여부 | boolean | - | false | Y |  |  |  |
| created_at | 생성일시 | timestamp | - | now() | Y |  |  |  |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  |  |

> 템플릿 단계는 실제 결재 단계 생성 시 그대로 복제되며, `step_order` 순서대로 승인자가 배치됨.

---

### 3.24 `inventory_balance_events_view` (재고_이벤트_뷰)
| 영문테이블명 | 한글테이블명 |
|---|---|
| inventory_balance_events_view | 재고_이벤트_뷰 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| event_id | 이벤트ID | bigint | - | - | Y | Y | Y | transaction_lines.id |
| transaction_id | 트랜잭션ID | bigint | - | - | Y |  |  | stock_transactions.id |
| transaction_line_id | 트랜잭션라인ID | bigint | - | - | Y |  |  | transaction_lines.id |
| transaction_no | 전표번호 | varchar | 40 | - | Y |  |  | - |
| product_id | 제품ID | bigint | - | - | Y |  |  | products.id |
| warehouse_id | 창고ID | bigint | - | - | Y |  |  | warehouses.id |
| transaction_type_id | 트랜잭션타입ID | bigint | - | - | Y |  |  | transaction_types.id |
| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y |  |  | transaction_statuses.id |
| delta_quantity | 증감수량 | numeric | 20,6 | 0 | Y |  |  | - |
| event_kind | 이벤트종류 | varchar | 30 | - | Y |  |  | - |
| counterparty_name | 거래처요약 | varchar | 150 | - | N |  |  | - |
| event_occurred_at | 이벤트일시 | timestamp | - | - | Y |  |  | - |
| captured_at | 집계일시 | timestamp | - | now() | Y |  |  | - |

> 입고/출고/대여 라인을 `transaction_lines`에서 펼친 뷰다. `delta_quantity`는 입고/반납=양수, 출고/대여=음수 규칙을 따른다. `event_kind`는 `receipt`, `issue`, `rental_out`, `rental_return` 등 표준 문자열로 저장한다. 최신 변동 정렬을 위해 `event_occurred_at DESC`, `event_id DESC` 복합 인덱스를 생성하며, 뷰는 마테리얼라이즈드 형태로 5분마다 리프레시된다.

---

### 3.25 `inventory_balance_snapshots` (재고_집계_뷰)
| 영문테이블명 | 한글테이블명 |
|---|---|
| inventory_balance_snapshots | 재고_집계_뷰 |

| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK |
|---|---|---|---|---|---|---|---|---|
| product_id | 제품ID | bigint | - | - | Y | Y | Y | products.id |
| product_code | 제품코드 | varchar | 30 | - | Y |  |  | - |
| product_name | 제품명 | varchar | 100 | - | Y |  |  | - |
| vendor_id | 벤더ID | bigint | - | - | Y |  |  | vendors.id |
| vendor_name | 벤더명 | varchar | 100 | - | Y |  |  | - |
| total_quantity | 총재고수량 | numeric | 20,6 | 0 | Y |  |  | - |
| warehouse_balances | 창고별요약 | jsonb | - | '[]'::jsonb | Y |  |  | - |
| recent_event_id | 최근이벤트ID | bigint | - | - | N |  |  | inventory_balance_events_view.event_id |
| recent_event_kind | 최근이벤트종류 | varchar | 30 | - | N |  |  | - |
| recent_event_delta | 최근이벤트증감 | numeric | 20,6 | 0 | N |  |  | - |
| recent_event_counterparty | 최근거래처 | varchar | 150 | - | N |  |  | - |
| recent_event_warehouse_id | 최근창고ID | bigint | - | - | N |  |  | warehouses.id |
| recent_event_warehouse_name | 최근창고명 | varchar | 100 | - | N |  |  | - |
| recent_event_transaction_id | 최근전표ID | bigint | - | - | N |  |  | stock_transactions.id |
| recent_event_transaction_no | 최근전표번호 | varchar | 40 | - | N |  |  | - |
| recent_event_at | 최근이벤트일시 | timestamp | - | - | N |  |  | - |
| updated_at | 변경일시 | timestamp | - | now() | Y |  |  | - |
| refreshed_at | 리프레시일시 | timestamp | - | now() | Y |  |  | - |

> `inventory_balance_events_view`를 제품 단위로 집계한 마테뷰. `warehouse_balances`는 `[ { "warehouse_id": 1, "warehouse_code": "WH-001", "warehouse_name": "1센터", "quantity": 80 } ]` 형태의 배열 JSON을 저장한다. `recent_*` 필드는 최신 이벤트 스냅샷을 캐싱해 `/api/v1/inventory/summary` 응답과 동일 구조를 제공하며, `refreshed_at`은 뷰가 새로 고쳐진 시각(UTC)을 그대로 보존한다. `updated_at` 컬럼으로 증분 조회가 가능하도록 `updated_at DESC` 인덱스를 생성한다.

---

## 4) FK 관계 (source → target)
- `menus.parent_menu_id` → `menus.id`
- `users.group_id` → `groups.id`
- `group_menu_permissions.group_id` → `groups.id`
- `group_menu_permissions.menu_id` → `menus.id`
- `products.vendor_id` → `vendors.id`
- `products.uom_id` → `uoms.id`
- `stock_transactions.warehouse_id` → `warehouses.id`
- `stock_transactions.created_by_id` → `users.id`
- `stock_transactions.transaction_type_id` → `transaction_types.id`
- `stock_transactions.transaction_status_id` → `transaction_statuses.id`
- `transaction_lines.transaction_id` → `stock_transactions.id`
- `transaction_lines.product_id` → `products.id`
- `transaction_customers.transaction_id` → `stock_transactions.id`
- `transaction_customers.customer_id` → `customers.id`
- `approvals.transaction_id` → `stock_transactions.id`
- `approvals.approval_status_id` → `approval_statuses.id`
- `approvals.current_step_id` → `approval_steps.id`
- `approvals.requested_by_id` → `users.id`
- `approval_steps.approval_id` → `approvals.id`
- `approval_steps.approver_id` → `users.id`
- `approval_steps.step_status_id` → `approval_statuses.id`
- `approval_histories.approval_id` → `approvals.id`
- `approval_histories.approval_step_id` → `approval_steps.id`
- `approval_histories.approver_id` → `users.id`
- `approval_histories.approval_action_id` → `approval_actions.id`
- `approval_histories.from_status_id` → `approval_statuses.id`
- `approval_histories.to_status_id` → `approval_statuses.id`
- `approval_templates.created_by_id` → `users.id`
- `approval_template_steps.template_id` → `approval_templates.id`
- `approval_template_steps.approver_id` → `users.id`

---

## 5) 비즈니스/검증 규칙
- 제품 등록 시 `vendor_id` **필수**.
- 입고(`transaction_type_id`=입고) 트랜잭션의 공급자 정보는 **라인 제품의 벤더**로만 해석. (헤더에 벤더 금지)
- 출고 트랜잭션은 `transaction_customers` **최소 1건** 필요.
- 결재는 **트랜잭션당 1건**(미삭제 기준)만 허용.
- 단계 전이는 **현재 단계**에서만 수행 가능. blocking 상태에서는 차기 이동 불가.
- 수량/단가 음수 금지(CHECK).
- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리.
- 사용자의 소속 그룹(`users.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음.
- 재고 현황 조회 API는 읽기 전용 권한 스코프 `inventory.view`를 요구하며, 스코프가 없는 사용자는 `/api/v1/inventory/**` 경로에서 403(`INVENTORY_SCOPE_REQUIRED`)을 받는다.
- `users.employee_id`는 앞뒤 공백을 제거한 뒤 대소문자 구분 없이 중복 검증하며, 저장 시 대문자로 정규화한다.
- 자기 정보 수정(`PATCH /users/me`)에서는 `phone`, `email`, `password`만 변경할 수 있고, `password` 변경 시 기존 비밀번호 검증이 필수다.
- 비밀번호 재설정(관리자)은 8자 영문 대소문자+숫자 조합을 생성하고 이메일 발송 큐에 푸시한 뒤 `force_password_change=true`, `password_updated_at=now()`로 기록한다.
- 비밀번호 변경이 성공하면 기존 세션은 즉시 만료하고, 강제 로그아웃 알림을 반환한다. 이후 무효화된 토큰으로 요청하면 `token revoked` 메시지를 반환해야 한다.

---

## 6) 인덱스/유니크 권장
- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존:  
  - `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `users(employee_id)`, `menus(menu_code)`, `groups(group_name)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)`  
  - `group_menu_permissions(group_id, menu_id, is_deleted)`
  - `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장  
  - `transaction_lines(transaction_id, line_no, is_deleted)`  
  - `transaction_customers(transaction_id, customer_id, is_deleted)`
- FK 및 조회 인덱스: 모든 `*_id`, `updated_at`, `is_deleted`, `is_active`.
- 재고 집계 마테뷰 인덱스: `inventory_balance_events_view(event_occurred_at DESC, event_id DESC)`, `inventory_balance_snapshots(updated_at DESC)` 및 필요 시 `inventory_balance_snapshots`의 `warehouse_balances` JSONB GIN 인덱스.

---

## 7) 에러 규격(예시)
- `400 BAD_REQUEST` — 필수 필드 누락, 형식 오류
- `409 CONFLICT` — 유니크 충돌(코드/번호/조합), **현재 단계 아님**
- `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고인데 고객 없음, blocking 상태에서 이동 등)
- `404 NOT_FOUND` — 리소스 없음 또는 삭제됨(`deleted=false` 기본 필터로 미노출)

---

## 8) 마이그레이션 가이드(요약)
1) `stock_transactions`에서 `vendor_id` 드롭.  
2) `customer_roles` 테이블 및 관련 컬럼 드롭.  
3) 모든 타입/코드 테이블에 공통 컬럼 4종 추가(미존재 시).  
4) 부분 유니크 인덱스(`WHERE is_deleted=false`) 또는 `(컬럼, is_deleted)` 복합 유니크 구성.  
5) 기존 결재 이력은 `approval_step_id` 매핑(없으면 1단계로 귀속).  
6) `approval_statuses`에 `is_blocking_next`, `is_terminal` 값 시드.
7) `users` 테이블에 `employee_id`, `name`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 컬럼을 추가하고 기존 `employee_no`, `employee_name`, `mobile_no` 데이터를 규칙에 맞게 마이그레이션한다. 그룹 매핑은 `users.group_id`로 유지한다.
8) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사용자-그룹 매핑을 `users.group_id`로 유지한다.
9) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재.
10) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지.

---

## 9) 초기 시드 값(예시)
- `transaction_types`: [입고, 출고] (`is_default`: 입고)  
- `transaction_statuses`: [초안, 상신, 승인, 반려, 완료] (`is_default`: 초안)  
- `approval_statuses`: [대기(pending, default, blocking), 진행중(in_progress, blocking), 보류(on_hold, blocking), 승인(approved, !blocking), 반려(rejected, blocking+terminal)]  
- `approval_actions`: [승인(approve), 반려(reject), 코멘트(comment)]  
- `uoms`: [EA(기본), BOX, KG ...]
- `menus`: [대시보드, 입출고 관리, 결재 관리, 레포트 등] — 상위/하위 메뉴 구조 포함  
- `groups`: [전사 관리자(기본), 창고 관리자, 결재 담당자]  
- `group_menu_permissions`: 기본 그룹별 메뉴 권한(CRUD 플래그); 전사 관리자는 모든 메뉴 `can_*`=true, 역할별로 세분화 설정  
- `zipcodes`: 행정안전부 도로명 주소 DB(5자리) 최신본을 기준으로 일괄 적재  

---

## 10) 구현 팁
- `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장.  
- 낙관적 잠금(선택): `version`(int) + ETag.  
- 병렬 결재 확장(선택): `approval_steps`에 `group_no`, `approval_mode(all|any)` 도입.
- 감사 로그(`approval_audits`) 적재 시 `approval.audit.recorded` 이벤트를 Kafka(토픽 예: `approval_audit_events`)와 WebSocket 브로드캐스트로 발행한다. 이벤트 구성은 `{ event, version, emitted_at, request_id, audit_id, summary }` JSON으로 정의하며, 운영 환경별 엔드포인트는 `event_bus.kafka.*`, `event_bus.websocket.*` 설정으로 분리한다.
- `/health` 응답의 `build_version`은 `config/default.toml`의 `[app].build_version`을 사용하며, `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.
