다크모드
인사 조직·직원 디렉터리 — 백엔드 참고
범위와 SSOT
이번 수직 슬라이스는 공통 운영 주체 계약의 다음 테이블을 그대로 사용한다.
workforce_organizations: Company 조직 원장workforce_employees: 비PII 직원 identity 원장workforce_assignments: 직원의 Office·조직 effective-dated 배치 원장operational_role_grants:human_capital_admincapabilityoperational_audit_events: append-only 공통 감사 envelope
별도 human_capital_* 직원·조직 테이블을 만들지 않는다. 인증 identity는 auth.users, 업무상 직원 identity는 workforce_employees.id로 분리한다.
입사 처리를 근태에서 인사 메뉴로 옮기고 근로계약·증명서 화면을 인사 문서로 묶는 것은 UI 소유권 정리다. 직원·배치·근로계약·문서 데이터 모델과 기존 라우트/API 키는 변경하지 않는다. 근태 상품은 직원 생성의 소유자가 아니며, 근무형태 배정과 출퇴근 fact만 소비한다.
mutation 계약
Migration: supabase/migrations/20260717024624_human_capital_directory_mutations.sql
배치 생명주기 확장: supabase/migrations/20260718190000_workforce_assignment_lifecycle.sql
직원 퇴직·보관 확장: supabase/migrations/20260718240000_hr_employee_lifecycle.sql
create_workforce_organization
입력:
p_company_idp_management_code: trim·대문자 정규화, Company 범위 고유, 생성 후 불변p_display_namep_organization_type:company|division|department|team|sitep_parent_organization_id: 선택, 같은 Company의 active 조직만 허용p_request_key: 필수 멱등 키
결과는 생성된 workforce_organizations 행의 JSON이다.
create_workforce_employee
입력:
p_company_idp_management_code: trim·대문자 정규화, Company 범위 고유, 생성 후 불변p_employee_number: 선택, 중복 허용, 최초 값 설정 후 불변p_display_namep_hire_date: 선택p_request_key: 필수 멱등 키
결과는 생성된 workforce_employees 행의 JSON이다. workforce_identity_id는 이 RPC에서 연결하지 않는다.
update_workforce_employee
비PII 기본정보인 display_name, hire_date만 수정한다. employee_number, management_code, 로그인 identity, 재직상태는 이 RPC의 입력이 아니다. p_expected_revision 불일치 시 workforce_employee_revision_conflict다. 입사일을 기존 활성/종료 배치 시작일보다 뒤로 옮길 수 없다.
create_workforce_assignment
- 필수: Company, 직원, Office, 배치 유형, 시작일, request key
- 선택: 같은 Company의 active 조직, 직위, 직무, 종료일
- Office는 active·같은 Company이며
human-capital상품 capability가 유효해야 한다. - 직원은
terminated|archived가 아니고 배치 시작일은 입사일보다 빠를 수 없다. primary는 직원 전체에서 기간 겹침을 금지한다.concurrent|temporary는 동일 Office·조직·유형의 기간 겹침을 금지한다.
end_workforce_assignment
p_ends_on=KST 업무일이면 저장 상태를 ended로 바꾼다. 미래 날짜이면 status='active', valid_until=p_ends_on을 유지한다. valid_until은 마지막 유효일을 포함한다. 기존 종료일을 뒤로 늘릴 때는 직원 단위 advisory lock 뒤 self를 제외하고 primary·동일 scope overlap을 다시 검사한다.
cancel_workforce_assignment
날짜 파생 상태가 scheduled인 배치만 cancelled로 전이한다. 현재 배치는 종료 RPC를 사용한다. ended|cancelled는 terminal이며 다시 수정하지 않는다.
transition_workforce_employee_lifecycle
입력은 Company, 직원, terminate|archive, 적용일, 비PII 업무 사유, expected revision, request key다. Company 관리자 또는 Company 범위 인사관리자만 실행한다.
terminate:
active|on_leave이고 입사일이 있는 직원만 허용한다.- 퇴직 효력일은 KST 오늘 이상이며 입사일보다 빠를 수 없다. 기존
termination_date가 있으면 변경·재예약을 거부한다. - 오늘이면 저장
employment_status='terminated'; 미래이면 기존 raw 상태를 유지하고termination_date로termination_scheduled를 투영한다. termination_date는 첫 비재직일이다.workforce_assignments.valid_until은 마지막 유효일을 포함하므로valid_from < termination_date인 배치는valid_until=termination_date-1로 줄인다. 당일 효력이면ended, 미래 효력이면 downstream 호환을 위해active를 유지한다.valid_from >= termination_date인 active 예정 배치는cancelled로 바꾼다.- create/end/lifecycle 모두 Company+employee assignment advisory lock을 employee·assignment row lock보다 먼저 잡는다.
archive:
- 저장상태가 terminated이거나
termination_date <= KST 업무일이어서 as-of 상태가 terminated인 직원만 허용한다. - 현재·예정 active 배치가 남아 있으면 거부한다. 보관은 삭제가 아니며 employee, assignment, lifecycle event를 유지한다.
- 재입사·퇴직일 정정·퇴직 취소는 이번 범위 밖이다.
응답은 최신 employee JSON, lifecycle event 요약, 변경된 assignment 목록, integration event ID다.
직원 상태와 이력 계약
private.workforce_employee_lifecycle_status(employment_status, termination_date, as_of)가 다음 상태를 투영한다.
text
archived → archived
terminated → terminated
termination_date <= as_of → terminated
termination_date > as_of → termination_scheduled
그 외 → raw employment_status따라서 스케줄러 없이도 퇴직일 이후의 목록·상세 상태가 일치한다. workforce_employee_lifecycle_events는 termination_scheduled|terminated|archived 사건, 적용일, 비PII 사유, 전후 lifecycle 상태, 직원 revision, 변경 assignment를 append-only로 보존한다. update/delete trigger가 변조를 거부한다.
기존 employee revision trigger는 다음도 강제한다.
- 최초 등록된
termination_date불변 - 퇴직 예정 등록 후
hire_date불변 - 퇴직일은 입사일 이상
- terminated는 terminated→archived만, archived는 terminal
상태 파생 계약
저장 컬럼은 기존 downstream 호환을 위해 active|ended|cancelled를 유지한다.
text
cancelled → cancelled
ended → ended
active + valid_from > as_of → scheduled
active + valid_until < as_of → ended
그 외 → current따라서 미래 종료 예약은 종료일까지 기존 consumer의 status='active' AND valid_from <= today AND (valid_until IS NULL OR valid_until >= today) 조건을 계속 통과한다.
개인정보 경계
직원 RPC는 주민등록번호, 생년월일, 전화번호, 이메일, 주소, 급여계좌를 받지 않는다. 민감 PII가 필요해지면 별도 제한 테이블·암호화·접근감사·보존정책을 설계해야 하며 공통 직원 테이블에 컬럼을 추가하면 안 된다.
권한과 RLS
- 공개 함수는
security invoker, 구현은privateschema의security definer, 빈search_path다. - Company의 owner/contract_admin/company_admin 또는 Company 전체 범위의 활성
human_capital_admin만 생성할 수 있다. workforce_organizations,workforce_employees는 RLS SELECT 정책을 유지한다.- 디렉터리 RLS는 Company에
human-capital상품이 가능한 Office가 하나 이상 있을 때만 읽는다. 배치 RLS는 해당 배치 Office의 상품 capability도 확인한다. - Office 범위
human_capital_admin은 같은 Office의 active membership이 함께 있어야 그 Office 배치를 관리할 수 있다. Company 관리자/Company 범위 인사관리자는 전체 Company를 관리한다. - authenticated에는 직접 INSERT/UPDATE/DELETE grant가 없다.
- 함수 EXECUTE grant는
authenticated,service_role에만 명시하고anon과public은 revoke한다.
멱등성과 동시성
기존 생성 함수는 대상 companies 행을 FOR UPDATE로 잠근다. 새 update/create/end/cancel command는 (company_id, command, request_key) advisory lock을 먼저 잡고 (company_id, request_key) 감사 이벤트를 조회한다.
- metadata에
commandName과 canonicalrequestPayload를 저장한다. - 같은 action·subject·commandName·payload 재시도: 감사 이벤트
after_data반환 - 같은 요청키·다른 command/action/subject/payload:
human_capital_request_key_conflict - 생성과
workforce.*_created감사 이벤트는 한 트랜잭션 - 감사 request key 충돌 결과가 생성 subject와 다르면 전체 mutation 롤백
배치 기간 검증은 별도로 Company+employee advisory lock을 사용해 동시 생성·종료일 연장의 write-skew를 막는다. mutation과 감사 이벤트 insert는 같은 DB 트랜잭션이다.
직원 lifecycle command는 private.workforce_employee_lifecycle_commands에 안정적인 요청 payload와 exact response를 (company_id, request_key)로 보존한다. 요청키 advisory lock → 날짜 유효성 재검사 전 command replay → Company+employee assignment advisory lock → employee/assignment row lock 순서다. archive 요청의 raw effectiveOn=null을 그대로 보존하므로 KST 날짜가 바뀐 뒤 같은 요청키로 재시도해도 최초 response를 반환한다. 다른 payload는 human_capital_request_key_conflict, 다른 revision은 workforce_employee_revision_conflict다. employee·assignment 변경, lifecycle event, operational audit, command response는 한 트랜잭션이다.
선택적 상품 연결
HR local commit 뒤 private.enqueue_platform_integration_event에 workforce.employee_status_changed / human-capital.employee-status-changed.v1을 기록한다. payload는 직원 UUID, 관리코드, 사건·적용일, 저장/투영 상태, revision, assignment changes만 담고 사유와 PII는 전달하지 않는다.
이 migration은 payroll, attendance/time, approval table을 조회·수정하거나 FK로 연결하지 않는다. delivery route나 consumer를 등록하지 않으므로 소비자가 없는 상태가 정상 성공이다. 근태·급여·전자결재 consumer는 후속 제품 migration에서 platform event를 독립적으로 구독해야 한다.
불변식과 인덱스
기존 공통 migration이 관리코드, 최초 사번, Company scope FK 불변식을 trigger/FK/CHECK로 강제한다. 이번 migration은 다음 접근 경로를 보강한다.
(company_id, status, display_name, id): 조직 목록- Company 범위 active
human_capital_admin: partial composite index (company_id, employee_id, status, valid_from, valid_until, revision): 배치 목록·기간 판정
workforce_employees.revision, workforce_assignments.revision은 1부터 시작한다. update/end/cancel은 expected_revision을 검사하고 정확히 1 증가한다. UUID, Company, 직원, Office, 조직, 배치 유형, 시작일은 기존 identity trigger가 불변으로 강제한다.
검증과 배포
- 정적 migration 테스트:
src/composables/__tests__/humanCapitalDirectoryMigration.spec.js - rollback SQL smoke:
supabase/tests/human_capital_directory_smoke.sql - repo 테스트:
src/composables/__tests__/humanCapitalDirectoryRepo.spec.js - 배치 migration 정적 테스트:
src/composables/__tests__/workforceAssignmentLifecycleMigration.spec.js - 배치 rollback SQL smoke:
supabase/tests/workforce_assignment_lifecycle_smoke.sql - 직원 lifecycle 정적 테스트:
src/composables/__tests__/workforceEmployeeLifecycleMigration.spec.js - 직원 lifecycle rollback SQL smoke:
supabase/tests/workforce_employee_lifecycle_smoke.sql - 적용 후 보정 migration:
supabase/migrations/20260718260000_workforce_assignment_lock_order.sql(240000이 이미 적용된 DB도 이 파일만 순차 적용)
로컬 Supabase에서 migration 적용과 관리자 성공, 미권한·타사 Office·상품 없음 거부, request replay/conflict, revision, primary/동일 scope overlap, 미래·즉시 종료, 예정 취소, RLS·ACL·불변 identity rollback smoke를 통과한다. 운영 DB에는 아직 적용하지 않았다.
직원 lifecycle smoke는 UTC 세션에서도 KST 업무일을 사용하고, 미래/당일 퇴직, inclusive 배치 마지막 유효일, 예정 취소, archive stable request replay, stale revision, 입사일 lock, 퇴직 전 보관 거부, 당일 퇴직 후 보관, append-only/RLS/ACL, provider-neutral outbox와 delivery 0건을 검증한다. 실제 동일 employee의 create/end/lifecycle은 같은 period advisory를 row lock보다 먼저 획득하고 expected revision으로 충돌을 판정한다.