나중에 바꾸기 가장 어려운 부분

데이터 구조

무엇을 저장하고 서로 어떻게 이어지는지 관계도와 함께.

29,625자 · 시스템이 만든 그대로입니다

사람이 손댄 곳 4군데
  • · ‘전체 개발 안내’ 문서의 제목 한 줄에 내부에서 쓰는 단계 이름이 섞여 나와 그 단어만 지웠습니다. 본문은 손대지 않았습니다.
  • · 화면 목록에서 같은 일을 하는 화면이 두 개 만들어져 하나로 합쳤습니다. 3단계의 ‘화면 목록 다듬기’에서 누구나 하는 일입니다.
  • · ‘추가 확인 사항’은 부록 서너 항목만 쓰라고 시켰는데 제품 정의서를 통째로 한 벌 더 썼습니다. 지시를 분명히 고친 뒤 그 문서 하나만 다시 만들었습니다. 사람이 문장을 쓴 것은 아닙니다.
  • · 만들어진 뒤에 두 가지를 더 고쳤습니다. 화면 상태를 확인하는 개발용 링크가 서비스 이름보다 위에 있어 맨 아래로 접어 내렸고, 디자인 계약이 정한 글꼴을 실제로 불러오지 않아 기기마다 글자가 달라 보이던 것을 바로잡았습니다. 둘 다 화면을 보고 눈에 띈 것을 말로 적어 보낸 것입니다.

도자 공방 운영 허브 데이터 구조 문서

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 — 생활도자 공방

필드형식설명관련 요구사항
idUUID생활도자 공방 식별자FR-001, NFR-001
name문자열생활도자 공방 이름FR-001
timezone문자열날짜 계산 기준 시간대. 기본값 Asia/SeoulFR-015
setupStatus열거형초기 설정 진행 상태FR-002
setupConfirmedAt날짜시간, 선택운영 시작을 확정한 시각FR-002
createdAt / updatedAt날짜시간생성·수정 시각NFR-007

setupStatus 값은 NOT_STARTED, IN_PROGRESS, COMPLETED를 사용합니다.

OperatorAccount — 대표 운영자·보조 강사 운영 계정

필드형식설명관련 요구사항
idUUID운영 계정 식별자FR-001
studioIdUUID소속 생활도자 공방FR-001, NFR-001
name문자열대표 운영자 또는 보조 강사 이름FR-001
loginId문자열로그인 식별자FR-001
passwordHash문자열비밀번호 원문이 아닌 암호화 값FR-001, NFR-002
role열거형LEAD_OPERATOR, ASSISTANT_INSTRUCTORFR-001, FR-003
isActive불리언비활성 계정 로그인 차단 여부FR-001
lastLoginAt날짜시간, 선택최근 로그인 시각FR-001
createdAt / updatedAt날짜시간생성·수정 시각NFR-007
  • 대표 운영자만 생활도자 공방 작업 기준을 변경할 수 있습니다. (FR-003)
  • 보조 강사는 회원, 작품, 단계와 선반 칸을 관리할 수 있습니다. (FR-004, FR-007, FR-010)

3-2. 회원과 회원 전용 접근

Member — 회원

필드형식설명관련 요구사항
idUUID회원 식별자FR-004
studioIdUUID소속 생활도자 공방FR-004, NFR-001
name문자열회원 이름FR-004
contactHint문자열, 선택동명이인 구분을 위한 최소 식별 정보. 예: 전화번호 끝 4자리FR-004, NFR-002
memberType열거형MONTHLY_MEMBER, TEN_SESSION_MEMBER, EXPERIENCE_MEMBER, OTHERFR-004, FR-005
isActive불리언비활성 회원 여부FR-004
memo문자열, 선택운영 메모. 할인 구분 등FR-005
createdAt / updatedAt날짜시간생성·수정 시각NFR-007
version정수동시 수정 검사용 버전NFR-003

contactHint에는 전체 전화번호를 기본 저장하지 않습니다. 동명이인 구분에 필요한 최소 정보만 저장합니다.

