데이터를 다룰 때 읽는 문서

데이터 구조

가장 두꺼운 문서입니다. 동의 변경 이력을 따로 쌓고, 같은 번호의 고객을 합칠 때 원본을 지우지 않고 병합 이력을 남기는 구조가 여기서 나왔습니다.

41,307자 · 시스템이 만든 그대로입니다

온헤어 CRM 데이터 구조 문서

1. 설계 범위와 기준

이 문서는 온헤어 CRM 첫 개발 범위에서 실제로 사용하는 데이터를 설계한다. 고객은 가게 안에서 정규화 번호가 같은 경우 한 사람으로 판정한다. 모든 조회와 저장은 가게 단위로 분리한다. (FR-003, FR-004, FR-005, NFR-002)

  • 시간은 데이터베이스에 UTC로 저장하고, 화면 표시와 발송 일정 계산은 Asia/Seoul 기준으로 처리한다.
  • 고객의 이름과 휴대전화 번호는 필요한 운영 목적에만 사용한다.
  • 고객 병합은 원본 고객을 바로 삭제하지 않고 병합됨 상태로 보관한다.
  • 시술 사진은 촬영·보관 동의와 보유기간이 확인된 경우에만 저장한다.
  • 네이버 예약·톡톡 및 카카오톡 채널의 과거 대화는 저장하지 않는다.
  • 회원권·정액권은 첫 개발 범위에 없으므로 데이터 모델에 포함하지 않는다.

2. 확인이 필요한 정책

항목현재 문서에 반영한 처리확인 필요
대체 시술 시 타 담당 고객 이력 열람TemporaryAccessGrant로 임시 열람 요청, 승인, 만료, 열람 이력을 저장할 수 있게 설계원장님 승인 방식, 원장님이 미리 시간제 권한 부여하는 방식, 담당 디자이너 변경 방식 중 하나를 확정해야 함. (FR-006)
염색약 번호·배합 입력dyeNumber, formula를 선택 입력으로 설계염색 시술일 때 두 항목을 필수로 막을지 확정 필요. (FR-008)
사진 보유기간고객별 사진 동의에 retentionUntil을 저장사진의 기본 보유기간을 가게 정책으로 정해야 함. (FR-009, FR-027)
전날 오후 6시 이후 등록된 내일 예약운영 메시지를 생성하되 자동 발송 여부는 자동 발송 중지 또는 수동 확인 대상으로 처리 가능즉시 발송할지, 수동 연락 대상으로 둘지 확정 필요. (FR-012)
방문 3시간 이내 등록된 당일 예약자동 메시지 발송 시점을 이미 지났다면 수동 연락 대상으로 전환 가능즉시 발송 여부를 확정해야 함. (FR-013)

3. 주요 데이터와 관계

3.1 관계도

그림을 그리는 중…

가게는 고객, 직원, 시술 기록, 예약 확인 건, 자료 가져오기의 분리 단위다. 고객은 예약 확인 건과 시술 기록을 여러 건 가질 수 있다.

병합 이력은 대표 고객과 통합 대상 고객의 관계를 별도 항목으로 보관한다. 이 구조를 통해 병합 뒤에도 병합 취소 시 원래 연결을 복구할 수 있다. (FR-024, FR-025, NFR-005)

3.2 테이블과 사업 용어

테이블사업 용어용도관련 요구사항
Store가게온헤어 매장 단위와 운영 상태를 관리FR-001, FR-002, NFR-002
Employee직원 계정원장님·디자이너 계정, 역할, 활성 상태 관리FR-001, FR-002
Customer고객휴대전화 번호, 담당 디자이너, 고객 단계, 고객 상태 관리FR-003, FR-004, FR-019, FR-020
ConsentHistory동의 변경 이력개인정보 동의, 마케팅 수신동의, 사진 촬영·보관 동의의 변경 근거 관리FR-026, FR-027
ReservationConfirmation예약 확인 건예약 예정, 예약 연락처 스냅샷, 방문 결과, 발송 대상 관리FR-010, FR-011, FR-017, FR-018
OperationalMessage운영 메시지전날 예약 확인·당일 예약 안내의 발송 일정과 결과 관리FR-012~FR-016
ManualContact수동 연락알림톡 실패 또는 자동 발송 불가 시 연락 결과 관리FR-016
TreatmentRecord시술 기록시술 종류, 시술 메모, 염색약 번호, 배합, 다음 예약 관리FR-007, FR-008, FR-018
TreatmentPhoto시술 사진선택 사진 파일, 동의 근거, 보유기간 관리FR-009, FR-027
InternalTask내부 할 일시술 완료 후 시술 기록 미작성 알림 관리FR-017
ContactLog뜸해진 고객 연락고객별 연락 일시, 연락 채널, 연락 결과 기록FR-020, FR-021
ImportJob자료 가져오기CSV 및 수첩 자료의 임시 검증·반영 작업 관리FR-022, FR-023
ImportErrorRow가져오기 오류 행오류가 난 원본 행과 재처리 상태 관리FR-023
MergeHistory병합 이력고객 병합 실행, 충돌 처리, 취소 이력 관리FR-024, FR-025
MergeItem병합 항목병합에 포함된 대표 고객·통합 대상 고객과 이동 기록 관리FR-024, FR-025
TemporaryAccessGrant임시 열람 권한대체 시술 시 타 담당 고객 기록 열람 근거 관리FR-006
AuditLog활동 이력조회·수정·내보내기·권한 변경 이력 관리FR-002, FR-028

4. 테이블별 구조

4.1 Store — 가게

온헤어의 데이터 분리 기준이다. 첫 버전은 한 가게 운영을 전제로 하더라도, 모든 주요 데이터에 가게 식별값을 연결한다. (FR-001, FR-002, NFR-002)

필드형식필수설명
idUUID가게 식별값
name문자열가게명. 예: 온헤어
timezone문자열기본값 Asia/Seoul
isActive불리언가게 운영 상태
createdAt일시생성 일시
updatedAt일시수정 일시

인덱스

  • isActive
  • 모든 주요 테이블의 storeId 외래 키 인덱스

4.2 Employee — 직원 계정

원장님과 디자이너의 로그인, 역할, 계정 활성 상태를 관리한다. 계정이 비활성 상태면 로그인과 데이터 접근을 막는다. (FR-001, FR-002, FR-005)

