# 간단 입·출고 + 결재 시스템 설계서 (최종 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`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.