MemberAccess — 회원 전용 내 작품 조회 접근 정보

필드형식설명관련 요구사항
idUUID접근 정보 식별자FR-001, FR-016
memberIdUUID접근 대상 회원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 — 회원권

필드형식설명관련 요구사항
idUUID회원권 식별자FR-005
memberIdUUID회원권을 가진 회원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 — 회원권 잔여 횟수 변경 기록

필드형식설명관련 요구사항
idUUID변경 기록 식별자FR-005
membershipPassIdUUID변경된 회원권FR-005
beforeCount정수변경 전 잔여 횟수FR-005
afterCount정수변경 후 잔여 횟수FR-005
reason문자열차감 또는 정정 사유FR-005
changedByOperatorIdUUID변경한 대표 운영자 또는 보조 강사FR-005
changedAt날짜시간변경 시각FR-005
  • 첫 개발에서는 예약 기능이 없으므로 잔여 횟수를 자동 차감하지 않습니다.
  • 운영자가 잔여 횟수 변경 사유를 입력해야만 변경 기록을 저장합니다. (FR-005)

3-4. 공방 작업 기준: 현재 제작 단계, 선반, 선반 칸

WorkStageSetting — 현재 제작 단계 설정

필드형식설명관련 요구사항
idUUID단계 설정 식별자FR-003
studioIdUUID소속 생활도자 공방FR-003, NFR-001
code열거형DRYING, BISQUE_WAITING, BISQUE_COMPLETED, GLAZING, GLAZE_WAITING, COMPLETEDFR-003
displayName문자열화면에 표시할 단계명FR-003
sortOrder정수목록 표시 순서FR-009, FR-012
defaultDaysToCompletion정수, 선택이 단계에서 완성까지의 기본 소요 기간FR-003, FR-015
isActive불리언현재 사용하는 단계 여부FR-003
createdAt / updatedAt날짜시간생성·수정 시각NFR-007

기본 단계는 아래 여섯 가지입니다.

  1. 건조
  2. 초벌 대기
  3. 초벌 완료
  4. 시유
  5. 재벌 대기
  6. 완성

초벌 대기와 재벌 대기는 같은 목록이나 같은 다음 가마 후보에 섞지 않습니다. (FR-013, FR-014)

Shelf — 선반

필드형식설명관련 요구사항
idUUID선반 식별자FR-003
studioIdUUID소속 생활도자 공방FR-003, NFR-001
stageSettingIdUUID이 선반을 사용하는 현재 제작 단계FR-003
shelfNumber문자열선반 번호. 예: 2번 선반FR-003
displayName문자열화면 표시명. 예: 초벌 대기 2번 선반FR-003
shelfPhotoUrl문자열, 선택선반 사진 저장 주소FR-003, FR-014
isActive불리언현재 사용 여부FR-003
createdAt / updatedAt날짜시간생성·수정 시각NFR-007

ShelfSlot — 선반 칸

필드형식설명관련 요구사항
idUUID선반 칸 식별자FR-003
shelfIdUUID소속 선반FR-003
slotNumber문자열선반 안의 칸 번호. 예: 1칸FR-003
isActive불리언현재 사용 여부FR-003
createdAt / updatedAt날짜시간생성·수정 시각NFR-007
  • 선반 칸의 실제 위치는 초벌 대기 2번 선반 1칸처럼 단계, 선반, 선반 칸을 조합해 표시합니다.
  • 작품이 있는 선반과 선반 칸은 바로 삭제하지 않고 비활성화하거나 작품을 먼저 이동시켜야 합니다. (FR-003)
  • 작품 단계 변경 시 새 단계에 연결된 선반의 선반 칸만 선택할 수 있습니다. (FR-007, FR-010)

3-5. 작품과 작품 사진

Work — 작품