필드형식필수설명
idUUID직원 식별값
storeIdUUID소속 가게
name문자열직원 이름
role열거형OWNER, DESIGNER
loginIdentifier문자열로그인 식별 정보
loginMethod열거형로그인 수단. 실제 지원 수단 확정 필요
isActive불리언활성 또는 차단 상태
lastRoleChangedAt일시아니오마지막 권한 변경 일시
createdAt일시생성 일시
updatedAt일시수정 일시

제약과 인덱스

  • loginIdentifier는 전체 서비스에서 중복되지 않아야 한다.
  • storeId, role, isActive 복합 인덱스
  • 마지막 활성 원장님 계정을 차단하거나 디자이너로 변경하지 못하게 애플리케이션에서 검증한다. (FR-002)

4.3 Customer — 고객

고객의 휴대전화 번호, 담당 디자이너, 동의 상태, 고객 단계와 병합 상태를 관리한다. 원본 번호를 유지하면서 정규화 번호로 중복을 판정한다. (FR-003, FR-004, FR-019, FR-026)

필드형식필수설명
idUUID고객 식별값
storeIdUUID소속 가게
name문자열고객 이름
phoneRaw문자열입력받은 원본 휴대전화 번호
phoneNormalized문자열아니오중복 판정과 검색용 정규화 번호
phoneDisplay문자열아니오화면 표시용 번호
phoneValidationStatus열거형VALID, REVIEW_NEEDED, MISSING
assignedDesignerIdUUID아니오담당 디자이너
stage열거형자동 계산된 고객 단계
status열거형ACTIVE, MERGED, ANONYMIZED, DELETED
mergedIntoCustomerIdUUID아니오병합된 경우 대표 고객
personalConsentStatus열거형개인정보 동의 현재 상태
marketingConsentStatus열거형마케팅 수신동의 현재 상태
photoConsentStatus열거형사진 촬영·보관 동의 현재 상태
photoRetentionUntil일시아니오사진 보관 종료 일시
lastTreatmentAt일시아니오마지막 시술 일시
retentionUntil일시아니오개인정보 보유 종료 예정 일시
createdAt일시생성 일시
updatedAt일시수정 일시
version정수동시 수정 충돌 방지용 버전

고객 단계 값

화면 표시판정 기준관련 요구사항
INQUIRING문의 중고객은 있으나 유효한 미래 예약과 시술 기록이 없는 상태FR-019
RESERVATION_BOOKED방문 예약됨유효한 미래 예약 확인 건이 있음FR-019
TREATMENT_COMPLETED시술 완료시술 기록이 있으나 다음 예약 및 재방문 조건에 해당하지 않음FR-019
NEXT_RESERVATION_BOOKED다음 예약 잡힘시술 후 다음 예약이 등록되어 있음FR-018, FR-019
RETURNING_CUSTOMER재방문 고객시술 기록이 두 건 이상 있음FR-019
DORMANT뜸해짐마지막 시술 후 달력 기준 2개월 경과, 미래 예약 없음FR-020

고객 상태 값

화면 표시의미
ACTIVE활성현재 관리 고객
MERGED병합됨대표 고객으로 통합된 원본 고객
ANONYMIZED알아볼 수 없게 처리됨삭제 요청 또는 보유기간 종료에 따른 비식별 처리
DELETED삭제됨삭제 정책상 완전 삭제가 가능한 경우의 상태

제약과 인덱스

  • storeId + phoneNormalizedstatus = ACTIVE인 고객에 한해 유일해야 한다.
  • PostgreSQL 부분 유니크 인덱스를 사용한다.
  • storeId + assignedDesignerId + status 인덱스
  • storeId + stage + lastTreatmentAt 인덱스
  • storeId + name 검색 인덱스
  • phoneNormalized 검색 인덱스

phoneNormalized가 없거나 REVIEW_NEEDED인 고객은 자동 병합과 자동 알림톡 발송 대상에서 제외한다. (FR-004, FR-010, FR-012, FR-013)


4.4 ConsentHistory — 동의 변경 이력

개인정보 동의, 마케팅 수신동의, 사진 촬영·보관 동의를 서로 분리해 저장한다. 고객 테이블은 현재 상태를 빠르게 조회하기 위한 값이고, 이 테이블은 변경 근거를 보관한다. (FR-026, FR-027)

필드형식필수설명
idUUID이력 식별값
customerIdUUID대상 고객
consentType열거형PERSONAL, MARKETING, PHOTO
previousStatus열거형아니오변경 전 상태
status열거형AGREED, REFUSED, UNKNOWN
consentedAt일시아니오동의 또는 거부 확인 일시
source열거형STORE, PHONE, NAVER_RESERVATION, KAKAO_CHANNEL, IMPORT, OTHER
changedByEmployeeIdUUID아니오변경한 직원
evidenceNote문자열아니오기존 자료에서 확인한 근거나 메모
createdAt일시생성 일시

인덱스

  • customerId + consentType + createdAt DESC
  • consentType + status

4.5 ReservationConfirmation — 예약 확인 건

네이버 예약을 대체하지 않는다. 예약 확인, 알림톡, 방문 결과, 시술 기록 연결에 필요한 최소 정보를 저장한다. (FR-010~FR-018)

필드형식필수설명
idUUID예약 확인 건 식별값
storeIdUUID소속 가게
customerIdUUID연결 고객
designerIdUUID방문 예정 담당 디자이너
scheduledAt일시방문 예정 일시
expectedTreatment문자열아니오예정 시술 종류
touchpoint열거형예약·방문 접점
customerConfirmationStatus열거형고객 확인 상태
visitResult열거형방문 결과
contactNameSnapshot문자열예약 등록 당시 고객명
contactPhoneSnapshot문자열예약 등록 당시 휴대전화 번호
contactPhoneNormalizedSnapshot문자열아니오예약 발송 대상 판정용 정규화 번호
contactValidationStatus열거형예약 연락처 검증 상태
canceledAt일시아니오취소 처리 일시
createdByEmployeeIdUUID등록자
createdAt일시생성 일시
updatedAt일시수정 일시
version정수동시 수정 충돌 방지용 버전

예약 확인 건 상태 값

