도자 공방 운영 허브 데이터 구조 문서
1. 설계 범위
이 문서는 생활도자 공방이 작품 번호를 기준으로 작품, 회원, 선반 칸, 현재 제작 단계, 회원권, 다음 가마 후보와 예상 완성 시점을 관리하기 위한 데이터 구조를 정의합니다.
첫 개발 범위에서는 실제 가마 적재와 소성 완료 처리보다 다음 가마 후보를 비교·선택하는 데 필요한 정보까지 관리합니다. 재료비, 수납, 실제 결제, 문제 작품 처리, 카카오톡 완료 알림은 후속 개발 범위이므로 이번 모델에 포함하지 않습니다.
데이터 설계 원칙
- 모든 운영 데이터는 생활도자 공방 단위로 분리한다. 다른 생활도자 공방의 작품 번호, 회원, 선반은 서로 조회되지 않는다. (NFR-001)
- 작품 번호는 같은 생활도자 공방 안에서 유일해야 한다. 다른 생활도자 공방에서는 같은 번호를 사용할 수 있다. (FR-006)
- 작품의 현재 제작 단계와 실제 선반 칸은 현재값으로 빠르게 조회하고, 모든 변경은 이력으로 별도 보관한다. (FR-008, FR-010, NFR-007)
- 회원은 본인에게 연결된 작품만 조회한다. 회원 전용 초대 주소와 최초 접근 코드는 최소 정보만 저장한다. (FR-001, FR-016, NFR-002)
- 대표 운영자와 보조 강사가 동시에 수정할 수 있으므로, 주요 수정 대상에는 수정 시각과 버전 번호를 둔다. (FR-005, FR-007, FR-010, FR-011, NFR-003)
2. 주요 데이터와 관계
Studio는 생활도자 공방의 데이터 경계입니다. 모든 회원, 작품, 선반, 가마 데이터는 하나의 생활도자 공방에 속합니다.
Work는 핵심 데이터입니다. 작품은 회원, 현재 제작 단계, 현재 선반 칸, 사진, 예상 완성 시점과 연결됩니다.
- 작품의 현재 상태는
Work에 저장하고, 변경 전후 기록은 이력 테이블에 남깁니다. 따라서 운영자는 현재 위치를 빠르게 찾고, 필요하면 이동 기록도 확인할 수 있습니다.
3. 테이블별 정의
3-1. 생활도자 공방과 운영 계정
Studio — 생활도자 공방
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 생활도자 공방 식별자 | FR-001, NFR-001 |
| name | 문자열 | 생활도자 공방 이름 | FR-001 |
| timezone | 문자열 | 날짜 계산 기준 시간대. 기본값 Asia/Seoul | FR-015 |
| setupStatus | 열거형 | 초기 설정 진행 상태 | FR-002 |
| setupConfirmedAt | 날짜시간, 선택 | 운영 시작을 확정한 시각 | FR-002 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
setupStatus 값은 NOT_STARTED, IN_PROGRESS, COMPLETED를 사용합니다.
OperatorAccount — 대표 운영자·보조 강사 운영 계정
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 운영 계정 식별자 | FR-001 |
| studioId | UUID | 소속 생활도자 공방 | FR-001, NFR-001 |
| name | 문자열 | 대표 운영자 또는 보조 강사 이름 | FR-001 |
| loginId | 문자열 | 로그인 식별자 | FR-001 |
| passwordHash | 문자열 | 비밀번호 원문이 아닌 암호화 값 | FR-001, NFR-002 |
| role | 열거형 | LEAD_OPERATOR, ASSISTANT_INSTRUCTOR | FR-001, FR-003 |
| isActive | 불리언 | 비활성 계정 로그인 차단 여부 | FR-001 |
| lastLoginAt | 날짜시간, 선택 | 최근 로그인 시각 | FR-001 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
- 대표 운영자만 생활도자 공방 작업 기준을 변경할 수 있습니다. (FR-003)
- 보조 강사는 회원, 작품, 단계와 선반 칸을 관리할 수 있습니다. (FR-004, FR-007, FR-010)
3-2. 회원과 회원 전용 접근
Member — 회원
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 회원 식별자 | FR-004 |
| studioId | UUID | 소속 생활도자 공방 | FR-004, NFR-001 |
| name | 문자열 | 회원 이름 | FR-004 |
| contactHint | 문자열, 선택 | 동명이인 구분을 위한 최소 식별 정보. 예: 전화번호 끝 4자리 | FR-004, NFR-002 |
| memberType | 열거형 | MONTHLY_MEMBER, TEN_SESSION_MEMBER, EXPERIENCE_MEMBER, OTHER | FR-004, FR-005 |
| isActive | 불리언 | 비활성 회원 여부 | FR-004 |
| memo | 문자열, 선택 | 운영 메모. 할인 구분 등 | FR-005 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
| version | 정수 | 동시 수정 검사용 버전 | NFR-003 |
contactHint에는 전체 전화번호를 기본 저장하지 않습니다. 동명이인 구분에 필요한 최소 정보만 저장합니다.
MemberAccess — 회원 전용 내 작품 조회 접근 정보
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 접근 정보 식별자 | FR-001, FR-016 |
| memberId | UUID | 접근 대상 회원 | FR-001 |
| accessTokenHash | 문자열 | 회원 전용 초대 주소의 토큰 암호화 값 | FR-001, NFR-002 |
| firstAccessCodeHash | 문자열 | 최초 접근 코드 암호화 값 | FR-001, NFR-002 |
| expiresAt | 날짜시간, 선택 | 최초 접근 코드 만료 시각 | FR-001 |
| activatedAt | 날짜시간, 선택 | 본인 확인 완료 시각 | FR-001 |
| revokedAt | 날짜시간, 선택 | 접근 취소 시각 | FR-001 |
| lastAccessAt | 날짜시간, 선택 | 최근 회원 조회 시각 | FR-016 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
- 초대 주소의 실제 토큰과 최초 접근 코드 원문은 저장하지 않습니다.
revokedAt이 있거나 expiresAt이 지난 접근 정보는 사용할 수 없습니다. (FR-001)
3-3. 회원권과 잔여 횟수
MembershipPass — 회원권
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 회원권 식별자 | FR-005 |
| memberId | UUID | 회원권을 가진 회원 | FR-005 |
| passName | 문자열 | 월 정기권, 10회권 등 공방이 사용하는 회원권 이름 | FR-005 |
| remainingCount | 정수 | 현재 잔여 횟수. 음수 불가 | FR-005 |
| validFrom | 날짜, 선택 | 유효기간 시작일 | FR-005 |
| validUntil | 날짜, 선택 | 유효기간 종료일 | FR-005 |
| memo | 문자열, 선택 | 체험 후 등록 할인 등 설명 | FR-005 |
| isActive | 불리언 | 현재 사용 중인 회원권 여부 | FR-005 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
| version | 정수 | 동시 수정 검사용 버전 | FR-005, NFR-003 |
RemainingCountChangeHistory — 회원권 잔여 횟수 변경 기록
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 변경 기록 식별자 | FR-005 |
| membershipPassId | UUID | 변경된 회원권 | FR-005 |
| beforeCount | 정수 | 변경 전 잔여 횟수 | FR-005 |
| afterCount | 정수 | 변경 후 잔여 횟수 | FR-005 |
| reason | 문자열 | 차감 또는 정정 사유 | FR-005 |
| changedByOperatorId | UUID | 변경한 대표 운영자 또는 보조 강사 | FR-005 |
| changedAt | 날짜시간 | 변경 시각 | FR-005 |
- 첫 개발에서는 예약 기능이 없으므로 잔여 횟수를 자동 차감하지 않습니다.
- 운영자가 잔여 횟수 변경 사유를 입력해야만 변경 기록을 저장합니다. (FR-005)
3-4. 공방 작업 기준: 현재 제작 단계, 선반, 선반 칸
WorkStageSetting — 현재 제작 단계 설정
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 단계 설정 식별자 | FR-003 |
| studioId | UUID | 소속 생활도자 공방 | FR-003, NFR-001 |
| code | 열거형 | DRYING, BISQUE_WAITING, BISQUE_COMPLETED, GLAZING, GLAZE_WAITING, COMPLETED | FR-003 |
| displayName | 문자열 | 화면에 표시할 단계명 | FR-003 |
| sortOrder | 정수 | 목록 표시 순서 | FR-009, FR-012 |
| defaultDaysToCompletion | 정수, 선택 | 이 단계에서 완성까지의 기본 소요 기간 | FR-003, FR-015 |
| isActive | 불리언 | 현재 사용하는 단계 여부 | FR-003 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
기본 단계는 아래 여섯 가지입니다.
- 건조
- 초벌 대기
- 초벌 완료
- 시유
- 재벌 대기
- 완성
초벌 대기와 재벌 대기는 같은 목록이나 같은 다음 가마 후보에 섞지 않습니다. (FR-013, FR-014)
Shelf — 선반
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 선반 식별자 | FR-003 |
| studioId | UUID | 소속 생활도자 공방 | FR-003, NFR-001 |
| stageSettingId | UUID | 이 선반을 사용하는 현재 제작 단계 | FR-003 |
| shelfNumber | 문자열 | 선반 번호. 예: 2번 선반 | FR-003 |
| displayName | 문자열 | 화면 표시명. 예: 초벌 대기 2번 선반 | FR-003 |
| shelfPhotoUrl | 문자열, 선택 | 선반 사진 저장 주소 | FR-003, FR-014 |
| isActive | 불리언 | 현재 사용 여부 | FR-003 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
ShelfSlot — 선반 칸
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 선반 칸 식별자 | FR-003 |
| shelfId | UUID | 소속 선반 | FR-003 |
| slotNumber | 문자열 | 선반 안의 칸 번호. 예: 1칸 | FR-003 |
| isActive | 불리언 | 현재 사용 여부 | FR-003 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
- 선반 칸의 실제 위치는
초벌 대기 2번 선반 1칸처럼 단계, 선반, 선반 칸을 조합해 표시합니다.
- 작품이 있는 선반과 선반 칸은 바로 삭제하지 않고 비활성화하거나 작품을 먼저 이동시켜야 합니다. (FR-003)
- 작품 단계 변경 시 새 단계에 연결된 선반의 선반 칸만 선택할 수 있습니다. (FR-007, FR-010)
3-5. 작품과 작품 사진
Work — 작품
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 작품 내부 식별자 | FR-007 |
| studioId | UUID | 소속 생활도자 공방 | FR-006, NFR-001 |
| memberId | UUID | 작품 소유 회원 | FR-007 |
| workNumber | 문자열 | 작품 바닥에 새긴 작품 번호 | FR-006, FR-008 |
| currentStageId | UUID | 현재 제작 단계 | FR-007, FR-010 |
| currentShelfSlotId | UUID | 현재 실제 보관 선반 칸 | FR-007, FR-010 |
| widthMm | 정수, 선택 | 작품 크기. 기준은 가로 폭 밀리미터 | FR-007, FR-014 |
| depthMm | 정수, 선택 | 작품 크기. 기준은 깊이 밀리미터 | FR-007, FR-014 |
| heightMm | 정수, 선택 | 작품 높이. 밀리미터 | FR-007, FR-014 |
| kilnPlannedDate | 날짜, 선택 | 초벌 또는 재벌 대기 작품의 가마 예정일 | FR-007, FR-015 |
| estimatedCompletionDate | 날짜, 선택 | 자동 계산한 예상 완성 날짜 | FR-015, FR-016 |
| estimatedCompletionSource | 열거형 | STAGE_DURATION, KILN_PLANNED_DATE, COMPLETED | FR-015 |
| completedAt | 날짜시간, 선택 | 완성 처리 시각 | FR-015, FR-016 |
| lastStageChangedAt | 날짜시간 | 마지막 단계 변경 시각 | FR-010, FR-015 |
| createdByOperatorId | UUID | 최초 등록한 운영자 | FR-007 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
| version | 정수 | 동시 수정 검사용 버전 | FR-007, FR-010, FR-011, NFR-003 |
WorkPhoto — 작품 사진
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 작품 사진 식별자 | FR-007 |
| workId | UUID | 연결 작품 | FR-007 |
| storageKey | 문자열 | 이미지 저장소 내부 경로 | NFR-002 |
| displayOrder | 정수 | 사진 표시 순서 | FR-007, FR-016 |
| isPrimary | 불리언 | 목록과 회원 화면에 우선 표시할 사진 | FR-007, FR-009, FR-016 |
| uploadedByOperatorId | UUID | 사진을 등록한 운영자 | FR-007 |
| createdAt | 날짜시간 | 업로드 시각 | NFR-007 |
작품 번호 규칙
| 항목 | 규칙 | 관련 요구사항 |
|---|
| 기본 형식 | 2608-01처럼 제작 연월 4자리 + 하이픈 + 순번 | FR-006 |
| 길이 | 일곱 자 이내 | FR-006 |
| 중복 기준 | 같은 생활도자 공방 안에서 중복 불가 | FR-006, NFR-001 |
| 주인 확인 | 작품 번호만으로 주인을 알 수 없으며 시스템에서 회원과 연결 | FR-006, FR-008 |
| 정정 | 이전 번호와 새 번호를 모두 기록 | FR-006 |
| 월 100점 이상 | 01~99만으로 월 약 130점 처리 불가. 두 자리 영문·숫자 조합 등 최종 운영 규칙 확인 필요 | FR-006 |
widthMm, depthMm, heightMm은 다음 가마 후보 비교에 필요하지만, 작품 등록 시 선택 입력으로 둡니다. 크기와 높이의 첫 개발 필수 입력 전환 여부는 실제 운영 검증 후 정합니다. (FR-007, FR-014)
3-6. 작품 변경 이력
StageChangeHistory — 단계 변경 이력
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 단계 변경 이력 식별자 | FR-010 |
| workId | UUID | 변경된 작품 | FR-010 |
| previousStageId | UUID, 선택 | 변경 전 현재 제작 단계 | FR-010 |
| nextStageId | UUID | 변경 후 현재 제작 단계 | FR-010 |
| reason | 문자열, 선택 | 이전 단계로 되돌릴 때 정정 사유 | FR-010 |
| changedByOperatorId | UUID | 변경한 운영자 | FR-010 |
| changedAt | 날짜시간 | 변경 시각 | FR-010 |
LocationChangeHistory — 위치 변경 이력
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 위치 변경 이력 식별자 | FR-010 |
| workId | UUID | 변경된 작품 | FR-010 |
| previousShelfSlotId | UUID, 선택 | 이전 선반 칸 | FR-010 |
| nextShelfSlotId | UUID | 새 선반 칸 | FR-010 |
| changedByOperatorId | UUID | 변경한 운영자 | FR-010 |
| changedAt | 날짜시간 | 변경 시각 | FR-010 |
WorkNumberCorrectionHistory — 작품 번호 정정 기록
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 작품 번호 정정 기록 식별자 | FR-006 |
| workId | UUID | 정정된 작품 | FR-006 |
| previousWorkNumber | 문자열 | 정정 전 작품 번호 | FR-006 |
| nextWorkNumber | 문자열 | 정정 후 작품 번호 | FR-006 |
| changedByOperatorId | UUID | 정정한 운영자 | FR-006 |
| changedAt | 날짜시간 | 정정 시각 | FR-006 |
- 작품의 단계와 선반 칸을 한 번에 변경하면
StageChangeHistory와 LocationChangeHistory를 함께 남깁니다. (FR-010)
- 선반 칸만 이동하는 경우에도
LocationChangeHistory를 남깁니다. (FR-010)
- 여러 작품 일괄 변경은 작품별로 각각의 이력 행을 생성합니다. (FR-011)
3-7. 가마와 다음 가마 후보
Kiln — 가마
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 가마 식별자 | FR-013, FR-014 |
| studioId | UUID | 소속 생활도자 공방 | NFR-001 |
| name | 문자열 | 가마 이름 또는 구분명 | FR-014 |
| kilnType | 열거형 | BISQUE, GLAZE | FR-013, FR-014 |
| isActive | 불리언 | 현재 사용 여부 | FR-014 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
첫 개발에서는 가마 내부 크기, 적재 위치, 자동 채움 비율 계산을 저장하지 않습니다. 채움 비율 계산은 가마 크기와 작품 부피 기준이 확정된 후 추가합니다.
KilnCandidateGroup — 다음 가마 후보 묶음
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 후보 묶음 식별자 | FR-014 |
| studioId | UUID | 소속 생활도자 공방 | NFR-001 |
| kilnId | UUID | 비교 대상 가마 | FR-014 |
| stageType | 열거형 | BISQUE_WAITING, GLAZE_WAITING | FR-013, FR-014 |
| plannedDate | 날짜, 선택 | 운영자가 정한 가마 예정일 | FR-014, FR-015 |
| status | 열거형 | DRAFT, SELECTED, ARCHIVED | FR-014 |
| selectedByOperatorId | UUID, 선택 | 후보를 선택한 운영자 | FR-014 |
| selectedAt | 날짜시간, 선택 | 후보 선택 시각 | FR-014 |
| memo | 문자열, 선택 | 비교·선택 메모 | FR-014 |
| createdAt / updatedAt | 날짜시간 | 생성·수정 시각 | NFR-007 |
KilnCandidateWork — 다음 가마 후보 작품
| 필드 | 형식 | 설명 | 관련 요구사항 |
|---|
| id | UUID | 후보 작품 식별자 | FR-014 |
| kilnCandidateGroupId | UUID | 다음 가마 후보 묶음 | FR-014 |
| workId | UUID | 후보 작품 | FR-014 |
| addedByOperatorId | UUID | 후보에 넣은 운영자 | FR-014 |
| addedAt | 날짜시간 | 후보 추가 시각 | FR-014 |
| memo | 문자열, 선택 | 높이·크기·선반 사진 비교 메모 | FR-014 |
- 초벌 대기 작품은 초벌 가마 후보에만 넣을 수 있습니다.
- 재벌 대기 작품은 재벌 가마 후보에만 넣을 수 있습니다.
- 하나의 후보 묶음에 초벌 대기와 재벌 대기 작품을 함께 저장할 수 없습니다. (FR-013, FR-014)
- 선택된 후보 묶음의
plannedDate는 연결 작품의 가마 예정일로 반영할 수 있으며, 예상 완성 시점 계산에 우선 사용합니다. (FR-014, FR-015)
4. 예상 완성 시점 계산 규칙
예상 완성 시점은 별도 수기 입력값이 아니라 Work에 저장된 현재 제작 단계, 마지막 단계 변경 시각, 단계별 기본 소요 기간, 가마 예정일을 기준으로 계산합니다. (FR-015)
- 완성 작품은 날짜 대신
완성을 표시합니다.
- 초벌 대기 또는 재벌 대기 작품에 가마 예정일이 있으면 해당 날짜를 우선합니다.
- 가마 예정일이 없으면 마지막 단계 변경일과 현재 제작 단계의 기본 소요 기간을 사용합니다.
- 단계별 기본 소요 기간이 없는 경우, 운영자와 회원 화면에 “예상 완성 시점 계산 기준 미설정” 상태를 표시합니다. (FR-003, FR-015, FR-016)
5. 주요 업무별 데이터 처리
| 업무 | 생성·수정 데이터 | 완료 기준 | 관련 요구사항 |
|---|
| 초기 설정 | Studio, WorkStageSetting, Shelf, ShelfSlot, Member, Work | 공방 작업 기준과 현재 보관 작품의 등록 범위를 확인하고 운영 시작 상태로 전환 | FR-002, FR-003, FR-004, FR-007 |
| 회원 등록 | Member, 필요 시 MemberAccess | 회원 상세와 작품 등록 화면에서 해당 회원을 선택 가능 | FR-004 |
| 회원권 변경 | MembershipPass, RemainingCountChangeHistory | 최신 잔여 횟수와 변경 사유·변경자를 확인 가능 | FR-005 |
| 작품 등록 | Work, WorkPhoto, 최초 이력 | 작품 번호, 회원, 사진, 단계, 선반 칸이 연결되어 목록·검색에 표시 | FR-006, FR-007 |
| 작품 번호 검색 | Work, WorkPhoto, 회원권, 이력 조회 | 번호 하나로 회원, 사진, 현재 단계, 선반 칸을 확인 | FR-008 |
| 단계별 작품 목록 | Work, WorkPhoto, ShelfSlot, WorkStageSetting 조회 | 단계·선반·회원·작품 번호로 작품을 좁혀 확인 | FR-009 |
| 작품 이동·단계 변경 | Work, StageChangeHistory, LocationChangeHistory | 현재 단계와 선반 칸이 최신값으로 갱신되고 변경 이력이 남음 | FR-010 |
| 여러 작품 일괄 변경 | 여러 Work와 각 이력 | 성공·실패 작품을 구분하고 성공 작품만 변경 | FR-011 |
| 대시보드 | 작품 단계별 집계, 가마 대기 집계, 회원권 조회 | 단계별 작품 수와 가마 대기 수를 한 화면에서 확인 | FR-012, FR-013 |
| 다음 가마 후보 비교 | Kiln, KilnCandidateGroup, KilnCandidateWork, Work | 초벌·재벌 후보를 분리하고 크기·높이·선반 사진을 비교해 선택 | FR-014 |
| 회원 전용 조회 | MemberAccess, 회원의 Work, WorkPhoto | 회원이 본인 작품의 사진, 단계, 예상 완성 시점을 확인 | FR-001, FR-016 |
6. 필요한 인덱스와 제약 조건
| 대상 | 인덱스 또는 제약 | 목적 | 관련 요구사항 |
|---|
Work | UNIQUE(studioId, workNumber) | 공방 안 작품 번호 중복 방지 | FR-006, NFR-001 |
Work | INDEX(studioId, currentStageId, currentShelfSlotId) | 단계별·선반 칸별 작품 목록 빠른 조회 | FR-009, FR-012, NFR-004 |
Work | INDEX(studioId, memberId) | 회원 상세의 연결 작품 조회 | FR-004, FR-016 |
Work | INDEX(studioId, kilnPlannedDate) | 가마 예정일 기준 후보 확인 | FR-014, FR-015 |
Member | INDEX(studioId, name) | 공방 안 회원 검색 | FR-004, NFR-004 |
Shelf | UNIQUE(studioId, shelfNumber) | 공방 안 선반 번호 중복 방지 | FR-003 |
ShelfSlot | UNIQUE(shelfId, slotNumber) | 같은 선반의 칸 번호 중복 방지 | FR-003 |
WorkStageSetting | UNIQUE(studioId, code) | 공방별 같은 기본 단계 중복 방지 | FR-003 |
StageChangeHistory | INDEX(workId, changedAt DESC) | 작품 상세에서 최근 단계 이력 조회 | FR-008, FR-010 |
LocationChangeHistory | INDEX(workId, changedAt DESC) | 작품 상세에서 최근 위치 이력 조회 | FR-008, FR-010 |
MembershipPass | INDEX(memberId, isActive, validUntil) | 회원의 사용 중 회원권·만료 상태 확인 | FR-005 |
KilnCandidateWork | UNIQUE(kilnCandidateGroupId, workId) | 같은 후보 묶음에 작품 중복 추가 방지 | FR-014 |
MemberAccess | UNIQUE(accessTokenHash) | 회원 전용 초대 주소 식별 | FR-001, NFR-002 |
데이터 무결성 처리
- 작품 등록·수정, 단계 변경, 일괄 변경은
version을 함께 확인합니다. 화면을 연 뒤 다른 운영자가 먼저 수정했다면 최신 정보를 다시 보여 줍니다. (FR-007, FR-010, FR-011, NFR-003)
- 단계 변경과 위치 변경은 작품 현재값 갱신 및 이력 저장을 하나의 데이터 저장 단위로 처리합니다. 하나만 저장되고 다른 하나가 누락되지 않아야 합니다. (FR-010, NFR-007)
- 작품이 연결된 선반 칸은 삭제할 수 없고, 먼저 작품을 다른 유효 선반 칸으로 이동해야 합니다. (FR-003)
- 회원이 조회하는 작품 데이터는
MemberAccess.memberId = Work.memberId 조건을 서버에서 항상 확인합니다. URL 값만 바꿔 다른 회원 작품을 조회할 수 없어야 합니다. (FR-001, FR-016, NFR-002)
7. Prisma 모델 초안
아래 초안은 PostgreSQL과 Prisma 사용을 기준으로 작성했습니다. 사진 파일 자체는 데이터베이스에 저장하지 않고, 접근 제어가 가능한 이미지 저장소에 보관한 뒤 storageKey만 저장합니다. (NFR-002)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum SetupStatus {
NOT_STARTED
IN_PROGRESS
COMPLETED
}
enum OperatorRole {
LEAD_OPERATOR
ASSISTANT_INSTRUCTOR
}
enum MemberType {
MONTHLY_MEMBER
TEN_SESSION_MEMBER
EXPERIENCE_MEMBER
OTHER
}
enum WorkStageCode {
DRYING
BISQUE_WAITING
BISQUE_COMPLETED
GLAZING
GLAZE_WAITING
COMPLETED
}
enum EstimatedCompletionSource {
STAGE_DURATION
KILN_PLANNED_DATE
COMPLETED
}
enum KilnType {
BISQUE
GLAZE
}
enum KilnCandidateStatus {
DRAFT
SELECTED
ARCHIVED
}
model Studio {
id String @id @default(uuid())
name String
timezone String @default("Asia/Seoul")
setupStatus SetupStatus @default(NOT_STARTED)
setupConfirmedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
operatorAccounts OperatorAccount[]
members Member[]
stageSettings WorkStageSetting[]
shelves Shelf[]
kilns Kiln[]
works Work[]
kilnCandidates KilnCandidateGroup[]
}
model OperatorAccount {
id String @id @default(uuid())
studioId String
name String
loginId String
passwordHash String
role OperatorRole
isActive Boolean @default(true)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
createdWorks Work[] @relation("WorkCreatedBy")
uploadedWorkPhotos WorkPhoto[]
stageChanges StageChangeHistory[]
locationChanges LocationChangeHistory[]
workNumberCorrections WorkNumberCorrectionHistory[]
remainingCountChanges RemainingCountChangeHistory[]
selectedKilnCandidates KilnCandidateGroup[] @relation("KilnCandidateSelectedBy")
addedKilnCandidateWorks KilnCandidateWork[]
@@unique([studioId, loginId])
@@index([studioId, role, isActive])
}
model Member {
id String @id @default(uuid())
studioId String
name String
contactHint String?
memberType MemberType @default(OTHER)
isActive Boolean @default(true)
memo String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
membershipPasses MembershipPass[]
memberAccesses MemberAccess[]
works Work[]
@@index([studioId, name])
@@index([studioId, isActive])
}
model MemberAccess {
id String @id @default(uuid())
memberId String
accessTokenHash String @unique
firstAccessCodeHash String
expiresAt DateTime?
activatedAt DateTime?
revokedAt DateTime?
lastAccessAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@index([memberId, revokedAt])
}
model MembershipPass {
id String @id @default(uuid())
memberId String
passName String
remainingCount Int
validFrom DateTime? @db.Date
validUntil DateTime? @db.Date
memo String?
isActive Boolean @default(true)
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
remainingCountChangeHistories RemainingCountChangeHistory[]
@@index([memberId, isActive, validUntil])
}
model RemainingCountChangeHistory {
id String @id @default(uuid())
membershipPassId String
beforeCount Int
afterCount Int
reason String
changedByOperatorId String
changedAt DateTime @default(now())
membershipPass MembershipPass @relation(fields: [membershipPassId], references: [id], onDelete: Cascade)
changedByOperator OperatorAccount @relation(fields: [changedByOperatorId], references: [id], onDelete: Restrict)
@@index([membershipPassId, changedAt(sort: Desc)])
}
model WorkStageSetting {
id String @id @default(uuid())
studioId String
code WorkStageCode
displayName String
sortOrder Int
defaultDaysToCompletion Int?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
shelves Shelf[]
currentWorks Work[] @relation("CurrentWorkStage")
previousChanges StageChangeHistory[] @relation("PreviousStage")
nextChanges StageChangeHistory[] @relation("NextStage")
@@unique([studioId, code])
@@unique([studioId, sortOrder])
}
model Shelf {
id String @id @default(uuid())
studioId String
stageSettingId String
shelfNumber String
displayName String
shelfPhotoUrl String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
stageSetting WorkStageSetting @relation(fields: [stageSettingId], references: [id], onDelete: Restrict)
shelfSlots ShelfSlot[]
@@unique([studioId, shelfNumber])
@@index([studioId, stageSettingId, isActive])
}
model ShelfSlot {
id String @id @default(uuid())
shelfId String
slotNumber String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shelf Shelf @relation(fields: [shelfId], references: [id], onDelete: Restrict)
currentWorks Work[] @relation("CurrentShelfSlot")
previousMoves LocationChangeHistory[] @relation("PreviousShelfSlot")
nextMoves LocationChangeHistory[] @relation("NextShelfSlot")
@@unique([shelfId, slotNumber])
@@index([shelfId, isActive])
}
model Work {
id String @id @default(uuid())
studioId String
memberId String
workNumber String
currentStageId String
currentShelfSlotId String
widthMm Int?
depthMm Int?
heightMm Int?
kilnPlannedDate DateTime? @db.Date
estimatedCompletionDate DateTime? @db.Date
estimatedCompletionSource EstimatedCompletionSource?
completedAt DateTime?
lastStageChangedAt DateTime @default(now())
createdByOperatorId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
member Member @relation(fields: [memberId], references: [id], onDelete: Restrict)
currentStage WorkStageSetting @relation("CurrentWorkStage", fields: [currentStageId], references: [id], onDelete: Restrict)
currentShelfSlot ShelfSlot @relation("CurrentShelfSlot", fields: [currentShelfSlotId], references: [id], onDelete: Restrict)
createdByOperator OperatorAccount @relation("WorkCreatedBy", fields: [createdByOperatorId], references: [id], onDelete: Restrict)
photos WorkPhoto[]
stageChangeHistories StageChangeHistory[]
locationChangeHistories LocationChangeHistory[]
workNumberCorrections WorkNumberCorrectionHistory[]
kilnCandidateWorks KilnCandidateWork[]
@@unique([studioId, workNumber])
@@index([studioId, currentStageId, currentShelfSlotId])
@@index([studioId, memberId])
@@index([studioId, kilnPlannedDate])
@@index([studioId, updatedAt])
}
model WorkPhoto {
id String @id @default(uuid())
workId String
storageKey String
displayOrder Int @default(0)
isPrimary Boolean @default(false)
uploadedByOperatorId String
createdAt DateTime @default(now())
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
uploadedByOperator OperatorAccount @relation(fields: [uploadedByOperatorId], references: [id], onDelete: Restrict)
@@index([workId, isPrimary, displayOrder])
}
model StageChangeHistory {
id String @id @default(uuid())
workId String
previousStageId String?
nextStageId String
reason String?
changedByOperatorId String
changedAt DateTime @default(now())
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
previousStage WorkStageSetting? @relation("PreviousStage", fields: [previousStageId], references: [id], onDelete: Restrict)
nextStage WorkStageSetting @relation("NextStage", fields: [nextStageId], references: [id], onDelete: Restrict)
changedByOperator OperatorAccount @relation(fields: [changedByOperatorId], references: [id], onDelete: Restrict)
@@index([workId, changedAt(sort: Desc)])
}
model LocationChangeHistory {
id String @id @default(uuid())
workId String
previousShelfSlotId String?
nextShelfSlotId String
changedByOperatorId String
changedAt DateTime @default(now())
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
previousShelfSlot ShelfSlot? @relation("PreviousShelfSlot", fields: [previousShelfSlotId], references: [id], onDelete: Restrict)
nextShelfSlot ShelfSlot @relation("NextShelfSlot", fields: [nextShelfSlotId], references: [id], onDelete: Restrict)
changedByOperator OperatorAccount @relation(fields: [changedByOperatorId], references: [id], onDelete: Restrict)
@@index([workId, changedAt(sort: Desc)])
}
model WorkNumberCorrectionHistory {
id String @id @default(uuid())
workId String
previousWorkNumber String
nextWorkNumber String
changedByOperatorId String
changedAt DateTime @default(now())
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
changedByOperator OperatorAccount @relation(fields: [changedByOperatorId], references: [id], onDelete: Restrict)
@@index([workId, changedAt(sort: Desc)])
}
model Kiln {
id String @id @default(uuid())
studioId String
name String
kilnType KilnType
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
kilnCandidateGroups KilnCandidateGroup[]
@@index([studioId, kilnType, isActive])
}
model KilnCandidateGroup {
id String @id @default(uuid())
studioId String
kilnId String
stageType WorkStageCode
plannedDate DateTime? @db.Date
status KilnCandidateStatus @default(DRAFT)
selectedByOperatorId String?
selectedAt DateTime?
memo String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
studio Studio @relation(fields: [studioId], references: [id], onDelete: Restrict)
kiln Kiln @relation(fields: [kilnId], references: [id], onDelete: Restrict)
selectedByOperator OperatorAccount? @relation("KilnCandidateSelectedBy", fields: [selectedByOperatorId], references: [id], onDelete: Restrict)
candidateWorks KilnCandidateWork[]
@@index([studioId, stageType, status])
@@index([kilnId, plannedDate])
}
model KilnCandidateWork {
id String @id @default(uuid())
kilnCandidateGroupId String
workId String
addedByOperatorId String
addedAt DateTime @default(now())
memo String?
kilnCandidateGroup KilnCandidateGroup @relation(fields: [kilnCandidateGroupId], references: [id], onDelete: Cascade)
work Work @relation(fields: [workId], references: [id], onDelete: Restrict)
addedByOperator OperatorAccount @relation(fields: [addedByOperatorId], references: [id], onDelete: Restrict)
@@unique([kilnCandidateGroupId, workId])
@@index([workId])
}
8. 구현 시 확인이 필요한 항목
-
월 100점 이상 작품 번호 규칙
현재 형식 2608-01은 순번 두 자리이므로 월 최대 99점만 표현할 수 있습니다. 월 약 130점을 처리하려면 01~99 이후 사용할 두 자리 영문·숫자 조합 또는 월 번호 기준 변경을 실제 공방 운영 규칙으로 확정해야 합니다. (FR-006)
-
가마 예정일 반영 방식
다음 가마 후보를 선택했을 때 후보 묶음의 가마 예정일을 모든 후보 작품에 자동 반영할지, 운영자가 작품별로 확인 후 반영할지 정해야 합니다. 첫 개발에서는 운영자가 선택한 후보에만 반영하는 방식이 안전합니다. (FR-014, FR-015)
-
작품 크기 입력 기준
다음 가마 후보 비교를 위해 폭·깊이·높이를 밀리미터 단위로 제안했습니다. 현장에서 실제로 줄자 측정이 가능한지 확인한 뒤, 첫 개발에서는 높이만 필수 또는 모두 선택 입력으로 정해야 합니다. (FR-007, FR-014)
-
회원 전용 접근 유지 방식
최초 접근 코드 확인 이후 회원이 매번 코드를 입력할지, 브라우저에 안전한 로그인 상태를 유지할지 정해야 합니다. 회원이 공용 기기를 사용하는 경우 로그아웃 기능과 접근 취소 기능이 필요합니다. (FR-001, FR-016, NFR-002)