필드형식설명관련 요구사항
idUUID작품 내부 식별자FR-007
studioIdUUID소속 생활도자 공방FR-006, NFR-001
memberIdUUID작품 소유 회원FR-007
workNumber문자열작품 바닥에 새긴 작품 번호FR-006, FR-008
currentStageIdUUID현재 제작 단계FR-007, FR-010
currentShelfSlotIdUUID현재 실제 보관 선반 칸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, COMPLETEDFR-015
completedAt날짜시간, 선택완성 처리 시각FR-015, FR-016
lastStageChangedAt날짜시간마지막 단계 변경 시각FR-010, FR-015
createdByOperatorIdUUID최초 등록한 운영자FR-007
createdAt / updatedAt날짜시간생성·수정 시각NFR-007
version정수동시 수정 검사용 버전FR-007, FR-010, FR-011, NFR-003

WorkPhoto — 작품 사진

필드형식설명관련 요구사항
idUUID작품 사진 식별자FR-007
workIdUUID연결 작품FR-007
storageKey문자열이미지 저장소 내부 경로NFR-002
displayOrder정수사진 표시 순서FR-007, FR-016
isPrimary불리언목록과 회원 화면에 우선 표시할 사진FR-007, FR-009, FR-016
uploadedByOperatorIdUUID사진을 등록한 운영자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 — 단계 변경 이력

필드형식설명관련 요구사항
idUUID단계 변경 이력 식별자FR-010
workIdUUID변경된 작품FR-010
previousStageIdUUID, 선택변경 전 현재 제작 단계FR-010
nextStageIdUUID변경 후 현재 제작 단계FR-010
reason문자열, 선택이전 단계로 되돌릴 때 정정 사유FR-010
changedByOperatorIdUUID변경한 운영자FR-010
changedAt날짜시간변경 시각FR-010

LocationChangeHistory — 위치 변경 이력

필드형식설명관련 요구사항
idUUID위치 변경 이력 식별자FR-010
workIdUUID변경된 작품FR-010
previousShelfSlotIdUUID, 선택이전 선반 칸FR-010
nextShelfSlotIdUUID새 선반 칸FR-010
changedByOperatorIdUUID변경한 운영자FR-010
changedAt날짜시간변경 시각FR-010

WorkNumberCorrectionHistory — 작품 번호 정정 기록

필드형식설명관련 요구사항
idUUID작품 번호 정정 기록 식별자FR-006
workIdUUID정정된 작품FR-006
previousWorkNumber문자열정정 전 작품 번호FR-006
nextWorkNumber문자열정정 후 작품 번호FR-006
changedByOperatorIdUUID정정한 운영자FR-006
changedAt날짜시간정정 시각FR-006
  • 작품의 단계와 선반 칸을 한 번에 변경하면 StageChangeHistoryLocationChangeHistory를 함께 남깁니다. (FR-010)
  • 선반 칸만 이동하는 경우에도 LocationChangeHistory를 남깁니다. (FR-010)
  • 여러 작품 일괄 변경은 작품별로 각각의 이력 행을 생성합니다. (FR-011)

3-7. 가마와 다음 가마 후보

Kiln — 가마

필드형식설명관련 요구사항
idUUID가마 식별자FR-013, FR-014
studioIdUUID소속 생활도자 공방NFR-001
name문자열가마 이름 또는 구분명FR-014
kilnType열거형BISQUE, GLAZEFR-013, FR-014
isActive불리언현재 사용 여부FR-014
createdAt / updatedAt날짜시간생성·수정 시각NFR-007

첫 개발에서는 가마 내부 크기, 적재 위치, 자동 채움 비율 계산을 저장하지 않습니다. 채움 비율 계산은 가마 크기와 작품 부피 기준이 확정된 후 추가합니다.

KilnCandidateGroup — 다음 가마 후보 묶음