필드화면 표시관련 요구사항
customerConfirmationStatusPENDING확인 전FR-010, FR-011
CONFIRMED고객 확인FR-010, FR-011
UNREACHABLE연락 닿지 않음FR-010, FR-016
CHANGED변경 요청FR-010, FR-011
visitResultNOT_RECORDED결과 미입력FR-011, FR-017
TREATMENT_COMPLETED시술 완료FR-017
NO_SHOW노쇼FR-017, FR-029
CUSTOMER_CANCELED고객 취소FR-012~FR-014
STORE_CANCELED매장 취소FR-012~FR-014

인덱스

  • storeId + scheduledAt
  • storeId + designerId + scheduledAt
  • customerId + scheduledAt DESC
  • storeId + visitResult + scheduledAt
  • 중복 후보 경고용: storeId + customerId + designerId + scheduledAt

4.6 OperationalMessage — 운영 메시지

전날 오후 6시 예약 확인과 방문 3시간 전 당일 안내의 생성, 발송 예약, 요청, 결과 수신을 관리한다. 예약 확인 건 하나에는 안내 종류별로 여러 발송 이력이 생길 수 있으나, 같은 안내의 성공 발송은 한 번만 허용한다. (FR-012, FR-013, FR-014, FR-015)

필드형식필수설명
idUUID운영 메시지 식별값
reservationConfirmationIdUUID대상 예약 확인 건
messageType열거형DAY_BEFORE_CONFIRMATION, SAME_DAY_REMINDER
status열거형발송 상태
scheduledSendAt일시아니오자동 발송 예정 시각
sentAt일시아니오실제 발송 요청 일시
senderProfileIdUUID아니오사용한 발신 프로필
alimtalkTemplateIdUUID아니오사용한 템플릿
providerRequestId문자열아니오알림톡 발송 요청 식별값
providerResultId문자열아니오알림톡 결과 식별값
failureReason문자열아니오실패 또는 자동 발송 중지 이유
payloadSnapshotJSON아니오발송 당시 적용된 예약 정보
createdAt일시생성 일시
updatedAt일시수정 일시

운영 메시지 상태

화면 표시의미
SCHEDULED발송 예정자동 발송 대기
PROCESSING처리 중알림톡 사업자 결과 대기
SUCCESS성공정상 발송 결과 확인
FAILED실패발송 실패
AUTO_SEND_STOPPED자동 발송 중지번호·템플릿·발신 프로필 등의 문제로 자동 발송 중지
MANUAL_CONTACT_COMPLETED수동 연락 완료실패 뒤 수동 연락 처리가 완료됨

인덱스와 중복 방지

  • status + scheduledSendAt: 예약 발송 작업 조회
  • reservationConfirmationId + messageType + createdAt DESC
  • providerRequestId 유니크
  • providerResultId 유니크
  • 성공 상태 메시지는 예약 확인 건과 메시지 종류 기준으로 하나만 허용한다. (NFR-004)

4.7 ManualContact — 수동 연락

알림톡이 실패했거나 자동 발송이 중지된 예약 확인 건에 대해 전화, 카카오톡 채널 등으로 직접 연락한 결과를 남긴다. (FR-016)

필드형식필수설명
idUUID수동 연락 식별값
reservationConfirmationIdUUID대상 예약 확인 건
operationalMessageIdUUID아니오연결된 실패 운영 메시지
contactedAt일시연락 일시
channel열거형PHONE, KAKAO_CHANNEL, PERSONAL_KAKAO, NAVER_TALKTALK, OTHER
result열거형연락 결과
note문자열아니오필요한 운영 메모
contactedByEmployeeIdUUID연락한 직원
createdAt일시생성 일시

4.8 TreatmentRecord — 시술 기록

실제 시술 정보와 다음 예약을 관리한다. 시술 완료된 예약 확인 건에는 시술 기록이 하나만 연결될 수 있다. (FR-007, FR-008, FR-017, FR-018)

필드형식필수설명
idUUID시술 기록 식별값
storeIdUUID소속 가게
customerIdUUID대상 고객
reservationConfirmationIdUUID아니오연결 예약 확인 건
treatmentAt일시실제 시술 일시
designerIdUUID실제 시술 담당 디자이너
treatmentType문자열실제 시술 종류
treatmentMemo문자열시술 메모
dyeNumber문자열아니오염색약 번호
formula문자열아니오배합
nextReservationAt일시아니오시술 직후 등록한 다음 예약 일시
createdByEmployeeIdUUID작성자
updatedByEmployeeIdUUID마지막 수정자
createdAt일시작성 일시
updatedAt일시수정 일시
version정수동시 수정 충돌 방지용 버전

제약과 인덱스

  • reservationConfirmationId는 값이 있을 경우 유일하다.
  • storeId + customerId + treatmentAt DESC
  • storeId + designerId + treatmentAt DESC
  • reservationConfirmationId
  • nextReservationAt

시술 기록 저장 시 treatmentTypetreatmentMemo는 필수다. 이 기록이 저장되면 연결된 시술 기록 미작성 내부 할 일을 완료한다. (FR-008, FR-017)


4.9 TreatmentPhoto — 시술 사진

사진 파일 자체는 비공개 객체 저장소에 두고, 데이터베이스에는 파일 식별값과 접근·보유 근거만 저장한다. (FR-009, FR-027, NFR-003)

필드형식필수설명
idUUID사진 식별값
treatmentRecordIdUUID연결 시술 기록
storageKey문자열비공개 파일 저장 경로
originalFileName문자열원본 파일명
mimeType문자열파일 형식
fileSizeBytes정수파일 크기
consentHistoryIdUUID사진 촬영·보관 동의 근거
retentionUntil일시사진 보유 종료 일시
uploadedByEmployeeIdUUID업로드 직원
uploadedAt일시업로드 시각
deletedAt일시아니오보유기간 종료 또는 삭제 처리 일시

인덱스

  • treatmentRecordId
  • retentionUntil + deletedAt

4.10 InternalTask — 시술 기록 미작성 내부 할 일

오늘 예약의 방문 결과가 시술 완료인데 시술 기록이 없으면 담당 디자이너에게 표시한다. (FR-017)

