온헤어 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)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 가게 식별값 |
name | 문자열 | 예 | 가게명. 예: 온헤어 |
timezone | 문자열 | 예 | 기본값 Asia/Seoul |
isActive | 불리언 | 예 | 가게 운영 상태 |
createdAt | 일시 | 예 | 생성 일시 |
updatedAt | 일시 | 예 | 수정 일시 |
인덱스
isActive- 모든 주요 테이블의
storeId외래 키 인덱스
4.2 Employee — 직원 계정
원장님과 디자이너의 로그인, 역할, 계정 활성 상태를 관리한다. 계정이 비활성 상태면 로그인과 데이터 접근을 막는다. (FR-001, FR-002, FR-005)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 직원 식별값 |
storeId | UUID | 예 | 소속 가게 |
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)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 고객 식별값 |
storeId | UUID | 예 | 소속 가게 |
name | 문자열 | 예 | 고객 이름 |
phoneRaw | 문자열 | 예 | 입력받은 원본 휴대전화 번호 |
phoneNormalized | 문자열 | 아니오 | 중복 판정과 검색용 정규화 번호 |
phoneDisplay | 문자열 | 아니오 | 화면 표시용 번호 |
phoneValidationStatus | 열거형 | 예 | VALID, REVIEW_NEEDED, MISSING |
assignedDesignerId | UUID | 아니오 | 담당 디자이너 |
stage | 열거형 | 예 | 자동 계산된 고객 단계 |
status | 열거형 | 예 | ACTIVE, MERGED, ANONYMIZED, DELETED |
mergedIntoCustomerId | UUID | 아니오 | 병합된 경우 대표 고객 |
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 + phoneNormalized는status = 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)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 이력 식별값 |
customerId | UUID | 예 | 대상 고객 |
consentType | 열거형 | 예 | PERSONAL, MARKETING, PHOTO |
previousStatus | 열거형 | 아니오 | 변경 전 상태 |
status | 열거형 | 예 | AGREED, REFUSED, UNKNOWN |
consentedAt | 일시 | 아니오 | 동의 또는 거부 확인 일시 |
source | 열거형 | 예 | STORE, PHONE, NAVER_RESERVATION, KAKAO_CHANNEL, IMPORT, OTHER |
changedByEmployeeId | UUID | 아니오 | 변경한 직원 |
evidenceNote | 문자열 | 아니오 | 기존 자료에서 확인한 근거나 메모 |
createdAt | 일시 | 예 | 생성 일시 |
인덱스
customerId + consentType + createdAt DESCconsentType + status
4.5 ReservationConfirmation — 예약 확인 건
네이버 예약을 대체하지 않는다. 예약 확인, 알림톡, 방문 결과, 시술 기록 연결에 필요한 최소 정보를 저장한다. (FR-010~FR-018)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 예약 확인 건 식별값 |
storeId | UUID | 예 | 소속 가게 |
customerId | UUID | 예 | 연결 고객 |
designerId | UUID | 예 | 방문 예정 담당 디자이너 |
scheduledAt | 일시 | 예 | 방문 예정 일시 |
expectedTreatment | 문자열 | 아니오 | 예정 시술 종류 |
touchpoint | 열거형 | 예 | 예약·방문 접점 |
customerConfirmationStatus | 열거형 | 예 | 고객 확인 상태 |
visitResult | 열거형 | 예 | 방문 결과 |
contactNameSnapshot | 문자열 | 예 | 예약 등록 당시 고객명 |
contactPhoneSnapshot | 문자열 | 예 | 예약 등록 당시 휴대전화 번호 |
contactPhoneNormalizedSnapshot | 문자열 | 아니오 | 예약 발송 대상 판정용 정규화 번호 |
contactValidationStatus | 열거형 | 예 | 예약 연락처 검증 상태 |
canceledAt | 일시 | 아니오 | 취소 처리 일시 |
createdByEmployeeId | UUID | 예 | 등록자 |
createdAt | 일시 | 예 | 생성 일시 |
updatedAt | 일시 | 예 | 수정 일시 |
version | 정수 | 예 | 동시 수정 충돌 방지용 버전 |
예약 확인 건 상태 값
| 필드 | 값 | 화면 표시 | 관련 요구사항 |
|---|---|---|---|
customerConfirmationStatus | PENDING | 확인 전 | FR-010, FR-011 |
CONFIRMED | 고객 확인 | FR-010, FR-011 | |
UNREACHABLE | 연락 닿지 않음 | FR-010, FR-016 | |
CHANGED | 변경 요청 | FR-010, FR-011 | |
visitResult | NOT_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 + scheduledAtstoreId + designerId + scheduledAtcustomerId + scheduledAt DESCstoreId + visitResult + scheduledAt- 중복 후보 경고용:
storeId + customerId + designerId + scheduledAt
4.6 OperationalMessage — 운영 메시지
전날 오후 6시 예약 확인과 방문 3시간 전 당일 안내의 생성, 발송 예약, 요청, 결과 수신을 관리한다. 예약 확인 건 하나에는 안내 종류별로 여러 발송 이력이 생길 수 있으나, 같은 안내의 성공 발송은 한 번만 허용한다. (FR-012, FR-013, FR-014, FR-015)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 운영 메시지 식별값 |
reservationConfirmationId | UUID | 예 | 대상 예약 확인 건 |
messageType | 열거형 | 예 | DAY_BEFORE_CONFIRMATION, SAME_DAY_REMINDER |
status | 열거형 | 예 | 발송 상태 |
scheduledSendAt | 일시 | 아니오 | 자동 발송 예정 시각 |
sentAt | 일시 | 아니오 | 실제 발송 요청 일시 |
senderProfileId | UUID | 아니오 | 사용한 발신 프로필 |
alimtalkTemplateId | UUID | 아니오 | 사용한 템플릿 |
providerRequestId | 문자열 | 아니오 | 알림톡 발송 요청 식별값 |
providerResultId | 문자열 | 아니오 | 알림톡 결과 식별값 |
failureReason | 문자열 | 아니오 | 실패 또는 자동 발송 중지 이유 |
payloadSnapshot | JSON | 아니오 | 발송 당시 적용된 예약 정보 |
createdAt | 일시 | 예 | 생성 일시 |
updatedAt | 일시 | 예 | 수정 일시 |
운영 메시지 상태
| 값 | 화면 표시 | 의미 |
|---|---|---|
SCHEDULED | 발송 예정 | 자동 발송 대기 |
PROCESSING | 처리 중 | 알림톡 사업자 결과 대기 |
SUCCESS | 성공 | 정상 발송 결과 확인 |
FAILED | 실패 | 발송 실패 |
AUTO_SEND_STOPPED | 자동 발송 중지 | 번호·템플릿·발신 프로필 등의 문제로 자동 발송 중지 |
MANUAL_CONTACT_COMPLETED | 수동 연락 완료 | 실패 뒤 수동 연락 처리가 완료됨 |
인덱스와 중복 방지
status + scheduledSendAt: 예약 발송 작업 조회reservationConfirmationId + messageType + createdAt DESCproviderRequestId유니크providerResultId유니크- 성공 상태 메시지는 예약 확인 건과 메시지 종류 기준으로 하나만 허용한다. (NFR-004)
4.7 ManualContact — 수동 연락
알림톡이 실패했거나 자동 발송이 중지된 예약 확인 건에 대해 전화, 카카오톡 채널 등으로 직접 연락한 결과를 남긴다. (FR-016)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 수동 연락 식별값 |
reservationConfirmationId | UUID | 예 | 대상 예약 확인 건 |
operationalMessageId | UUID | 아니오 | 연결된 실패 운영 메시지 |
contactedAt | 일시 | 예 | 연락 일시 |
channel | 열거형 | 예 | PHONE, KAKAO_CHANNEL, PERSONAL_KAKAO, NAVER_TALKTALK, OTHER |
result | 열거형 | 예 | 연락 결과 |
note | 문자열 | 아니오 | 필요한 운영 메모 |
contactedByEmployeeId | UUID | 예 | 연락한 직원 |
createdAt | 일시 | 예 | 생성 일시 |
4.8 TreatmentRecord — 시술 기록
실제 시술 정보와 다음 예약을 관리한다. 시술 완료된 예약 확인 건에는 시술 기록이 하나만 연결될 수 있다. (FR-007, FR-008, FR-017, FR-018)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 시술 기록 식별값 |
storeId | UUID | 예 | 소속 가게 |
customerId | UUID | 예 | 대상 고객 |
reservationConfirmationId | UUID | 아니오 | 연결 예약 확인 건 |
treatmentAt | 일시 | 예 | 실제 시술 일시 |
designerId | UUID | 예 | 실제 시술 담당 디자이너 |
treatmentType | 문자열 | 예 | 실제 시술 종류 |
treatmentMemo | 문자열 | 예 | 시술 메모 |
dyeNumber | 문자열 | 아니오 | 염색약 번호 |
formula | 문자열 | 아니오 | 배합 |
nextReservationAt | 일시 | 아니오 | 시술 직후 등록한 다음 예약 일시 |
createdByEmployeeId | UUID | 예 | 작성자 |
updatedByEmployeeId | UUID | 예 | 마지막 수정자 |
createdAt | 일시 | 예 | 작성 일시 |
updatedAt | 일시 | 예 | 수정 일시 |
version | 정수 | 예 | 동시 수정 충돌 방지용 버전 |
제약과 인덱스
reservationConfirmationId는 값이 있을 경우 유일하다.storeId + customerId + treatmentAt DESCstoreId + designerId + treatmentAt DESCreservationConfirmationIdnextReservationAt
시술 기록 저장 시
treatmentType과treatmentMemo는 필수다. 이 기록이 저장되면 연결된 시술 기록 미작성 내부 할 일을 완료한다. (FR-008, FR-017)
4.9 TreatmentPhoto — 시술 사진
사진 파일 자체는 비공개 객체 저장소에 두고, 데이터베이스에는 파일 식별값과 접근·보유 근거만 저장한다. (FR-009, FR-027, NFR-003)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 사진 식별값 |
treatmentRecordId | UUID | 예 | 연결 시술 기록 |
storageKey | 문자열 | 예 | 비공개 파일 저장 경로 |
originalFileName | 문자열 | 예 | 원본 파일명 |
mimeType | 문자열 | 예 | 파일 형식 |
fileSizeBytes | 정수 | 예 | 파일 크기 |
consentHistoryId | UUID | 예 | 사진 촬영·보관 동의 근거 |
retentionUntil | 일시 | 예 | 사진 보유 종료 일시 |
uploadedByEmployeeId | UUID | 예 | 업로드 직원 |
uploadedAt | 일시 | 예 | 업로드 시각 |
deletedAt | 일시 | 아니오 | 보유기간 종료 또는 삭제 처리 일시 |
인덱스
treatmentRecordIdretentionUntil + deletedAt
4.10 InternalTask — 시술 기록 미작성 내부 할 일
오늘 예약의 방문 결과가 시술 완료인데 시술 기록이 없으면 담당 디자이너에게 표시한다. (FR-017)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 할 일 식별값 |
storeId | UUID | 예 | 소속 가게 |
reservationConfirmationId | UUID | 예 | 시술 완료 예약 확인 건 |
assigneeEmployeeId | UUID | 예 | 담당 디자이너 |
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)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 연락 기록 식별값 |
storeId | UUID | 예 | 소속 가게 |
customerId | UUID | 예 | 대상 고객 |
contactedAt | 일시 | 예 | 연락 일시 |
channel | 열거형 | 예 | 연락 채널 |
result | 열거형 | 예 | 연락 결과 |
marketingConsentSnapshot | 열거형 | 예 | 연락 당시 마케팅 수신동의 |
note | 문자열 | 아니오 | 연락 메모 |
createdByEmployeeId | UUID | 예 | 기록한 직원 |
createdAt | 일시 | 예 | 생성 일시 |
인덱스
storeId + customerId + contactedAt DESCstoreId + createdByEmployeeId + contactedAt DESC
재방문 광고 자동 발송은 첫 개발 범위가 아니다. 이 테이블은 디자이너가 직접 연락한 결과를 남기는 용도다. (FR-021)
4.12 ImportJob, ImportErrorRow — 자료 가져오기와 오류 행
CSV 파일과 수첩 자료를 바로 고객 데이터에 반영하지 않고, 먼저 검증한 뒤 반영한다. 원본 행 번호와 오류 이유를 보관해 수정·재처리할 수 있다. (FR-022, FR-023, NFR-006)
ImportJob
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 자료 가져오기 식별값 |
storeId | UUID | 예 | 대상 가게 |
sourceType | 열거형 | 예 | CSV, MANUAL_NOTE |
fileName | 문자열 | 아니오 | CSV 파일명 |
fileStorageKey | 문자열 | 아니오 | 원본 파일 보관 경로 |
status | 열거형 | 예 | 작업 상태 |
totalRowCount | 정수 | 예 | 전체 행 수 |
successRowCount | 정수 | 예 | 정상 반영 행 수 |
errorRowCount | 정수 | 예 | 오류 행 수 |
createdByEmployeeId | UUID | 예 | 작업 실행 원장님 |
startedAt | 일시 | 아니오 | 처리 시작 일시 |
completedAt | 일시 | 아니오 | 처리 완료 일시 |
createdAt | 일시 | 예 | 생성 일시 |
ImportErrorRow
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 오류 행 식별값 |
importJobId | UUID | 예 | 자료 가져오기 작업 |
rowNumber | 정수 | 예 | 원본 CSV 행 번호 |
rawData | JSON | 예 | 원본 행 데이터 |
errorCode | 문자열 | 예 | 오류 코드 |
errorMessage | 문자열 | 예 | 오류 설명 |
status | 열거형 | 예 | REVIEW_NEEDED, CORRECTED, REPROCESSED, SKIPPED |
correctedData | JSON | 아니오 | 화면에서 수정한 데이터 |
reprocessedAt | 일시 | 아니오 | 재처리 일시 |
createdAt | 일시 | 예 | 생성 일시 |
updatedAt | 일시 | 예 | 수정 일시 |
인덱스
ImportJob:storeId + createdAt DESCImportErrorRow:importJobId + status + rowNumber
4.13 MergeHistory, MergeItem — 병합 이력과 병합 취소
고객 병합은 고객, 시술 기록, 예약 확인 건 등의 연결을 대표 고객으로 옮기는 작업이다. 병합 취소를 위해 변경 전·후 연결 정보를 보관한다. (FR-024, FR-025, NFR-005)
MergeHistory
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 병합 작업 식별값 |
storeId | UUID | 예 | 대상 가게 |
representativeCustomerId | UUID | 예 | 대표 고객 |
status | 열거형 | 예 | COMPLETED, REVERSED, REVIEW_NEEDED |
conflictResolution | JSON | 예 | 이름, 담당 디자이너, 동의 상태 충돌 처리 내용 |
executedByEmployeeId | UUID | 예 | 병합 실행 원장님 |
executedAt | 일시 | 예 | 병합 실행 일시 |
reversedByEmployeeId | UUID | 아니오 | 병합 취소 실행 원장님 |
reversedAt | 일시 | 아니오 | 병합 취소 일시 |
reverseReason | 문자열 | 아니오 | 병합 취소 사유 |
createdAt | 일시 | 예 | 생성 일시 |
MergeItem
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 병합 항목 식별값 |
mergeHistoryId | UUID | 예 | 병합 작업 |
sourceCustomerId | UUID | 예 | 통합 대상 고객 |
targetCustomerId | UUID | 예 | 대표 고객 |
movedTreatmentRecordIds | JSON | 예 | 이동한 시술 기록 식별값 목록 |
movedReservationIds | JSON | 예 | 이동한 예약 확인 건 식별값 목록 |
reviewNeededRecordIds | JSON | 예 | 자동 분리할 수 없어 검토가 필요한 기록 목록 |
createdAt | 일시 | 예 | 생성 일시 |
제약과 인덱스
MergeHistory:storeId + representativeCustomerId + executedAt DESCMergeItem:mergeHistoryId,sourceCustomerId- 병합 취소 전, 병합 후 새로 만들어진 기록이 있으면
reviewNeededRecordIds에 넣고 원장님이 처리하도록 한다. (FR-025)
4.14 TemporaryAccessGrant — 임시 열람 권한
다른 담당 디자이너의 고객을 대체 시술해야 할 때 필요한 최소 정보 열람 근거를 저장한다. 최종 정책 확정 전까지는 유연하게 설계한다. (FR-006)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 권한 식별값 |
storeId | UUID | 예 | 소속 가게 |
customerId | UUID | 예 | 대상 고객 |
requesterEmployeeId | UUID | 예 | 열람 요청 디자이너 |
approvedByEmployeeId | UUID | 아니오 | 승인 원장님 |
status | 열거형 | 예 | REQUESTED, APPROVED, REJECTED, EXPIRED, REVOKED |
accessScope | 열거형 | 예 | TREATMENT_HISTORY_ONLY, CUSTOMER_DETAIL_AND_HISTORY |
reason | 문자열 | 예 | 대체 시술 사유 |
startsAt | 일시 | 아니오 | 열람 시작 시각 |
expiresAt | 일시 | 아니오 | 열람 만료 시각 |
createdAt | 일시 | 예 | 요청 일시 |
updatedAt | 일시 | 예 | 처리 일시 |
인덱스
requesterEmployeeId + status + expiresAtcustomerId + status + expiresAt
4.15 AuditLog — 조회·수정·내보내기·권한 변경 이력
개인정보가 포함된 고객 정보의 열람, 수정, 내보내기와 권한 변경을 기록한다. 이력은 수정·삭제하지 않는다. (FR-028, NFR-003, NFR-009)
| 필드 | 형식 | 필수 | 설명 |
|---|---|---|---|
id | UUID | 예 | 이력 식별값 |
storeId | UUID | 예 | 대상 가게 |
actorEmployeeId | UUID | 아니오 | 수행 직원 |
action | 열거형 | 예 | VIEW, CREATE, UPDATE, EXPORT, ROLE_CHANGE, MERGE, MERGE_REVERSE, DELETE, LOGIN |
targetType | 문자열 | 예 | 대상 종류. 예: Customer |
targetId | UUID | 아니오 | 대상 식별값 |
metadata | JSON | 아니오 | 변경 필드명, 내보내기 조건 등 최소 메타데이터 |
ipAddress | 문자열 | 아니오 | 접속 IP |
userAgent | 문자열 | 아니오 | 브라우저 정보 |
createdAt | 일시 | 예 | 수행 시각 |
인덱스
storeId + createdAt DESCactorEmployeeId + createdAt DESCtargetType + targetId + createdAt DESCaction + 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 시술 사진 접근
시술 사진은 다음 조건을 모두 만족할 때만 표시한다.
- 사용자에게 해당 고객 또는 임시 열람 권한이 있다.
- 사진 촬영·보관 동의가 유효하다.
- 사진 보유기간이 지나지 않았다.
- 사진이 삭제 처리되지 않았다.
(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, TreatmentRecord의 version을 저장 시 비교 | FR-008, NFR-010 |
| 사진 접근 제한 | 파일 저장소는 비공개로 두고, 권한 확인 뒤 짧은 만료 시간의 접근 URL만 발급 | FR-009, NFR-003 |
| 보유기간 처리 | 매일 보유기간 종료 고객과 사진을 찾아 삭제 또는 알아볼 수 없게 처리할 대상 목록 생성 | FR-027 |
이 구조는 온헤어가 고객의 시술 이력과 염색약 번호를 한곳에서 확인하고, 예약 알림톡·노쇼 관리·뜸해진 고객 연락 관리를 운영하는 데 필요한 데이터만 포함한다.