필드형식설명관련 요구사항
idUUID후보 묶음 식별자FR-014
studioIdUUID소속 생활도자 공방NFR-001
kilnIdUUID비교 대상 가마FR-014
stageType열거형BISQUE_WAITING, GLAZE_WAITINGFR-013, FR-014
plannedDate날짜, 선택운영자가 정한 가마 예정일FR-014, FR-015
status열거형DRAFT, SELECTED, ARCHIVEDFR-014
selectedByOperatorIdUUID, 선택후보를 선택한 운영자FR-014
selectedAt날짜시간, 선택후보 선택 시각FR-014
memo문자열, 선택비교·선택 메모FR-014
createdAt / updatedAt날짜시간생성·수정 시각NFR-007

KilnCandidateWork — 다음 가마 후보 작품

필드형식설명관련 요구사항
idUUID후보 작품 식별자FR-014
kilnCandidateGroupIdUUID다음 가마 후보 묶음FR-014
workIdUUID후보 작품FR-014
addedByOperatorIdUUID후보에 넣은 운영자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. 필요한 인덱스와 제약 조건

대상인덱스 또는 제약목적관련 요구사항
WorkUNIQUE(studioId, workNumber)공방 안 작품 번호 중복 방지FR-006, NFR-001
WorkINDEX(studioId, currentStageId, currentShelfSlotId)단계별·선반 칸별 작품 목록 빠른 조회FR-009, FR-012, NFR-004
WorkINDEX(studioId, memberId)회원 상세의 연결 작품 조회FR-004, FR-016
WorkINDEX(studioId, kilnPlannedDate)가마 예정일 기준 후보 확인FR-014, FR-015
MemberINDEX(studioId, name)공방 안 회원 검색FR-004, NFR-004
ShelfUNIQUE(studioId, shelfNumber)공방 안 선반 번호 중복 방지FR-003
ShelfSlotUNIQUE(shelfId, slotNumber)같은 선반의 칸 번호 중복 방지FR-003
WorkStageSettingUNIQUE(studioId, code)공방별 같은 기본 단계 중복 방지FR-003
StageChangeHistoryINDEX(workId, changedAt DESC)작품 상세에서 최근 단계 이력 조회FR-008, FR-010
LocationChangeHistoryINDEX(workId, changedAt DESC)작품 상세에서 최근 위치 이력 조회FR-008, FR-010
MembershipPassINDEX(memberId, isActive, validUntil)회원의 사용 중 회원권·만료 상태 확인FR-005
KilnCandidateWorkUNIQUE(kilnCandidateGroupId, workId)같은 후보 묶음에 작품 중복 추가 방지FR-014
MemberAccessUNIQUE(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. 구현 시 확인이 필요한 항목

  1. 월 100점 이상 작품 번호 규칙
    현재 형식 2608-01은 순번 두 자리이므로 월 최대 99점만 표현할 수 있습니다. 월 약 130점을 처리하려면 01~99 이후 사용할 두 자리 영문·숫자 조합 또는 월 번호 기준 변경을 실제 공방 운영 규칙으로 확정해야 합니다. (FR-006)

  2. 가마 예정일 반영 방식
    다음 가마 후보를 선택했을 때 후보 묶음의 가마 예정일을 모든 후보 작품에 자동 반영할지, 운영자가 작품별로 확인 후 반영할지 정해야 합니다. 첫 개발에서는 운영자가 선택한 후보에만 반영하는 방식이 안전합니다. (FR-014, FR-015)

  3. 작품 크기 입력 기준
    다음 가마 후보 비교를 위해 폭·깊이·높이를 밀리미터 단위로 제안했습니다. 현장에서 실제로 줄자 측정이 가능한지 확인한 뒤, 첫 개발에서는 높이만 필수 또는 모두 선택 입력으로 정해야 합니다. (FR-007, FR-014)

  4. 회원 전용 접근 유지 방식
    최초 접근 코드 확인 이후 회원이 매번 코드를 입력할지, 브라우저에 안전한 로그인 상태를 유지할지 정해야 합니다. 회원이 공용 기기를 사용하는 경우 로그아웃 기능과 접근 취소 기능이 필요합니다. (FR-001, FR-016, NFR-002)

데이터 구조 — 도예공방 운영 관리 서비스 | Prometheon