필드형식필수설명
idUUID할 일 식별값
storeIdUUID소속 가게
reservationConfirmationIdUUID시술 완료 예약 확인 건
assigneeEmployeeIdUUID담당 디자이너
taskType열거형첫 버전은 TREATMENT_RECORD_REQUIRED
status열거형OPEN, COMPLETED, CANCELED
dueAt일시아니오처리 권장 시각
completedAt일시아니오완료 일시
createdAt일시생성 일시
updatedAt일시수정 일시

제약과 인덱스

  • reservationConfirmationId + taskType 유니크
  • assigneeEmployeeId + status + createdAt DESC

4.11 ContactLog — 뜸해진 고객 연락

뜸해진 고객 목록에서 디자이너가 고객별 연락 이력을 남긴다. 마케팅 수신동의 상태는 연락 당시 스냅샷으로도 함께 저장한다. (FR-020, FR-021)

필드형식필수설명
idUUID연락 기록 식별값
storeIdUUID소속 가게
customerIdUUID대상 고객
contactedAt일시연락 일시
channel열거형연락 채널
result열거형연락 결과
marketingConsentSnapshot열거형연락 당시 마케팅 수신동의
note문자열아니오연락 메모
createdByEmployeeIdUUID기록한 직원
createdAt일시생성 일시

인덱스

  • storeId + customerId + contactedAt DESC
  • storeId + createdByEmployeeId + contactedAt DESC

재방문 광고 자동 발송은 첫 개발 범위가 아니다. 이 테이블은 디자이너가 직접 연락한 결과를 남기는 용도다. (FR-021)


4.12 ImportJob, ImportErrorRow — 자료 가져오기와 오류 행

CSV 파일과 수첩 자료를 바로 고객 데이터에 반영하지 않고, 먼저 검증한 뒤 반영한다. 원본 행 번호와 오류 이유를 보관해 수정·재처리할 수 있다. (FR-022, FR-023, NFR-006)

ImportJob

필드형식필수설명
idUUID자료 가져오기 식별값
storeIdUUID대상 가게
sourceType열거형CSV, MANUAL_NOTE
fileName문자열아니오CSV 파일명
fileStorageKey문자열아니오원본 파일 보관 경로
status열거형작업 상태
totalRowCount정수전체 행 수
successRowCount정수정상 반영 행 수
errorRowCount정수오류 행 수
createdByEmployeeIdUUID작업 실행 원장님
startedAt일시아니오처리 시작 일시
completedAt일시아니오처리 완료 일시
createdAt일시생성 일시

ImportErrorRow

필드형식필수설명
idUUID오류 행 식별값
importJobIdUUID자료 가져오기 작업
rowNumber정수원본 CSV 행 번호
rawDataJSON원본 행 데이터
errorCode문자열오류 코드
errorMessage문자열오류 설명
status열거형REVIEW_NEEDED, CORRECTED, REPROCESSED, SKIPPED
correctedDataJSON아니오화면에서 수정한 데이터
reprocessedAt일시아니오재처리 일시
createdAt일시생성 일시
updatedAt일시수정 일시

인덱스

  • ImportJob: storeId + createdAt DESC
  • ImportErrorRow: importJobId + status + rowNumber

4.13 MergeHistory, MergeItem — 병합 이력과 병합 취소

고객 병합은 고객, 시술 기록, 예약 확인 건 등의 연결을 대표 고객으로 옮기는 작업이다. 병합 취소를 위해 변경 전·후 연결 정보를 보관한다. (FR-024, FR-025, NFR-005)

MergeHistory

필드형식필수설명
idUUID병합 작업 식별값
storeIdUUID대상 가게
representativeCustomerIdUUID대표 고객
status열거형COMPLETED, REVERSED, REVIEW_NEEDED
conflictResolutionJSON이름, 담당 디자이너, 동의 상태 충돌 처리 내용
executedByEmployeeIdUUID병합 실행 원장님
executedAt일시병합 실행 일시
reversedByEmployeeIdUUID아니오병합 취소 실행 원장님
reversedAt일시아니오병합 취소 일시
reverseReason문자열아니오병합 취소 사유
createdAt일시생성 일시

MergeItem

필드형식필수설명
idUUID병합 항목 식별값
mergeHistoryIdUUID병합 작업
sourceCustomerIdUUID통합 대상 고객
targetCustomerIdUUID대표 고객
movedTreatmentRecordIdsJSON이동한 시술 기록 식별값 목록
movedReservationIdsJSON이동한 예약 확인 건 식별값 목록
reviewNeededRecordIdsJSON자동 분리할 수 없어 검토가 필요한 기록 목록
createdAt일시생성 일시

제약과 인덱스

  • MergeHistory: storeId + representativeCustomerId + executedAt DESC
  • MergeItem: mergeHistoryId, sourceCustomerId
  • 병합 취소 전, 병합 후 새로 만들어진 기록이 있으면 reviewNeededRecordIds에 넣고 원장님이 처리하도록 한다. (FR-025)

4.14 TemporaryAccessGrant — 임시 열람 권한

다른 담당 디자이너의 고객을 대체 시술해야 할 때 필요한 최소 정보 열람 근거를 저장한다. 최종 정책 확정 전까지는 유연하게 설계한다. (FR-006)

필드형식필수설명
idUUID권한 식별값
storeIdUUID소속 가게
customerIdUUID대상 고객
requesterEmployeeIdUUID열람 요청 디자이너
approvedByEmployeeIdUUID아니오승인 원장님
status열거형REQUESTED, APPROVED, REJECTED, EXPIRED, REVOKED
accessScope열거형TREATMENT_HISTORY_ONLY, CUSTOMER_DETAIL_AND_HISTORY
reason문자열대체 시술 사유
startsAt일시아니오열람 시작 시각
expiresAt일시아니오열람 만료 시각
createdAt일시요청 일시
updatedAt일시처리 일시

인덱스

  • requesterEmployeeId + status + expiresAt
  • customerId + status + expiresAt

4.15 AuditLog — 조회·수정·내보내기·권한 변경 이력

개인정보가 포함된 고객 정보의 열람, 수정, 내보내기와 권한 변경을 기록한다. 이력은 수정·삭제하지 않는다. (FR-028, NFR-003, NFR-009)

