diff --git a/docs/HISTORY_ASOF_DB_PLAN.md b/docs/HISTORY_ASOF_DB_PLAN.md new file mode 100644 index 0000000..666b3ee --- /dev/null +++ b/docs/HISTORY_ASOF_DB_PLAN.md @@ -0,0 +1,294 @@ +# History / As-Of DB Plan + +## Goal + +월간 스냅샷 파일을 따로 만드는 대신, DB 자체를 시간축이 있는 구조로 전환한다. +목표는 다음과 같다. + +- 조직도와 자리배치도를 수정할 때마다 과거 값이 사라지지 않게 누적 저장 +- 사용자가 특정 날짜 또는 기간을 선택하면 그 시점 기준 상태를 다시 조회 +- 날짜가 원래 없는 데이터도 `유효 시작일`과 `유효 종료일`을 부여해 과거 버전 조회 가능하게 만들기 + +핵심 원칙은 아래 한 줄이다. + +- 최신 값을 덮어쓰지 않고, `valid_from`, `valid_to` 기반 버전 행을 누적한다 + +## Why This Instead Of Snapshots + +- 월간 스냅샷 파일은 생성 시점만 남고 중간 변경 추적이 약하다 +- 원하는 날짜 기준으로 바로 조회하기 어렵다 +- 조직도만 따로 파일로 남으면 자리배치도, 권한, 운영 이력을 함께 맞추기 어렵다 + +따라서 이 프로젝트에는 "파일 스냅샷"보다 "시점 조회 가능한 버전 DB"가 더 맞다. + +## Query Model + +조회 기준은 `as_of` 또는 `date_from`, `date_to` 이다. + +- 특정 날짜 조회: + - `GET /api/members?as_of=2026-03-01` + - `GET /api/seat-maps/active/layout?as_of=2026-03-01` +- 기간 비교: + - `GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31` + +공통 조회 조건은 아래다. + +```sql +WHERE valid_from <= :as_of + AND (valid_to IS NULL OR valid_to > :as_of) +``` + +## Recommended Data Model + +### 1. Stable Base Tables + +식별자와 최소 메타만 유지하는 기준 테이블. + +```sql +CREATE TABLE members ( + id SERIAL PRIMARY KEY, + employee_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +```sql +CREATE TABLE seat_assignment_targets ( + member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +설명: +- `members` 는 "사람 자체" 식별자 역할 +- 실제 이름, 조직, 직급, 연락처, 좌석 같은 표시 데이터는 버전 테이블로 이동 + +### 2. Member Version Table + +```sql +CREATE TABLE member_versions ( + id BIGSERIAL PRIMARY KEY, + member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE, + name TEXT NOT NULL, + company TEXT NOT NULL DEFAULT '', + rank TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + department TEXT NOT NULL DEFAULT '', + grp TEXT NOT NULL DEFAULT '', + division TEXT NOT NULL DEFAULT '', + team TEXT NOT NULL DEFAULT '', + cell TEXT NOT NULL DEFAULT '', + work_status TEXT NOT NULL DEFAULT '', + work_time TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + photo_url TEXT NOT NULL DEFAULT '', + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, + revision_no BIGINT NOT NULL, + changed_by_user_id BIGINT, + change_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX member_versions_member_time_idx +ON member_versions (member_id, valid_from, valid_to); +``` + +설명: +- 날짜가 원래 없던 조직도 데이터도 이 테이블에서 과거 버전 관리 +- 어떤 시점에 이름, 조직, 직책, 연락처가 어땠는지 재구성 가능 + +### 3. Seat Assignment Version Table + +```sql +CREATE TABLE seat_assignment_versions ( + id BIGSERIAL PRIMARY KEY, + member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE, + seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE, + seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE, + seat_label TEXT NOT NULL DEFAULT '', + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, + revision_no BIGINT NOT NULL, + changed_by_user_id BIGINT, + change_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX seat_assignment_versions_member_time_idx +ON seat_assignment_versions (member_id, valid_from, valid_to); +``` + +설명: +- 현재 `seat_positions` 가 맡는 "최신 좌석 상태"를 버전형으로 저장 +- 특정 날짜의 자리배치도를 다시 그릴 수 있음 + +### 4. Optional Change Event Table + +```sql +CREATE TABLE entity_change_events ( + id BIGSERIAL PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id BIGINT NOT NULL, + action_type TEXT NOT NULL, + revision_no BIGINT NOT NULL, + changed_by_user_id BIGINT, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + change_reason TEXT NOT NULL DEFAULT '', + patch_json JSONB NOT NULL DEFAULT '{}'::jsonb +); +``` + +설명: +- 버전 테이블은 "그 시점의 전체 값" +- 이벤트 테이블은 "무엇이 바뀌었는지" +- 초기에는 없어도 되지만, 추후 비교 UI와 감사로그에 유용 + +### 5. Revision Table + +```sql +CREATE TABLE history_revisions ( + id BIGSERIAL PRIMARY KEY, + scope TEXT NOT NULL DEFAULT 'organization', + revision_label TEXT NOT NULL, + created_by_user_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + note TEXT NOT NULL DEFAULT '' +); +``` + +설명: +- 버전 묶음을 사람 친화적으로 관리할 때 사용 +- 예: `2026-03-27 1차 조직개편 반영` + +## How Writes Change + +현재 구조: +- `UPDATE members SET ...` +- `UPSERT seat_positions ...` + +바꿀 구조: +1. 현재 유효한 버전 행을 조회 +2. 값이 달라지면 기존 행의 `valid_to` 를 닫음 +3. 새 값을 가진 행을 `valid_from = now()` 로 insert +4. 필요하면 최신 캐시 테이블도 함께 갱신 + +예시: + +```sql +UPDATE member_versions +SET valid_to = NOW() +WHERE member_id = :member_id + AND valid_to IS NULL; + +INSERT INTO member_versions ( + member_id, name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, photo_url, + valid_from, valid_to, revision_no, changed_by_user_id, change_reason +) +VALUES ( + :member_id, :name, :company, :rank, :role, :department, :grp, :division, :team, :cell, + :work_status, :work_time, :phone, :email, :photo_url, + NOW(), NULL, :revision_no, :changed_by_user_id, :change_reason +); +``` + +## How Date-Bearing And Date-Less Data Coexist + +### 날짜가 원래 있는 데이터 + +- `integration_work_logs.work_date` +- `integration_vouchers.issue_date` + +이 데이터는 원래 날짜 컬럼이 있으므로 그대로 사용하면 된다. + +### 날짜가 원래 없는 데이터 + +- 조직도 인원 기본 정보 +- 조직 소속 +- 자리배치 상태 +- 사진 경로 + +이 데이터는 `valid_from`, `valid_to` 를 붙여 시점 조회가 가능하게 만든다. + +즉, "날짜가 없는 데이터"가 아니라 "유효기간을 부여한 버전 데이터"로 바꾸는 것이다. + +## API Direction + +### Common UI Input + +사용자가 실제 HTML에서 고르는 기준은 헤더의 날짜 제어를 공통 입력으로 쓰는 것이 맞다. + +권장안: +- 프로젝트/팀 분석: 기존처럼 `시작일 ~ 종료일` +- 조직도/자리배치도: 우선 `기준일(as_of)` 1개를 사용 +- 필요하면 조직도 비교 화면에서 `비교 시작일`, `비교 종료일` 확장 + +현재 상태: +- 헤더 날짜 제어는 `프로젝트별 분석`, `팀/개인별 분석` iframe에 이미 전달되고 있음 +- 조직도/자리배치도는 아직 헤더 날짜를 실제 조회 조건으로 사용하지 않음 + +권장 API: + +```text +GET /api/members?as_of=2026-03-27 +GET /api/members/{id}?as_of=2026-03-27 +GET /api/seat-maps/active/layout?as_of=2026-03-27 +GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31 +``` + +## Migration Strategy + +### Phase 1. History Tables Add + +- `member_versions` +- `seat_assignment_versions` +- `history_revisions` +- 필요 시 `entity_change_events` + +현재 `members`, `seat_positions` 는 그대로 유지 + +### Phase 2. Backfill + +- 현재 `members` 최신값을 `member_versions(valid_from = NOW(), valid_to = NULL)` 로 적재 +- 현재 `seat_positions` 최신값을 `seat_assignment_versions(valid_from = NOW(), valid_to = NULL)` 로 적재 +- 이 단계에서는 과거 진짜 이력은 없고 "현재 상태를 버전 구조에 싣는 것"이 목표 + +### Phase 3. Dual Write + +- 조직도 수정 시: + - 기존 `members` 갱신 + - 동시에 `member_versions` 에 append +- 자리배치 저장 시: + - 기존 `seat_positions` 갱신 + - 동시에 `seat_assignment_versions` 에 append + +### Phase 4. As-Of Read APIs + +- 조직도 API에 `as_of` 지원 +- 자리배치도 API에 `as_of` 지원 +- 헤더 날짜 제어와 연결 + +### Phase 5. Full History-First Read + +- 최신 조회도 버전 테이블 기준으로 전환 +- `members`, `seat_positions` 는 캐시 또는 편의 테이블로 축소 가능 + +## Recommended First Scope + +처음부터 모든 테이블을 이력화하지 말고 아래부터 시작하는 것이 안전하다. + +1. `members` -> `member_versions` +2. `seat_positions` -> `seat_assignment_versions` +3. 조직도/자리배치도 조회 API에 `as_of` + +이 세 가지가 되면 사용자는 원하는 날짜의 조직 상태와 좌석 상태를 볼 수 있다. + +## Explicitly Removed From Scope + +- 월간 스냅샷 파일 생성 +- 스냅샷 다운로드 기능 +- 조직도만 따로 파일로 내보내는 방식 + +이 프로젝트의 방향은 "파일 스냅샷"이 아니라 "시점 조회 가능한 버전 DB"다.