다크모드
전자결재 — 데이터 모델·상태머신 (BE 참고)
독자: BE 개발자/AI. 갱신: 2026-07-18. as-built = auth user/Office participant 기반 생성 → 상신 → 순차 승인/반려. 전결·대결·회수·보류와 설정 저장은 후속 범위다.
받은 결재·상신함·참조·새 기안·전체 문서를 하나의 업무 내비게이션으로 묶은 변경은 프런트 IA다. 문서 상태머신, participant 범위, RPC, 기존 라우트와 데이터 키는 변경하지 않는다.
1. 서버 정본 상태머신
draft(version=1) → submitted(version+1) → approved | rejected(version+1)
submitted에서 승인하면 현재 step만pending → approved. 다음 step이 있으면 문서는submitted유지, 마지막이면approved.- 반려하면 현재 step이
pending → rejected, 문서는 즉시rejected터미널. - 문서 행을
FOR UPDATE로 잠근 뒤expected_version을 검사한다. 불일치는approval_version_conflict이고 변경은 없다. - 요청마다 Company 범위
request_key를 요구한다. 같은 키·같은 payload/행위 재요청은 기존 결과를 반환하고, 생성 payload·문서·행위·버전·의견이 다르면approval_request_key_conflict다.
2. 테이블과 식별 계약
approval_documents: Company/Office, 불변 관리코드, 등록번호, 상태·version·current_step,drafter_actor_user_id와 불변 participant 스냅샷.approval_document_steps: 문서별 순번·행위·상태,participant_actor_user_id, 불변 participant 스냅샷, 실제 처리자의 core actor 스냅샷.approval_document_events:created/submitted/approved/rejectedappend-only 이력 겸 멱등 요청 원장.approval_document_sequences: Company·연도별 등록번호 카운터. 인증 사용자의 직접 조회/쓰기는 열지 않는다.- v2 정본은
auth.users+ Company/Office membership이다.party_id는 선택이며 인사 UUID는 nullable legacy 호환 열일 뿐 FK·권한 판단에 사용하지 않는다.
get_approval_participant_directory는 정확한 Office의 active membership과 Company 관리자만 반환한다. 서버가 participantId를 다시 검증하고 사용자명·Office·역할을 스냅샷으로 만든다. 클라이언트가 보낸 표시명은 신뢰하지 않는다.
3. RPC 경계
get_approval_participant_directory(company_id, office_id): 현재 actor와 선택 가능한 Office 참여자를 반환한다.create_approval_document:auth.uid()actor와 각participantId를 검증하고 문서·결재선·created event·감사를 한 트랜잭션에 기록한다. 이전 consumer의employeeIdJSON 키는 값이 auth user UUID일 때만 호환 입력으로 해석한다.submit_approval_document(document_id, expected_version, request_key): 기안자 본인만 draft를 상신하며 등록번호를 부여한다.decide_approval_document(document_id, approve|reject, opinion, expected_version, request_key): 현재 pending step의 participant auth user UUID와 인증 actor가 같아야 한다.
공개 함수는 security invoker, 구현은 private schema의 최소 security definer다. 모든 경로는 auth.uid(), 정확한 Office 접근, private.require_office_product(..., 'approval')를 검사한다. definer는 search_path=''이고 authenticated는 도메인 테이블 SELECT만 가진다.
4. RLS·감사·인덱스
- 노출 테이블은 모두 RLS를 켠다. 활성 approval entitlement가 있는 Office에서 기안자·결재 참여자·관리자만 읽는다. RLS와 별개로 table/function grant와
PUBLIC/anon execute revoke를 선언한다. - 도메인 이벤트마다 같은 트랜잭션에서 core actor 전용
private.append_approval_audit_event_v2가 공통 감사 원장에 기록한다.actor_employee_id는 null이고 participant snapshot과 contract version을 남긴다. action key는approval.document_created/submitted/approved/rejected, subject는approval_document다. - 목록/RLS 경로는 Company·Office·상태·시간, 받은 결재함은 participant user·state, 이력은 document·time 인덱스를 사용한다. 모든 FK 보조 인덱스도 명시했다.
- v2 SQL rollback smoke:
supabase/tests/approval_core_actor_workflow_smoke.sql. workforce 행 0개에서 directory·생성·상신·승인, entitlement/Office gate와 legacy reconciliation 표시를 검증한다.
Legacy v1
기존 HR FK는 forward migration에서 제거하고 열은 nullable로 보존한다. v1 pending step의 auth user를 확정할 수 없으면 추측하지 않는다. 문서는 관리자에게 읽히며 legacyReconciliationRequired=true를 반환하고 v2 결정 RPC는 approval_legacy_reconciliation_required로 중단한다. 원 직원/배치 스냅샷은 변경하지 않는다.
5. 후속 확장 경계
전결·대결·회수·보류는 현재 FE 데모 상태에만 남아 있다. 서버에 추가할 때 기존 enum을 억지로 넓히지 말고 권한·위임 유효기간·터미널 전이를 별도 결정문으로 확정한다. 양식/템플릿/참조/수신 정규화, 첨부 보존·악성코드 검사, 알림 outbox도 후속이다.
6. 업무 주체 link와 결과 outbox
마이그레이션 20260717051820_approval_subject_link_outbox.sql은 결재가 구매·근로계약·근태 같은 원 업무의 금액·수량 원장을 소유하지 않도록 경계를 고정한다.
approval_subject_links:subject_type + subject_id와 결재 문서를 안정 UUID로 연결한다. 한 문서는 한 업무 주체만 참조하고, 동일 Company·업무 주체에는 동시에 열린 결재를 한 건만 허용한다. 과거 승인·반려 link는 삭제하지 않으므로 재결재 이력을 시간순으로 추적할 수 있다.- subject snapshot은 결재 당시 제목·금액·revision 등 검토 맥락만 보존한다. 원 업무의 현재 상태와 원장은 원 도메인이 계속 소유한다.
private.create_linked_approval_document_impl은 결재 draft와 link를 한 트랜잭션에서 만든다. 이 helper는 authenticated에 노출하지 않는다. 각 도메인의 전용 RPC가 자신의 주체 존재·Company/Office·revision·권한을 검증한 뒤 호출한다.private.approval_subject_outbox_events는 제출과 최종 승인·반려를 append-only로 보존한다. 다단계 결재의 중간 step 승인은 문서 상태가 아직submitted이므로 업무 결과로 발행하지 않는다.- 업무 consumer는 outbox event UUID를 멱등키로 사용하고 자신의 상태 전이·감사 기록과 함께 소비한다. outbox 행은 수정·삭제하거나
consumed로 바꾸지 않는다. operational_audit_events에도 update/delete 거부 trigger를 추가해 service 역할을 포함한 모든 경로에서 append-only를 DB가 강제한다.
RLS는 결재 본문 권한을 넓히지 않는다. link의 authenticated SELECT는 기존 can_read_approval_document를 그대로 사용하며 private outbox는 Data API에 노출하지 않는다. 구매 등 원 업무 화면에는 다음 도메인 slice의 권한 검증 summary RPC가 허용하는 결재번호·상태만 표시한다.
검증은 supabase/tests/approval_subject_link_outbox_smoke.sql과 approvalSubjectLinkMigration.spec.js가 담당한다. 같은 요청 재시도, payload 충돌, 동시 open 차단, terminal-only 결과, link/outbox/audit append-only, authenticated 직접 쓰기와 private outbox 조회 거부를 고정한다.