필드형식필수설명
idUUID이력 식별값
storeIdUUID대상 가게
actorEmployeeIdUUID아니오수행 직원
action열거형VIEW, CREATE, UPDATE, EXPORT, ROLE_CHANGE, MERGE, MERGE_REVERSE, DELETE, LOGIN
targetType문자열대상 종류. 예: Customer
targetIdUUID아니오대상 식별값
metadataJSON아니오변경 필드명, 내보내기 조건 등 최소 메타데이터
ipAddress문자열아니오접속 IP
userAgent문자열아니오브라우저 정보
createdAt일시수행 시각

인덱스

  • storeId + createdAt DESC
  • actorEmployeeId + createdAt DESC
  • targetType + targetId + createdAt DESC
  • action + createdAt DESC

전화번호, 시술 메모 전문, 사진 파일 경로 같은 민감한 원문은 metadata에 복제하지 않는다.


5. 고객 단계 계산 규칙

고객 단계는 사용자가 직접 수정하는 값이 아니라 예약 확인 건과 시술 기록을 기준으로 자동 계산한다. 계산 결과는 Customer.stage에 저장해 목록 조회 성능을 확보한다. (FR-019, FR-020, NFR-007)

그림을 그리는 중…

고객의 미래 예약, 시술 기록, 방문 결과가 변경되면 고객 단계를 다시 계산한다. 뜸해짐은 마지막 시술일로부터 달력 기준 2개월이 지나고 유효한 미래 예약이 없을 때 적용한다.

다음 예약 잡힘재방문 고객이 동시에 가능한 경우에는 유효한 다음 예약이 있는 상태를 우선 표시한다. 이는 고객이 현재 처리해야 할 예약 상태를 먼저 보여주기 위한 계산 규칙이다. 이 우선순위가 사용자 의도와 다르면 확인이 필요하다. (FR-019)


6. 알림톡 발송 데이터 흐름

그림을 그리는 중…

전날 예약 확인은 방문 전날 오후 6시에, 당일 예약 안내는 방문 3시간 전에 실행한다. 각각의 발송 결과는 독립된 운영 메시지로 저장한다. (FR-012, FR-013, FR-014)

자동 발송은 고객의 현재 전화번호가 아니라 예약 등록 당시 저장한 예약 연락처 스냅샷을 사용한다. 예약 후 고객 정보가 바뀌어도 이미 예약된 안내의 발송 근거를 유지하기 위해서다. (FR-010, FR-012, FR-013)


7. 권한 적용 기준

7.1 서버 조회 조건

사용자기본 조회 범위관련 요구사항
원장님같은 가게의 전체 고객, 예약 확인 건, 시술 기록, 사진, 병합 이력, 활동 이력FR-005
디자이너기본적으로 자기 담당 고객, 자기 담당 예약 확인 건, 해당 고객의 시술 기록FR-005
임시 열람이 승인된 디자이너승인된 시간과 범위 안에서 타 담당 고객의 시술 이력 또는 고객 상세FR-006

모든 서버 요청에는 storeId 조건을 필수로 넣는다. 디자이너의 경우 기본적으로 assignedDesignerId = 로그인 직원 ID 조건을 함께 적용한다. 화면에서 숨기는 것만으로 권한을 처리하지 않는다. (FR-005, NFR-002, NFR-003)

7.2 시술 사진 접근

시술 사진은 다음 조건을 모두 만족할 때만 표시한다.

  1. 사용자에게 해당 고객 또는 임시 열람 권한이 있다.
  2. 사진 촬영·보관 동의가 유효하다.
  3. 사진 보유기간이 지나지 않았다.
  4. 사진이 삭제 처리되지 않았다.

(FR-009, FR-027)


8. 운영 지표 계산용 데이터

지표계산에 쓰는 데이터계산 기준관련 요구사항
노쇼율ReservationConfirmation.visitResult노쇼 / (시술 완료 + 노쇼)FR-029
예약 확인 안내 발송률OperationalMessage발송 대상 중 예정된 안내가 성공한 비율FR-012, FR-013, FR-029
두 달 내 재방문율TreatmentRecord.treatmentAt이전 시술일 후 2개월 안에 다음 시술 완료한 고객 비율FR-029
시술 기록 완료율예약 확인 건, 시술 기록시술 완료 예약 확인 건 중 시술 종류·시술 메모가 있는 연결 시술 기록 비율FR-017, FR-029
시술 직후 재예약률TreatmentRecord.nextReservationAt전체 시술 완료 기록 중 다음 예약 일시를 함께 기록한 비율FR-018, FR-029

지표를 매번 전체 원본 데이터에서 계산하면 고객 수가 늘어날 때 느려질 수 있다. 첫 버전에서는 조회 시 계산하되, 일별 집계가 필요해지면 별도 집계 테이블을 추가한다. 이 집계 테이블은 첫 버전의 필수 데이터가 아니므로 Prisma 초안에는 포함하지 않는다.


9. Prisma 모델 초안

아래 초안은 PostgreSQL과 Prisma를 기준으로 한다. 부분 유니크 인덱스와 예약 발송 작업의 행 잠금 같은 일부 제약은 Prisma 스키마만으로 완전히 표현하기 어려우므로 마이그레이션 SQL과 서버 트랜잭션으로 추가 구현한다. (NFR-004, NFR-005)

enum EmployeeRole {
  OWNER
  DESIGNER
}

enum LoginMethod {
  EMAIL_PASSWORD
  MAGIC_LINK
  OTHER
}

enum CustomerStatus {
  ACTIVE
  MERGED
  ANONYMIZED
  DELETED
}

enum PhoneValidationStatus {
  VALID
  REVIEW_NEEDED
  MISSING
}

enum CustomerStage {
  INQUIRING
  RESERVATION_BOOKED
  TREATMENT_COMPLETED
  NEXT_RESERVATION_BOOKED
  RETURNING_CUSTOMER
  DORMANT
}

enum ConsentType {
  PERSONAL
  MARKETING
  PHOTO
}

enum ConsentStatus {
  AGREED
  REFUSED
  UNKNOWN
}

enum ConsentSource {
  STORE
  PHONE
  NAVER_RESERVATION
  KAKAO_CHANNEL
  IMPORT
  OTHER
}

enum Touchpoint {
  NAVER_RESERVATION
  NAVER_TALKTALK
  KAKAO_CHANNEL
  PERSONAL_KAKAO
  INSTAGRAM_SNS
  PHONE
  STORE_VISIT
  OTHER
}

