온헤어 CRM 기능 연결 방식(API) 문서
공통 규칙
- 기준 시간대:
Asia/Seoul
- 웹 클라이언트: Next.js
- 인증 방식: 로그인 후 발급된 세션 쿠키 사용
- 모든 데이터는 로그인한 직원의 가게 범위에서만 조회·수정한다. (FR-001, FR-005, NFR-002)
- 원장님 전용 기능은 서버에서 역할을 다시 확인한다. 화면 숨김만으로 권한을 처리하지 않는다. (FR-002, FR-005, NFR-003)
- 날짜·시간 값은 ISO 8601 형식으로 전달한다. 예:
2026-09-16T18:00:00+09:00
- 목록 조회 기본 응답 형식:
{
"items": [],
"page": 1,
"pageSize": 20,
"total": 0
}
{
"error": {
"code": "CUSTOMER_NOT_FOUND",
"message": "고객을 찾을 수 없습니다.",
"field": "customerId"
}
}
1. 로그인과 가게 확인
POST /api/auth/login
- 목적: 활성 상태인 원장님 또는 디자이너를 로그인하고, 소속 가게와 역할 범위의 세션을 만든다. (FR-001, UIR-001)
- 인증: 필요 없음
- 요청값:
{
"loginId": "designer@example.com",
"password": "비밀번호"
}
{
"user": {
"id": "staff_123",
"name": "김디자이너",
"role": "designer",
"storeId": "store_001",
"storeName": "온헤어"
},
"redirectTo": "/reservations/today-tomorrow"
}
- 사용자에게 표시할 오류 상황:
- 로그인 정보가 맞지 않음: “로그인 정보를 다시 확인해 주세요.”
- 계정이 차단됨: “사용이 중지된 계정입니다. 원장님에게 문의해 주세요.”
- 소속 가게가 없거나 가게가 비활성 상태임: “현재 가게에 접근할 수 없습니다.”
POST /api/auth/logout
- 목적: 현재 로그인 세션을 종료한다. (FR-001)
- 인증: 필요
- 요청값: 없음
- 성공 응답:
{
"success": true
}
- 사용자에게 표시할 오류 상황:
- 세션이 이미 만료됨: 로그인 화면으로 이동한다.
GET /api/auth/me
- 목적: 로그인 상태, 직원 역할, 가게 정보를 확인하고 첫 화면의 접근 범위를 결정한다. (FR-001, FR-005)
- 인증: 필요
- 요청값: 없음
- 성공 응답:
{
"user": {
"id": "staff_123",
"name": "김디자이너",
"role": "designer",
"isActive": true
},
"store": {
"id": "store_001",
"name": "온헤어",
"timezone": "Asia/Seoul"
},
"permissions": {
"canManageStaff": false,
"canImportData": false,
"canViewAllCustomers": false,
"canViewActivityLogs": false
}
}
- 사용자에게 표시할 오류 상황:
- 세션 만료: “로그인이 만료되었습니다. 다시 로그인해 주세요.”
- 계정 차단: “사용이 중지된 계정입니다.”
2. 직원·권한 설정
GET /api/staff
- 목적: 가게 직원 목록과 역할, 계정 상태, 마지막 권한 변경일을 조회한다. (FR-002, UIR-009)
- 인증: 필요, 원장님만 가능
- 요청값:
status: 선택. active, blocked, all
- 성공 응답:
{
"items": [
{
"id": "staff_123",
"name": "김디자이너",
"role": "designer",
"loginMethod": "email",
"isActive": true,
"assignedCustomerCount": 128,
"lastPermissionChangedAt": "2026-09-10T10:20:00+09:00"
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 디자이너가 접근함: “직원·권한 설정은 원장님만 사용할 수 있습니다.”
POST /api/staff
- 목적: 새 직원 계정을 등록하고 원장님 또는 디자이너 역할을 지정한다. (FR-002, UIR-009)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"name": "이디자이너",
"loginId": "lee@example.com",
"loginMethod": "email",
"role": "designer",
"isActive": true
}
{
"id": "staff_456",
"name": "이디자이너",
"role": "designer",
"isActive": true,
"createdAt": "2026-09-16T11:00:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 이미 사용 중인 로그인 정보: “이미 등록된 로그인 정보입니다.”
- 이름 또는 로그인 수단 미입력: “직원 이름과 로그인 정보를 입력해 주세요.”
- 다른 가게 직원 연결 시도: “다른 가게 직원은 등록할 수 없습니다.”
PATCH /api/staff/{staffId}
- 목적: 직원 이름, 역할, 계정 활성 상태를 변경하고 권한 변경 이력을 남긴다. (FR-002, FR-028, UIR-009)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"name": "이디자이너",
"role": "designer",
"isActive": false,
"changeReason": "퇴사 처리"
}
{
"id": "staff_456",
"name": "이디자이너",
"role": "designer",
"isActive": false,
"lastPermissionChangedAt": "2026-09-16T11:30:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 마지막 원장님 계정을 차단하거나 디자이너로 변경하려 함: “마지막 원장님 계정은 차단하거나 역할을 변경할 수 없습니다.”
- 존재하지 않는 직원: “직원을 찾을 수 없습니다.”
- 현재 로그인한 원장님이 본인 계정을 차단함: “현재 사용 중인 계정은 직접 차단할 수 없습니다.”
3. 고객 등록, 검색, 상세 조회
GET /api/customers
- 목적: 고객 목록에서 이름, 휴대전화 번호, 담당 디자이너로 고객을 검색한다. 디자이너는 자기 담당 고객만 조회한다. (FR-003, FR-005, UIR-003)
- 인증: 필요
- 요청값:
query: 선택. 이름 또는 휴대전화 번호
designerId: 선택. 원장님만 사용 가능
stage: 선택. 고객 단계
page, pageSize
- 성공 응답:
{
"items": [
{
"id": "customer_001",
"name": "김고객",
"displayPhone": "010-1234-5678",
"assignedDesigner": {
"id": "staff_123",
"name": "김디자이너"
},
"stage": "repeat_customer",
"lastTreatmentAt": "2026-08-10T14:00:00+09:00",
"marketingConsentStatus": "agreed"
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 검색어가 너무 짧음: “이름 또는 휴대전화 번호를 2자 이상 입력해 주세요.”
- 권한 없는 담당 고객 조회: 결과를 반환하지 않고 “조회 권한이 없습니다.”를 표시한다.
POST /api/customers/phone-check
- 목적: 고객 등록 전 휴대전화 번호를 정규화하고, 같은 가게의 중복 고객 여부를 확인한다. (FR-003, FR-004)
- 인증: 필요
- 요청값:
{
"rawPhone": "+82 10-1234-5678"
}
{
"rawPhone": "+82 10-1234-5678",
"normalizedPhone": "01012345678",
"displayPhone": "010-1234-5678",
"validationStatus": "valid",
"duplicateCandidates": []
}
{
"rawPhone": "010-1234-5678",
"normalizedPhone": "01012345678",
"displayPhone": "010-1234-5678",
"validationStatus": "valid",
"duplicateCandidates": [
{
"id": "customer_001",
"name": "김고객",
"assignedDesignerName": "김디자이너",
"accessAllowed": true
}
]
}
- 사용자에게 표시할 오류 상황:
- 일반 전화번호 또는 자릿수 부족: “휴대전화 번호를 확인해 주세요. 자동 병합과 알림톡 발송은 할 수 없습니다.”
011, 016, 017, 018, 019 번호: 기존 번호 그대로 검토 대상으로 처리한다.
- 번호에 메모가 함께 입력됨: “전화번호 외 내용이 포함되어 있습니다. 번호를 따로 입력해 주세요.”
POST /api/customers
- 목적: 새 고객을 등록하고 개인정보 및 마케팅 수신동의 상태를 저장한다. (FR-003, FR-004, FR-026)
- 인증: 필요
- 요청값:
{
"name": "김고객",
"rawPhone": "010-1234-5678",
"assignedDesignerId": "staff_123",
"privacyConsent": {
"status": "agreed",
"consentedAt": "2026-09-16T11:20:00+09:00",
"channel": "매장"
},
"marketingConsent": {
"status": "declined",
"consentedAt": null,
"channel": null
}
}
{
"id": "customer_001",
"name": "김고객",
"normalizedPhone": "01012345678",
"displayPhone": "010-1234-5678",
"validationStatus": "valid",
"stage": "inquiry",
"recordStatus": "active"
}
- 사용자에게 표시할 오류 상황:
- 같은 정규화 번호 고객 존재: “같은 휴대전화 번호의 고객이 이미 있습니다. 기존 고객을 확인해 주세요.”
- 담당 디자이너가 현재 가게에 없음: “선택한 담당 디자이너를 사용할 수 없습니다.”
- 개인정보 동의 상태 미입력: “개인정보 동의 상태를 선택해 주세요.”
GET /api/customers/{customerId}
- 목적: 고객 상세에서 고객 정보, 동의 상태, 시술 이력, 다음 예약, 예약 확인 건을 한 번에 조회한다. (FR-005, FR-007, UIR-004)
- 인증: 필요
- 요청값: 없음
- 성공 응답:
{
"id": "customer_001",
"name": "김고객",
"displayPhone": "010-1234-5678",
"assignedDesigner": {
"id": "staff_123",
"name": "김디자이너"
},
"stage": "next_reservation_booked",
"privacyConsent": {
"status": "agreed",
"consentedAt": "2026-03-01T12:00:00+09:00",
"channel": "매장"
},
"marketingConsent": {
"status": "agreed",
"consentedAt": "2026-03-01T12:00:00+09:00",
"channel": "카카오톡 채널"
},
"photoConsent": {
"status": "agreed",
"retentionUntil": "2027-03-01"
},
"lastTreatmentAt": "2026-09-10T14:00:00+09:00",
"treatmentRecords": [],
"upcomingReservations": [],
"recentReservationConfirmations": []
}
- 사용자에게 표시할 오류 상황:
- 병합된 고객 주소 접근: “통합된 고객입니다. 대표 고객 정보로 이동합니다.”
- 다른 담당 고객 접근: FR-006에서 확정한 열람 정책에 따라 필요한 범위만 표시한다.
- 삭제 또는 알아볼 수 없게 처리된 고객: “삭제 처리된 고객입니다. 허용된 최소 이력만 볼 수 있습니다.”
PATCH /api/customers/{customerId}
- 목적: 고객 이름, 담당 디자이너, 동의 상태, 촬영·보관 동의 및 보유기간을 수정한다. (FR-003, FR-026)
- 인증: 필요
- 요청값:
{
"name": "김고객",
"assignedDesignerId": "staff_456",
"privacyConsent": {
"status": "agreed",
"consentedAt": "2026-09-16T12:00:00+09:00",
"channel": "매장"
},
"marketingConsent": {
"status": "agreed",
"consentedAt": "2026-09-16T12:00:00+09:00",
"channel": "카카오톡 채널"
},
"photoConsent": {
"status": "agreed",
"consentedAt": "2026-09-16T12:00:00+09:00",
"channel": "매장",
"retentionUntil": "2027-09-16"
}
}
{
"id": "customer_001",
"updatedAt": "2026-09-16T12:00:00+09:00",
"stage": "next_reservation_booked"
}
- 사용자에게 표시할 오류 상황:
- 디자이너가 권한 없는 고객 수정: “이 고객을 수정할 권한이 없습니다.”
- 사진 보유기간 없이 사진 동의 저장 시도: “시술 사진 보유기간을 함께 입력해 주세요.”
- 이미 삭제 처리된 고객 수정: “삭제 처리된 고객은 수정할 수 없습니다.”
4. 대체 시술 열람
POST /api/customers/{customerId}/temporary-access-requests
- 목적: 다른 담당 고객의 시술 이력, 염색약 번호, 배합을 확인해야 할 때 임시 열람을 요청한다. (FR-006)
- 인증: 필요, 디자이너 또는 원장님
- 요청값:
{
"reason": "담당 디자이너 휴무로 염색약 번호 확인 필요",
"requestedUntil": "2026-09-16T20:00:00+09:00"
}
{
"id": "access_request_001",
"status": "pending",
"customerId": "customer_001",
"requestedUntil": "2026-09-16T20:00:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 자기 담당 고객: “내 담당 고객은 별도 요청 없이 확인할 수 있습니다.”
- 고객이 다른 가게 소속: “다른 가게 고객에게는 접근할 수 없습니다.”
PATCH /api/temporary-access-requests/{requestId}
- 목적: 원장님이 임시 열람 요청을 승인하거나 거절한다. 승인 시 열람 기간과 승인자를 기록한다. (FR-006, FR-028)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"status": "approved",
"approvedUntil": "2026-09-16T20:00:00+09:00"
}
{
"id": "access_request_001",
"status": "approved",
"approvedBy": {
"id": "staff_owner_001",
"name": "원장님"
},
"approvedUntil": "2026-09-16T20:00:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 이미 처리된 요청: “이미 승인 또는 거절된 요청입니다.”
- 승인 시간이 현재보다 이전: “임시 열람 종료 시간을 다시 확인해 주세요.”
확인 필요: FR-006의 구현 방식은 임시 열람 요청 후 원장님 승인 방식으로 연결했다. 원장님이 직접 일정 시간 권한을 부여하는 방식 또는 담당 디자이너 변경 방식으로 확정하면 이 API를 해당 정책에 맞게 조정한다.
5. 시술 기록과 시술 사진
POST /api/treatment-records
- 목적: 실제 시술 내용, 염색약 번호, 배합, 메모를 저장하고 필요하면 다음 예약을 함께 등록한다. (FR-008, FR-018, UIR-005)
- 인증: 필요
- 요청값:
{
"customerId": "customer_001",
"reservationConfirmationId": "reservation_001",
"treatedAt": "2026-09-16T14:00:00+09:00",
"designerId": "staff_123",
"treatmentType": "염색",
"treatmentMemo": "뿌리 염색, 붉은기 보정",
"dyeNumber": "7N",
"formula": "7N 30g + 7A 10g + 6% 산화제 60g",
"nextReservation": {
"scheduledAt": "2026-11-16T14:00:00+09:00",
"treatmentType": "뿌리 염색",
"contactPoint": "매장·현장 방문"
}
}
{
"id": "treatment_001",
"customerId": "customer_001",
"reservationConfirmationId": "reservation_001",
"treatedAt": "2026-09-16T14:00:00+09:00",
"treatmentType": "염색",
"treatmentMemo": "뿌리 염색, 붉은기 보정",
"nextReservationConfirmationId": "reservation_002",
"createdAt": "2026-09-16T15:00:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 시술 종류 또는 시술 메모 미입력: “시술 종류와 시술 메모를 입력해 주세요.”
- 다른 가게 고객 또는 예약 확인 건 연결: “현재 가게의 고객과 예약만 연결할 수 있습니다.”
- 권한 없는 고객 기록 작성: “이 고객의 시술 기록을 작성할 권한이 없습니다.”
- 동시 수정 충돌: “다른 사용자가 먼저 수정했습니다. 최신 내용을 확인해 주세요.”
PATCH /api/treatment-records/{treatmentRecordId}
- 목적: 기존 시술 기록의 시술 내용, 염색약 번호, 배합, 메모를 수정하고 수정일을 남긴다. (FR-008)
- 인증: 필요
- 요청값:
{
"treatmentType": "염색",
"treatmentMemo": "뿌리 염색, 붉은기 보정 완료",
"dyeNumber": "7N",
"formula": "7N 30g + 7A 10g + 6% 산화제 60g",
"version": 3
}
{
"id": "treatment_001",
"version": 4,
"updatedAt": "2026-09-16T15:20:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 최신 버전과 다름: “기록이 수정되었습니다. 최신 내용을 확인한 뒤 다시 저장해 주세요.”
- 시술 기록 없음: “시술 기록을 찾을 수 없습니다.”
POST /api/treatment-records/{treatmentRecordId}/photos
- 목적: 촬영·보관 동의와 보유기간이 확인된 고객의 시술 기록에 사진을 연결한다. (FR-009)
- 인증: 필요
- 요청값:
multipart/form-data
| 항목 | 필수 | 설명 |
|---|
file | 예 | 시술 사진 파일 |
caption | 아니오 | 사진 설명 |
{
"id": "photo_001",
"treatmentRecordId": "treatment_001",
"fileName": "treatment-photo.jpg",
"uploadedAt": "2026-09-16T15:30:00+09:00",
"retentionUntil": "2027-09-16"
}
- 사용자에게 표시할 오류 상황:
- 촬영·보관 동의 없음 또는 미확인: “시술 사진 촬영·보관 동의를 확인한 뒤 첨부할 수 있습니다.”
- 보유기간 미설정: “사진 보유기간을 먼저 설정해 주세요.”
- 지원하지 않는 파일 또는 업로드 실패: “사진을 올리지 못했습니다. 파일 형식을 확인해 주세요.”
DELETE /api/treatment-records/{treatmentRecordId}/photos/{photoId}
- 목적: 시술 기록에 연결된 사진을 삭제하고 삭제 이력을 남긴다. (FR-009, FR-028)
- 인증: 필요
- 요청값: 없음
- 성공 응답:
{
"success": true,
"deletedAt": "2026-09-16T15:40:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 권한 없는 사진 삭제: “이 사진을 삭제할 권한이 없습니다.”
- 이미 삭제된 사진: “이미 삭제된 사진입니다.”
6. 예약 확인 건과 오늘·내일 예약
GET /api/reservation-confirmations
- 목적: 오늘·내일 예약 확인 화면과 고객 상세에서 예약 확인 건을 조회한다. (FR-005, FR-010, FR-011, UIR-002, UIR-004)
- 인증: 필요
- 요청값:
from: 시작 일시
to: 종료 일시
customerId: 선택
designerId: 선택. 원장님만 다른 디자이너 조건 조회 가능
visitResult: 선택. pending, completed, no_show, customer_cancelled, store_cancelled
- 성공 응답:
{
"items": [
{
"id": "reservation_001",
"scheduledAt": "2026-09-16T14:00:00+09:00",
"customer": {
"id": "customer_001",
"name": "김고객",
"displayPhone": "010-1234-5678"
},
"designer": {
"id": "staff_123",
"name": "김디자이너"
},
"treatmentType": "염색",
"contactPoint": "네이버 예약",
"customerConfirmationStatus": "confirmed",
"visitResult": "pending",
"operationalMessages": {
"dayBefore": "success",
"sameDay": "scheduled"
}
}
],
"page": 1,
"pageSize": 50,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 알림톡 설정 미완료: 목록 상단에 “알림톡 자동 발송 설정이 완료되지 않았습니다.”를 표시한다.
- 발송 실패 예약 존재: 해당 예약을 “수동 연락 필요”로 표시한다.
POST /api/reservation-confirmations
- 목적: 네이버 예약을 대체하지 않고 예약 확인과 안내 발송에 필요한 최소 예약 정보를 직접 등록한다. (FR-010, FR-011)
- 인증: 필요
- 요청값:
{
"customerId": "customer_001",
"scheduledAt": "2026-09-17T14:00:00+09:00",
"designerId": "staff_123",
"treatmentType": "염색",
"contactPoint": "네이버 예약",
"customerConfirmationStatus": "not_contacted"
}
{
"id": "reservation_002",
"customerId": "customer_001",
"scheduledAt": "2026-09-17T14:00:00+09:00",
"contactSnapshot": {
"name": "김고객",
"displayPhone": "010-1234-5678",
"normalizedPhone": "01012345678"
},
"messageSchedule": {
"dayBeforeAt": "2026-09-16T18:00:00+09:00",
"sameDayAt": "2026-09-17T11:00:00+09:00"
},
"autoMessageEligibility": "eligible"
}
- 사용자에게 표시할 오류 상황:
- 과거 방문 일시 입력: “지난 시간은 새 예약으로 등록할 수 없습니다.”
- 전화번호 검토 필요 고객: “연락처 확인이 필요합니다. 자동 알림톡은 예약되지 않습니다.”
- 같은 고객·방문 일시·담당 디자이너 예약 존재: “비슷한 예약이 이미 있습니다. 중복 등록인지 확인해 주세요.”
- 권한 없는 다른 담당 고객 등록: “이 고객의 예약을 등록할 권한이 없습니다.”
PATCH /api/reservation-confirmations/{reservationId}
- 목적: 예약 확인 건의 방문 예정 일시, 담당 디자이너, 예정 시술, 고객 확인 상태, 방문 결과를 수정한다. 변경 시 미발송 메시지 일정을 다시 계산한다. (FR-010, FR-011, FR-014, FR-017)
- 인증: 필요
- 요청값:
{
"scheduledAt": "2026-09-17T15:00:00+09:00",
"designerId": "staff_123",
"treatmentType": "염색",
"customerConfirmationStatus": "confirmed",
"visitResult": "completed"
}
{
"id": "reservation_002",
"scheduledAt": "2026-09-17T15:00:00+09:00",
"visitResult": "completed",
"messageSchedule": {
"dayBefore": "success",
"sameDay": "rescheduled"
},
"treatmentRecordTask": {
"status": "open",
"assigneeId": "staff_123"
}
}
- 사용자에게 표시할 오류 상황:
- 이미 취소된 예약에 시술 완료 입력: “취소된 예약은 시술 완료로 바꿀 수 없습니다.”
- 예약 시간 경과 후 당일 안내 발송 요청: “방문 시간이 지나 당일 안내를 보낼 수 없습니다.”
- 권한 없음: “이 예약 확인 건을 수정할 권한이 없습니다.”
POST /api/reservation-confirmations/{reservationId}/manual-contacts
- 목적: 알림톡 발송 실패 또는 연결 불가 시 전화, 카카오톡 채널 등으로 수동 연락한 결과를 기록한다. (FR-016)
- 인증: 필요
- 요청값:
{
"contactedAt": "2026-09-16T18:15:00+09:00",
"channel": "전화",
"result": "통화 완료, 방문 확인",
"customerConfirmationStatus": "confirmed"
}
{
"id": "manual_contact_001",
"reservationConfirmationId": "reservation_002",
"status": "manual_contact_completed",
"contactedAt": "2026-09-16T18:15:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 연락 일시 미입력: “연락한 일시를 입력해 주세요.”
- 연락 결과 미입력: “연락 결과를 입력해 주세요.”
- 취소된 예약: “취소된 예약에는 수동 연락 결과를 추가할 수 없습니다.”
GET /api/tasks/treatment-records
- 목적: 방문 결과가 시술 완료인데 연결된 시술 기록이 없는 예약 확인 건을 담당 디자이너의 내부 할 일로 보여준다. (FR-017, UIR-002)
- 인증: 필요
- 요청값:
- 성공 응답:
{
"items": [
{
"id": "task_001",
"type": "treatment_record_required",
"reservationConfirmationId": "reservation_002",
"customerName": "김고객",
"scheduledAt": "2026-09-17T15:00:00+09:00",
"assigneeName": "김디자이너",
"status": "open"
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 권한 없는 할 일 조회: 자기 담당 예약의 할 일만 표시한다.
7. 알림톡 설정, 발송과 상태 반영
GET /api/alimtalk/settings
- 목적: 발신 프로필, 전날 예약 확인 템플릿, 당일 예약 안내 템플릿의 연결 상태를 조회한다. (FR-015, UIR-012)
- 인증: 필요, 원장님만 가능
- 요청값: 없음
- 성공 응답:
{
"senderProfile": {
"status": "connected",
"profileName": "온헤어",
"updatedAt": "2026-09-01T10:00:00+09:00"
},
"templates": {
"dayBeforeConfirmation": {
"status": "approved",
"templateCode": "ONHAIR_RESERVATION_CONFIRM"
},
"sameDayReminder": {
"status": "approved",
"templateCode": "ONHAIR_SAME_DAY_NOTICE"
}
},
"automaticSendingEnabled": true
}
- 사용자에게 표시할 오류 상황:
- 발신 프로필 또는 템플릿 미연결: “알림톡 자동 발송을 위해 발신 프로필과 템플릿을 확인해 주세요.”
PATCH /api/alimtalk/settings
- 목적: 알림톡 발신 프로필과 예약 안내용 템플릿 상태를 저장하고 자동 발송 가능 여부를 관리한다. (FR-015, UIR-012)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"senderProfileKey": "provider-profile-key",
"dayBeforeTemplateCode": "ONHAIR_RESERVATION_CONFIRM",
"sameDayTemplateCode": "ONHAIR_SAME_DAY_NOTICE",
"automaticSendingEnabled": true
}
{
"senderProfile": {
"status": "verification_pending"
},
"templates": {
"dayBeforeConfirmation": {
"status": "verification_pending"
},
"sameDayReminder": {
"status": "verification_pending"
}
}
}
- 사용자에게 표시할 오류 상황:
- 발신 프로필 또는 템플릿 정보가 유효하지 않음: “알림톡 연결 정보를 확인해 주세요.”
- 템플릿에 필수 안내 항목 없음: “예약 일시, 담당 디자이너, 예정 시술, 변경·취소 안내가 포함된 템플릿이 필요합니다.”
POST /api/internal/jobs/send-day-before-alimtalk
- 목적: 매일 전날 오후 6시에 다음 날 예약 확인 알림톡 발송 대상을 찾아 한 번씩 발송한다. (FR-012, NFR-004)
- 인증: 내부 작업 인증 필요
- HTTP 방식과 경로:
POST /api/internal/jobs/send-day-before-alimtalk
- 요청값:
{
"runAt": "2026-09-16T18:00:00+09:00"
}
{
"processedCount": 15,
"successCount": 13,
"failedCount": 1,
"stoppedCount": 1
}
- 사용자에게 표시할 오류 상황:
- 이 API는 직원 화면에서 직접 실행하지 않는다.
- 실패 또는 자동 발송 중지 건은 오늘·내일 예약 확인 화면에서 “수동 연락 필요”로 표시한다.
POST /api/internal/jobs/send-same-day-alimtalk
- 목적: 각 예약의 방문 3시간 전에 당일 예약 안내 알림톡을 한 번씩 발송한다. (FR-013, NFR-004)
- 인증: 내부 작업 인증 필요
- 요청값:
{
"runAt": "2026-09-17T11:00:00+09:00"
}
{
"processedCount": 8,
"successCount": 7,
"failedCount": 1,
"skippedPastVisitCount": 0
}
- 사용자에게 표시할 오류 상황:
- 방문 시간이 지난 예약은 발송하지 않고 목록에 “발송 시간 지남”으로 표시한다.
- 번호 오류, 병합 검토 중, 알림톡 연결 오류는 수동 연락 대상으로 표시한다.
POST /api/webhooks/alimtalk/status
- 목적: 알림톡 제공업체가 전달하는 처리 중, 성공, 실패 결과를 운영 메시지 상태에 반영한다. (FR-014)
- 인증: 알림톡 제공업체 서명 또는 비밀키 검증 필요
- 요청값:
{
"requestId": "alimtalk_request_001",
"resultId": "provider_result_001",
"status": "success",
"occurredAt": "2026-09-16T18:00:03+09:00",
"failureReason": null
}
{
"success": true
}
- 사용자에게 표시할 오류 상황:
- 연결되지 않은 요청 식별값: 운영자 확인용 오류 기록을 남긴다.
- 실패 결과: 예약 확인 건에 “알림톡 실패”와 실패 원인을 표시하고 수동 연락 대상으로 전환한다.
8. 고객 단계와 뜸해진 고객
GET /api/dormant-customers
- 목적: 마지막 시술 후 달력 기준 2개월이 지났고 미래 예약이 없는 고객을 담당 디자이너별로 조회한다. 마케팅 수신동의와 마지막 연락 내용을 함께 표시한다. (FR-019, FR-020, UIR-006)
- 인증: 필요
- 요청값:
designerId: 선택. 원장님만 다른 담당자 조건 사용 가능
marketingConsentStatus: 선택. agreed, declined, unconfirmed
page, pageSize
- 성공 응답:
{
"items": [
{
"customerId": "customer_001",
"name": "김고객",
"displayPhone": "010-1234-5678",
"assignedDesignerName": "김디자이너",
"lastTreatmentAt": "2026-07-10T14:00:00+09:00",
"dormantSince": "2026-09-10T00:00:00+09:00",
"marketingConsentStatus": "agreed",
"lastContact": {
"contactedAt": "2026-09-12T13:00:00+09:00",
"channel": "카카오톡 채널",
"result": "응답 없음"
}
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 디자이너는 자기 담당 고객만 표시한다.
- 마케팅 수신동의가 거부 또는 미확인인 고객은 상태를 분명히 표시한다.
POST /api/customers/{customerId}/dormant-contacts
- 목적: 뜸해진 고객에게 연락한 일시, 연락 채널, 연락 결과를 기록한다. (FR-021, UIR-006)
- 인증: 필요
- 요청값:
{
"contactedAt": "2026-09-16T16:00:00+09:00",
"channel": "카카오톡 채널",
"result": "다음 주 예약 문의 예정"
}
{
"id": "dormant_contact_001",
"customerId": "customer_001",
"contactedAt": "2026-09-16T16:00:00+09:00",
"channel": "카카오톡 채널",
"result": "다음 주 예약 문의 예정"
}
- 사용자에게 표시할 오류 상황:
- 연락 채널 또는 결과 미입력: “연락 채널과 결과를 입력해 주세요.”
- 뜸해진 고객이 아님: “현재 뜸해진 고객 목록에 없는 고객입니다.”
POST /api/internal/jobs/recalculate-customer-stages
- 목적: 예약 확인 건과 시술 기록 변경 뒤 고객 단계를 자동 계산하고, 뜸해짐 대상 여부를 갱신한다. (FR-019, FR-020)
- 인증: 내부 작업 인증 필요
- 요청값:
{
"customerId": "customer_001",
"trigger": "treatment_record_created"
}
{
"customerId": "customer_001",
"previousStage": "completed_treatment",
"currentStage": "next_reservation_booked",
"isDormant": false
}
- 사용자에게 표시할 오류 상황:
- 이 작업의 오류는 고객 상세와 뜸해진 고객 목록에서 최신 계산이 지연될 수 있음을 운영자에게 표시한다.
9. CSV 자료 가져오기와 수첩 자료 입력
POST /api/import-jobs
- 목적: 고객 정보, 방문 이력, 시술 기록, 예약 확인 건 CSV 파일을 올려 임시 검증 작업을 시작한다. (FR-022, FR-023, UIR-008)
- 인증: 필요, 원장님만 가능
- 요청값:
multipart/form-data
| 항목 | 필수 | 설명 |
|---|
file | 예 | CSV 파일 |
dataType | 예 | customers, treatment_records, reservation_confirmations |
hasHeader | 예 | 첫 행 제목 포함 여부 |
{
"id": "import_001",
"status": "validating",
"dataType": "treatment_records",
"uploadedFileName": "onhair-treatment-history.csv"
}
- 사용자에게 표시할 오류 상황:
- CSV가 아닌 파일: “CSV 파일만 가져올 수 있습니다.”
- 지원하지 않는 열 구조: “필수 열 이름 또는 열 순서를 확인해 주세요.”
- 파일 처리 실패: “자료를 읽지 못했습니다. 파일을 다시 확인해 주세요.”
GET /api/import-jobs/{importJobId}
- 목적: 자료 가져오기 검증 결과, 정상 행 수, 오류 행 수, 중복 후보를 확인한다. (FR-022, FR-023, UIR-008)
- 인증: 필요, 원장님만 가능
- 요청값: 없음
- 성공 응답:
{
"id": "import_001",
"status": "validation_completed",
"summary": {
"totalRows": 300,
"validRows": 280,
"errorRows": 12,
"duplicateCandidateRows": 8
},
"errors": [
{
"rowNumber": 21,
"field": "phone",
"reason": "휴대전화 번호 형식을 확인할 수 없습니다."
}
]
}
- 사용자에게 표시할 오류 상황:
- 가져오기 작업 없음: “자료 가져오기 작업을 찾을 수 없습니다.”
- 아직 검증 중: “자료를 확인하고 있습니다. 잠시 후 다시 확인해 주세요.”
PATCH /api/import-jobs/{importJobId}/error-rows/{rowNumber}
- 목적: 전화번호, 날짜, 담당 디자이너 연결 오류가 있는 행을 수정하고 다시 검증한다. (FR-023)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"values": {
"customerName": "김고객",
"phone": "010-1234-5678",
"designerName": "김디자이너",
"treatedAt": "2026-08-10T14:00:00+09:00"
}
}
{
"rowNumber": 21,
"status": "valid",
"duplicateCheckStatus": "no_duplicate"
}
- 사용자에게 표시할 오류 상황:
- 수정 후에도 전화번호 또는 날짜가 유효하지 않음: “수정한 값을 다시 확인해 주세요.”
- 담당자 이름을 연결할 수 없음: “등록된 직원 중 담당 디자이너를 선택해 주세요.”
POST /api/import-jobs/{importJobId}/apply
- 목적: 검증이 끝난 정상 행만 실제 고객, 시술 기록, 예약 확인 건으로 반영한다. 반영 과정에서 중복 생성을 막는다. (FR-022, FR-023, NFR-006)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"applyValidRowsOnly": true
}
{
"id": "import_001",
"status": "applied",
"createdCustomers": 120,
"linkedExistingCustomers": 150,
"createdTreatmentRecords": 280,
"skippedErrorRows": 12
}
- 사용자에게 표시할 오류 상황:
- 검증되지 않은 행 존재: “오류 행을 수정하거나 정상 행만 반영하도록 선택해 주세요.”
- 이미 반영한 작업 재실행: “이미 반영된 자료입니다. 중복으로 가져올 수 없습니다.”
POST /api/customers/manual-entry
- 목적: 수첩에 있는 고객 정보와 과거 방문·시술 기록을 직접 입력한다. (FR-022)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"customer": {
"name": "박고객",
"rawPhone": "010-9876-5432",
"assignedDesignerId": "staff_123",
"privacyConsent": {
"status": "unconfirmed",
"channel": "기존 수첩"
},
"marketingConsent": {
"status": "unconfirmed",
"channel": "기존 수첩"
}
},
"treatmentRecords": [
{
"treatedAt": "2026-06-15T14:00:00+09:00",
"designerId": "staff_123",
"treatmentType": "염색",
"treatmentMemo": "전체 염색",
"dyeNumber": "6N",
"formula": "6N 40g + 산화제 80g"
}
]
}
{
"customerId": "customer_020",
"createdTreatmentRecordCount": 1,
"duplicateCheckStatus": "no_duplicate"
}
- 사용자에게 표시할 오류 상황:
- 같은 전화번호 고객 존재: “기존 고객이 있습니다. 새로 만들지 않고 기존 고객과 연결해 주세요.”
- 시술 종류 또는 시술 메모 누락: “시술 기록에는 시술 종류와 메모가 필요합니다.”
10. 중복 고객 병합과 병합 취소
GET /api/customer-merge-candidates
- 목적: 같은 정규화 번호 또는 자료 가져오기 과정에서 발견된 중복 고객 후보를 조회한다. (FR-024, UIR-010)
- 인증: 필요
- 원장님: 전체 후보 조회 가능
- 디자이너: 중복 후보 제시 범위만 가능
- 요청값:
status: pending, merged, ignored
source: 선택. manual, import
- 성공 응답:
{
"items": [
{
"id": "merge_candidate_001",
"reason": "같은 정규화 번호",
"customers": [
{
"id": "customer_001",
"name": "김고객",
"displayPhone": "010-1234-5678",
"assignedDesignerName": "김디자이너"
},
{
"id": "customer_098",
"name": "김OO",
"displayPhone": "010-1234-5678",
"assignedDesignerName": "이디자이너"
}
],
"status": "pending"
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 다른 가게 고객은 후보로 함께 표시하지 않는다.
- 디자이너는 병합 실행 권한이 없음을 표시한다.
POST /api/customer-merges
- 목적: 원장님이 대표 고객과 통합 대상 고객을 확인한 뒤 고객, 시술 기록, 예약 확인 건, 동의 이력을 안전하게 병합한다. (FR-024, NFR-005)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"primaryCustomerId": "customer_001",
"mergedCustomerId": "customer_098",
"resolution": {
"nameSource": "primary",
"assignedDesignerId": "staff_123",
"privacyConsentSource": "latest_history",
"marketingConsentSource": "latest_history"
},
"reason": "같은 휴대전화 번호의 기존 고객"
}
{
"mergeId": "merge_001",
"primaryCustomerId": "customer_001",
"mergedCustomerId": "customer_098",
"movedTreatmentRecordCount": 4,
"movedReservationConfirmationCount": 2,
"mergedAt": "2026-09-16T17:00:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 동일 고객을 대표와 통합 대상으로 선택: “서로 다른 고객을 선택해 주세요.”
- 이미 병합된 고객 다시 병합: “이미 통합된 고객입니다.”
- 다른 가게 고객 병합 시도: “같은 가게의 고객만 병합할 수 있습니다.”
GET /api/customer-merges
- 목적: 고객 병합 이력과 대표 고객, 통합 대상 고객, 실행자, 실행 시각을 조회한다. (FR-025, UIR-011)
- 인증: 필요, 원장님만 가능
- 요청값:
customerId: 선택
page, pageSize
- 성공 응답:
{
"items": [
{
"id": "merge_001",
"primaryCustomer": {
"id": "customer_001",
"name": "김고객"
},
"mergedCustomer": {
"id": "customer_098",
"name": "김OO"
},
"mergedBy": {
"id": "staff_owner_001",
"name": "원장님"
},
"mergedAt": "2026-09-16T17:00:00+09:00",
"canUndo": true
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
POST /api/customer-merges/{mergeId}/undo
- 목적: 병합 당시 저장한 연결 관계와 값으로 고객 병합을 취소한다. (FR-025, NFR-005)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"reason": "서로 다른 가족 고객으로 확인됨"
}
{
"mergeId": "merge_001",
"status": "undone",
"restoredCustomerId": "customer_098",
"undoneAt": "2026-09-16T17:20:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 병합 후 새 기록이 추가되어 자동 복구 불가: “병합 후 추가된 기록이 있어 자동 취소할 수 없습니다. 원장님이 기록을 확인해 주세요.”
- 이미 취소된 병합: “이미 취소된 병합입니다.”
11. 동의, 보유기간, 삭제 요청
GET /api/privacy/settings
- 목적: 가게의 개인정보 보유기간과 사진 보유기간 설정을 조회한다. (FR-026, FR-027, UIR-007)
- 인증: 필요, 원장님만 가능
- 요청값: 없음
- 성공 응답:
{
"customerRetentionMonths": 60,
"treatmentPhotoRetentionMonths": 12,
"lastUpdatedAt": "2026-09-01T10:00:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 설정값이 없는 경우: “개인정보와 시술 사진 보유기간을 설정해 주세요.”
PATCH /api/privacy/settings
- 목적: 고객 개인정보와 시술 사진의 보유기간을 설정한다. (FR-027, UIR-007)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"customerRetentionMonths": 60,
"treatmentPhotoRetentionMonths": 12
}
{
"customerRetentionMonths": 60,
"treatmentPhotoRetentionMonths": 12,
"updatedAt": "2026-09-16T17:30:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 0 이하 값 입력: “보유기간은 1개월 이상으로 입력해 주세요.”
GET /api/privacy/retention-targets
- 목적: 마지막 방문일과 보유기간을 기준으로 삭제 또는 알아볼 수 없게 처리해야 하는 고객을 찾는다. (FR-027, UIR-007)
- 인증: 필요, 원장님만 가능
- 요청값:
status: due, requested, processed
- 성공 응답:
{
"items": [
{
"customerId": "customer_005",
"name": "최고객",
"lastTreatmentAt": "2021-08-10T14:00:00+09:00",
"retentionDueAt": "2026-08-10T00:00:00+09:00",
"reason": "보유기간 경과"
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
POST /api/customers/{customerId}/deletion-requests
- 목적: 고객의 삭제 요청 또는 보유기간 경과에 따라 삭제·비식별 처리 작업을 등록한다. (FR-027)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"reason": "customer_request",
"requestedAt": "2026-09-16T17:40:00+09:00",
"note": "전화로 삭제 요청"
}
{
"id": "deletion_request_001",
"customerId": "customer_001",
"status": "pending"
}
- 사용자에게 표시할 오류 상황:
- 이미 처리 중인 요청: “이미 삭제 처리 중인 고객입니다.”
- 고객 없음: “고객을 찾을 수 없습니다.”
POST /api/deletion-requests/{deletionRequestId}/process
- 목적: 고객의 개인정보를 삭제하거나 알아볼 수 없게 처리하고 처리 이력을 남긴다. (FR-027, FR-028)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"method": "anonymize"
}
{
"id": "deletion_request_001",
"status": "processed",
"processedAt": "2026-09-16T18:00:00+09:00",
"customerRecordStatus": "anonymized"
}
- 사용자에게 표시할 오류 상황:
- 이미 처리된 요청: “이미 삭제 또는 비식별 처리된 고객입니다.”
- 처리 중 오류: “고객 정보 처리에 실패했습니다. 다시 시도해 주세요.”
12. 활동 이력과 운영 지표
GET /api/activity-logs
- 목적: 고객 조회·수정·내보내기, 권한 변경, 병합, 삭제 처리 이력을 원장님이 확인한다. (FR-028, UIR-007)
- 인증: 필요, 원장님만 가능
- 요청값:
action: 선택. view, update, export, permission_change, merge, delete
actorId: 선택
targetType: 선택. customer, treatment_record, staff, export
from, to
page, pageSize
- 성공 응답:
{
"items": [
{
"id": "activity_001",
"action": "view",
"actor": {
"id": "staff_123",
"name": "김디자이너"
},
"targetType": "customer",
"targetId": "customer_001",
"occurredAt": "2026-09-16T14:00:00+09:00",
"reason": "대체 시술 임시 열람 승인"
}
],
"page": 1,
"pageSize": 50,
"total": 1
}
- 사용자에게 표시할 오류 상황:
- 디자이너 접근: “활동 이력은 원장님만 확인할 수 있습니다.”
POST /api/customers/export
- 목적: 원장님이 선택한 고객 목록을 CSV로 내보내고 내보내기 이력을 남긴다. (FR-028)
- 인증: 필요, 원장님만 가능
- 요청값:
{
"customerIds": ["customer_001", "customer_002"],
"fields": [
"name",
"displayPhone",
"assignedDesigner",
"stage",
"lastTreatmentAt",
"marketingConsentStatus"
],
"reason": "월간 고객 현황 확인"
}
{
"exportId": "export_001",
"downloadUrl": "서명된-임시-다운로드-주소",
"expiresAt": "2026-09-16T18:30:00+09:00"
}
- 사용자에게 표시할 오류 상황:
- 내보낼 고객 미선택: “내보낼 고객을 선택해 주세요.”
- 민감한 정보 또는 권한 없는 범위 요청: “내보낼 수 없는 정보가 포함되어 있습니다.”
GET /api/metrics/operations
- 목적: 노쇼율, 예약 확인 안내 발송률, 두 달 내 재방문율, 시술 기록 완료율, 시술 직후 재예약률을 계산해 보여준다. (FR-029)
- 인증: 필요, 원장님만 가능
- 요청값:
from: 분석 시작일
to: 분석 종료일
designerId: 선택
- 성공 응답:
{
"period": {
"from": "2026-08-01",
"to": "2026-08-31"
},
"metrics": {
"noShowRate": {
"value": 4.2,
"numerator": 2,
"denominator": 48
},
"reservationMessageDeliveryRate": {
"value": 95.8,
"numerator": 46,
"denominator": 48
},
"twoMonthRevisitRate": {
"value": 38.5,
"numerator": 10,
"denominator": 26
},
"treatmentRecordCompletionRate": {
"value": 93.8,
"numerator": 45,
"denominator": 48
},
"immediateRebookingRate": {
"value": 41.7,
"numerator": 20,
"denominator": 48
}
}
}
- 사용자에게 표시할 오류 상황:
- 관찰 기간이 충분하지 않은 재방문율: “두 달 관찰 기간이 끝난 시술 기록이 부족해 재방문율을 계산할 수 없습니다.”
- 조회 기간이 잘못됨: “시작일은 종료일보다 이전이어야 합니다.”
구현 시 반드시 지킬 기준
| 항목 | 구현 기준 | 관련 요구사항 |
|---|
| 가게 데이터 분리 | 모든 조회와 수정은 서버에서 storeId를 기준으로 제한한다. 요청값의 가게 식별값을 신뢰하지 않는다. | FR-001, FR-005, NFR-002 |
| 디자이너 권한 | 디자이너는 자기 담당 고객, 예약 확인 건, 시술 기록만 기본 접근 가능하다. | FR-005, NFR-003 |
| 대체 시술 열람 | 다른 담당 고객의 상세 시술 이력은 FR-006에서 확정한 임시 열람 정책과 이력 기록을 거쳐야 한다. | FR-006, FR-028 |
| 중복 발송 방지 | 예약 확인 건별로 안내 종류 + 예약 확인 건 식별값을 고유하게 저장해 알림톡 중복 발송을 막는다. | FR-012, FR-013, FR-014, NFR-004 |
| 예약 시간 변경 | 이미 성공한 알림톡은 다시 보내지 않는다. 아직 발송되지 않은 안내만 새 예약 시간 기준으로 다시 계산한다. | FR-014 |
| 고객 병합 | 병합 전 고객 값, 연결된 시술 기록·예약 확인 건의 기존 연결, 동의 이력을 보관해야 병합 취소가 가능하다. | FR-024, FR-025, NFR-005 |
| 자료 가져오기 | CSV는 검증 완료 전 실제 데이터에 반영하지 않는다. 오류 행은 원본 행 번호와 오류 이유를 보관한다. | FR-022, FR-023, NFR-006 |
| 개인정보 | 개인정보 동의와 마케팅 수신동의는 별도 상태와 변경 이력을 가진다. | FR-026, NFR-003 |
| 시술 사진 | 사진은 촬영·보관 동의와 보유기간이 있을 때만 업로드한다. | FR-009, FR-027 |
| 오류 복구 | 저장 실패, 발송 실패, 가져오기 오류는 사용자가 원인과 다음 행동을 이해할 수 있게 표시한다. | NFR-010 |