enum CustomerConfirmationStatus {
  PENDING
  CONFIRMED
  UNREACHABLE
  CHANGED
}

enum VisitResult {
  NOT_RECORDED
  TREATMENT_COMPLETED
  NO_SHOW
  CUSTOMER_CANCELED
  STORE_CANCELED
}

enum OperationalMessageType {
  DAY_BEFORE_CONFIRMATION
  SAME_DAY_REMINDER
}

enum OperationalMessageStatus {
  SCHEDULED
  PROCESSING
  SUCCESS
  FAILED
  AUTO_SEND_STOPPED
  MANUAL_CONTACT_COMPLETED
}

enum ContactChannel {
  PHONE
  KAKAO_CHANNEL
  PERSONAL_KAKAO
  NAVER_TALKTALK
  INSTAGRAM_SNS
  OTHER
}

enum ContactResult {
  CONFIRMED
  NO_RESPONSE
  CANCELED
  RESCHEDULE_REQUESTED
  VISIT_COMPLETED
  DECLINED
  OTHER
}

enum InternalTaskType {
  TREATMENT_RECORD_REQUIRED
}

enum InternalTaskStatus {
  OPEN
  COMPLETED
  CANCELED
}

enum ImportSourceType {
  CSV
  MANUAL_NOTE
}

enum ImportJobStatus {
  CREATED
  VALIDATING
  REVIEW_NEEDED
  COMPLETED
  FAILED
}

enum ImportErrorRowStatus {
  REVIEW_NEEDED
  CORRECTED
  REPROCESSED
  SKIPPED
}

enum MergeStatus {
  COMPLETED
  REVERSED
  REVIEW_NEEDED
}

enum TemporaryAccessStatus {
  REQUESTED
  APPROVED
  REJECTED
  EXPIRED
  REVOKED
}

enum TemporaryAccessScope {
  TREATMENT_HISTORY_ONLY
  CUSTOMER_DETAIL_AND_HISTORY
}

enum AuditAction {
  VIEW
  CREATE
  UPDATE
  EXPORT
  ROLE_CHANGE
  MERGE
  MERGE_REVERSE
  DELETE
  LOGIN
}

model Store {
  id                 String     @id @default(uuid())
  name               String
  timezone           String     @default("Asia/Seoul")
  isActive           Boolean    @default(true)
  createdAt          DateTime   @default(now())
  updatedAt          DateTime   @updatedAt

  employees          Employee[]
  customers          Customer[]
  reservations       ReservationConfirmation[]
  treatmentRecords   TreatmentRecord[]
  importJobs         ImportJob[]
  mergeHistories     MergeHistory[]
  temporaryAccesses  TemporaryAccessGrant[]
  auditLogs          AuditLog[]
}

model Employee {
  id                 String     @id @default(uuid())
  storeId            String
  name               String
  role               EmployeeRole
  loginIdentifier    String     @unique
  loginMethod        LoginMethod
  isActive           Boolean    @default(true)
  lastRoleChangedAt  DateTime?
  createdAt          DateTime   @default(now())
  updatedAt          DateTime   @updatedAt

  store              Store      @relation(fields: [storeId], references: [id])
  assignedCustomers  Customer[] @relation("AssignedDesigner")
  reservations       ReservationConfirmation[] @relation("ReservationDesigner")
  treatmentRecords   TreatmentRecord[] @relation("TreatmentDesigner")
  createdTreatments  TreatmentRecord[] @relation("TreatmentCreatedBy")
  updatedTreatments  TreatmentRecord[] @relation("TreatmentUpdatedBy")
  internalTasks      InternalTask[] @relation("TaskAssignee")
  auditLogs          AuditLog[]
  accessRequests     TemporaryAccessGrant[] @relation("AccessRequester")
  accessApprovals    TemporaryAccessGrant[] @relation("AccessApprover")

  @@index([storeId, role, isActive])
}

model Customer {
  id                      String                @id @default(uuid())
  storeId                 String
  name                    String
  phoneRaw                String
  phoneNormalized         String?
  phoneDisplay            String?
  phoneValidationStatus   PhoneValidationStatus @default(REVIEW_NEEDED)
  assignedDesignerId      String?
  stage                   CustomerStage         @default(INQUIRING)
  status                  CustomerStatus        @default(ACTIVE)
  mergedIntoCustomerId    String?
  personalConsentStatus   ConsentStatus         @default(UNKNOWN)
  marketingConsentStatus  ConsentStatus         @default(UNKNOWN)
  photoConsentStatus      ConsentStatus         @default(UNKNOWN)
  photoRetentionUntil     DateTime?
  lastTreatmentAt         DateTime?
  retentionUntil          DateTime?
  createdAt               DateTime              @default(now())
  updatedAt               DateTime              @updatedAt
  version                 Int                   @default(1)

  store                   Store                 @relation(fields: [storeId], references: [id])
  assignedDesigner        Employee?             @relation("AssignedDesigner", fields: [assignedDesignerId], references: [id])
  mergedIntoCustomer      Customer?             @relation("CustomerMerge", fields: [mergedIntoCustomerId], references: [id])
  mergedCustomers         Customer[]            @relation("CustomerMerge")
  consents                ConsentHistory[]
  reservations            ReservationConfirmation[]
  treatmentRecords        TreatmentRecord[]
  contactLogs             ContactLog[]
  mergeItemsAsSource      MergeItem[]           @relation("MergeSourceCustomer")
  mergeItemsAsTarget      MergeItem[]           @relation("MergeTargetCustomer")
  temporaryAccesses       TemporaryAccessGrant[]

  @@index([storeId, assignedDesignerId, status])
  @@index([storeId, stage, lastTreatmentAt])
  @@index([storeId, name])
  @@index([phoneNormalized])
}

model ConsentHistory {
  id                    String        @id @default(uuid())
  customerId            String
  consentType           ConsentType
  previousStatus        ConsentStatus?
  status                ConsentStatus
  consentedAt           DateTime?
  source                ConsentSource
  changedByEmployeeId   String?
  evidenceNote          String?
  createdAt             DateTime      @default(now())

  customer              Customer      @relation(fields: [customerId], references: [id])
  photoReferences       TreatmentPhoto[]

  @@index([customerId, consentType, createdAt(sort: Desc)])
  @@index([consentType, status])
}

model ReservationConfirmation {
  id                            String                     @id @default(uuid())
  storeId                       String
  customerId                    String
  designerId                    String
  scheduledAt                   DateTime
  expectedTreatment             String?
  touchpoint                    Touchpoint
  customerConfirmationStatus    CustomerConfirmationStatus @default(PENDING)
  visitResult                   VisitResult                @default(NOT_RECORDED)
  contactNameSnapshot           String
  contactPhoneSnapshot          String
  contactPhoneNormalizedSnapshot String?
  contactValidationStatus       PhoneValidationStatus      @default(REVIEW_NEEDED)
  canceledAt                    DateTime?
  createdByEmployeeId           String
  createdAt                     DateTime                   @default(now())
  updatedAt                     DateTime                   @updatedAt
  version                       Int                        @default(1)

  store                         Store                      @relation(fields: [storeId], references: [id])
  customer                      Customer                   @relation(fields: [customerId], references: [id])
  designer                      Employee                   @relation("ReservationDesigner", fields: [designerId], references: [id])
  operationalMessages           OperationalMessage[]
  manualContacts                ManualContact[]
  treatmentRecord               TreatmentRecord?
  internalTasks                 InternalTask[]

  @@index([storeId, scheduledAt])
  @@index([storeId, designerId, scheduledAt])
  @@index([customerId, scheduledAt(sort: Desc)])
  @@index([storeId, visitResult, scheduledAt])
  @@index([storeId, customerId, designerId, scheduledAt])
}

model OperationalMessage {
  id                        String                   @id @default(uuid())
  reservationConfirmationId String
  messageType               OperationalMessageType
  status                    OperationalMessageStatus @default(SCHEDULED)
  scheduledSendAt           DateTime?
  sentAt                    DateTime?
  senderProfileId           String?
  alimtalkTemplateId        String?
  providerRequestId         String?                  @unique
  providerResultId          String?                  @unique
  failureReason             String?
  payloadSnapshot           Json?
  createdAt                 DateTime                 @default(now())
  updatedAt                 DateTime                 @updatedAt

  reservationConfirmation   ReservationConfirmation  @relation(fields: [reservationConfirmationId], references: [id])
  manualContacts            ManualContact[]

  @@index([status, scheduledSendAt])
  @@index([reservationConfirmationId, messageType, createdAt(sort: Desc)])
}

model ManualContact {
  id                        String                    @id @default(uuid())
  reservationConfirmationId String
  operationalMessageId      String?
  contactedAt               DateTime
  channel                   ContactChannel
  result                    ContactResult
  note                      String?
  contactedByEmployeeId     String
  createdAt                 DateTime                  @default(now())

  reservationConfirmation   ReservationConfirmation  @relation(fields: [reservationConfirmationId], references: [id])
  operationalMessage         OperationalMessage?      @relation(fields: [operationalMessageId], references: [id])

  @@index([reservationConfirmationId, contactedAt(sort: Desc)])
}

model TreatmentRecord {
  id                        String                    @id @default(uuid())
  storeId                   String
  customerId                String
  reservationConfirmationId String?                   @unique
  treatmentAt               DateTime
  designerId                String
  treatmentType             String
  treatmentMemo             String
  dyeNumber                 String?
  formula                   String?
  nextReservationAt         DateTime?
  createdByEmployeeId       String
  updatedByEmployeeId       String
  createdAt                 DateTime                  @default(now())
  updatedAt                 DateTime                  @updatedAt
  version                   Int                       @default(1)

  store                     Store                     @relation(fields: [storeId], references: [id])
  customer                  Customer                  @relation(fields: [customerId], references: [id])
  reservationConfirmation   ReservationConfirmation?  @relation(fields: [reservationConfirmationId], references: [id])
  designer                  Employee                  @relation("TreatmentDesigner", fields: [designerId], references: [id])
  createdBy                 Employee                  @relation("TreatmentCreatedBy", fields: [createdByEmployeeId], references: [id])
  updatedBy                 Employee                  @relation("TreatmentUpdatedBy", fields: [updatedByEmployeeId], references: [id])
  photos                    TreatmentPhoto[]

  @@index([storeId, customerId, treatmentAt(sort: Desc)])
  @@index([storeId, designerId, treatmentAt(sort: Desc)])
  @@index([nextReservationAt])
}

model TreatmentPhoto {
  id                    String         @id @default(uuid())
  treatmentRecordId     String
  storageKey            String         @unique
  originalFileName      String
  mimeType              String
  fileSizeBytes         Int
  consentHistoryId      String
  retentionUntil        DateTime
  uploadedByEmployeeId  String
  uploadedAt            DateTime       @default(now())
  deletedAt             DateTime?

  treatmentRecord       TreatmentRecord @relation(fields: [treatmentRecordId], references: [id])
  consentHistory        ConsentHistory  @relation(fields: [consentHistoryId], references: [id])

  @@index([treatmentRecordId])
  @@index([retentionUntil, deletedAt])
}

model InternalTask {
  id                        String             @id @default(uuid())
  storeId                   String
  reservationConfirmationId String
  assigneeEmployeeId        String
  taskType                  InternalTaskType
  status                    InternalTaskStatus @default(OPEN)
  dueAt                     DateTime?
  completedAt               DateTime?
  createdAt                 DateTime           @default(now())
  updatedAt                 DateTime           @updatedAt

  reservationConfirmation   ReservationConfirmation @relation(fields: [reservationConfirmationId], references: [id])
  assignee                  Employee                @relation("TaskAssignee", fields: [assigneeEmployeeId], references: [id])

  @@unique([reservationConfirmationId, taskType])
  @@index([assigneeEmployeeId, status, createdAt(sort: Desc)])
}

model ContactLog {
  id                        String         @id @default(uuid())
  storeId                   String
  customerId                String
  contactedAt               DateTime
  channel                   ContactChannel
  result                    ContactResult
  marketingConsentSnapshot  ConsentStatus
  note                      String?
  createdByEmployeeId       String
  createdAt                 DateTime       @default(now())

  customer                  Customer       @relation(fields: [customerId], references: [id])

  @@index([storeId, customerId, contactedAt(sort: Desc)])
  @@index([storeId, createdByEmployeeId, contactedAt(sort: Desc)])
}

model ImportJob {
  id                   String          @id @default(uuid())
  storeId              String
  sourceType           ImportSourceType
  fileName             String?
  fileStorageKey       String?
  status               ImportJobStatus @default(CREATED)
  totalRowCount        Int             @default(0)
  successRowCount      Int             @default(0)
  errorRowCount        Int             @default(0)
  createdByEmployeeId  String
  startedAt            DateTime?
  completedAt          DateTime?
  createdAt            DateTime        @default(now())

  store                Store           @relation(fields: [storeId], references: [id])
  errorRows            ImportErrorRow[]

  @@index([storeId, createdAt(sort: Desc)])
}

model ImportErrorRow {
  id                   String               @id @default(uuid())
  importJobId          String
  rowNumber            Int
  rawData              Json
  errorCode            String
  errorMessage         String
  status               ImportErrorRowStatus @default(REVIEW_NEEDED)
  correctedData        Json?
  reprocessedAt        DateTime?
  createdAt            DateTime             @default(now())
  updatedAt            DateTime             @updatedAt

  importJob            ImportJob            @relation(fields: [importJobId], references: [id])

  @@index([importJobId, status, rowNumber])
}

model MergeHistory {
  id                       String      @id @default(uuid())
  storeId                  String
  representativeCustomerId String
  status                   MergeStatus @default(COMPLETED)
  conflictResolution       Json
  executedByEmployeeId     String
  executedAt               DateTime    @default(now())
  reversedByEmployeeId     String?
  reversedAt               DateTime?
  reverseReason            String?
  createdAt                DateTime    @default(now())

  store                    Store       @relation(fields: [storeId], references: [id])
  items                    MergeItem[]

  @@index([storeId, representativeCustomerId, executedAt(sort: Desc)])
}

model MergeItem {
  id                       String       @id @default(uuid())
  mergeHistoryId           String
  sourceCustomerId         String
  targetCustomerId         String
  movedTreatmentRecordIds  Json
  movedReservationIds      Json
  reviewNeededRecordIds    Json
  createdAt                DateTime     @default(now())

  mergeHistory             MergeHistory @relation(fields: [mergeHistoryId], references: [id])
  sourceCustomer           Customer     @relation("MergeSourceCustomer", fields: [sourceCustomerId], references: [id])
  targetCustomer           Customer     @relation("MergeTargetCustomer", fields: [targetCustomerId], references: [id])

  @@index([mergeHistoryId])
  @@index([sourceCustomerId])
}

model TemporaryAccessGrant {
  id                    String                @id @default(uuid())
  storeId               String
  customerId            String
  requesterEmployeeId   String
  approvedByEmployeeId  String?
  status                TemporaryAccessStatus @default(REQUESTED)
  accessScope           TemporaryAccessScope
  reason                String
  startsAt              DateTime?
  expiresAt             DateTime?
  createdAt             DateTime              @default(now())
  updatedAt             DateTime              @updatedAt

  store                 Store                 @relation(fields: [storeId], references: [id])
  customer              Customer              @relation(fields: [customerId], references: [id])
  requester             Employee              @relation("AccessRequester", fields: [requesterEmployeeId], references: [id])
  approvedBy            Employee?             @relation("AccessApprover", fields: [approvedByEmployeeId], references: [id])

  @@index([requesterEmployeeId, status, expiresAt])
  @@index([customerId, status, expiresAt])
}

model AuditLog {
  id                String      @id @default(uuid())
  storeId           String
  actorEmployeeId   String?
  action            AuditAction
  targetType        String
  targetId          String?
  metadata          Json?
  ipAddress         String?
  userAgent         String?
  createdAt         DateTime    @default(now())

  store             Store       @relation(fields: [storeId], references: [id])
  actor             Employee?   @relation(fields: [actorEmployeeId], references: [id])

  @@index([storeId, createdAt(sort: Desc)])
  @@index([actorEmployeeId, createdAt(sort: Desc)])
  @@index([targetType, targetId, createdAt(sort: Desc)])
  @@index([action, createdAt(sort: Desc)])
}

10. 구현 시 반드시 추가할 데이터베이스 규칙

Prisma 모델 외에 다음 규칙은 데이터베이스 마이그레이션과 서버 트랜잭션으로 적용한다.

규칙구현 기준관련 요구사항
활성 고객 전화번호 중복 방지storeId, phoneNormalized가 같고 고객 상태가 ACTIVE인 행은 하나만 허용하는 부분 유니크 인덱스FR-003, FR-004
가게 간 연결 차단예약 확인 건, 시술 기록, 담당 디자이너, 고객을 연결할 때 모두 같은 storeId인지 서버 트랜잭션에서 검증FR-005, FR-008, FR-010
운영 메시지 중복 발송 방지발송 직전 대상 운영 메시지를 잠그고, 같은 예약 확인 건·메시지 종류의 성공 발송이 있는지 다시 확인FR-012~FR-014, NFR-004
예약 변경 시 발송 일정 재계산아직 성공하지 않은 운영 메시지만 취소 또는 새 일정으로 변경FR-014
병합 원자성고객 상태 변경, 시술 기록 이동, 예약 확인 건 이동, 병합 이력을 하나의 트랜잭션으로 처리FR-024, NFR-005
병합 취소 검증병합 뒤 새로 생성된 기록은 자동 복구하지 않고 검토 필요로 남김FR-025
동시 수정 방지Customer, ReservationConfirmation, TreatmentRecordversion을 저장 시 비교FR-008, NFR-010
사진 접근 제한파일 저장소는 비공개로 두고, 권한 확인 뒤 짧은 만료 시간의 접근 URL만 발급FR-009, NFR-003
보유기간 처리매일 보유기간 종료 고객과 사진을 찾아 삭제 또는 알아볼 수 없게 처리할 대상 목록 생성FR-027

이 구조는 온헤어가 고객의 시술 이력과 염색약 번호를 한곳에서 확인하고, 예약 알림톡·노쇼 관리·뜸해진 고객 연락 관리를 운영하는 데 필요한 데이터만 포함한다.

데이터 구조 — 동네 헤어샵의 시술 이력과 예약 확인 | Prometheon