// ============================================================================= // Startover MVP - Prisma Schema // ============================================================================= // 설계 원칙: // - 내부 PK: BigInt @id @default(autoincrement()) // - 외부 노출 ID: publicId String @unique @default(cuid()) // - 금액: Decimal @db.Decimal(14, 2) // - 시각: DateTime @db.Timestamptz(6) // - Prisma 모델명: PascalCase / 필드명: camelCase // - DB 테이블/컬럼: snake_case (@@map / @map) // - append-only 모델: updatedAt 없이 createdAt만 // - soft delete 필요 모델만 deletedAt // // Prisma 미지원 제약 (custom SQL migration에서 추가): // 1. partial unique index (열린 매칭 요청 중복 방지 등) // 2. CHECK constraints (amount >= 0, exclusiveAreaSqm > 0 등) // 3. PostGIS GIST index (향후 locationPoint) // ============================================================================= generator client { provider = "prisma-client-js" previewFeatures = ["postgresqlExtensions"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") extensions = [] } // ============================================================================= // Enums // ============================================================================= enum UserRole { CLOSING_OWNER FOUNDER VENDOR_MANAGER OPS_MANAGER SUBSIDY_OPERATOR TRUST_OPERATOR FINANCE_OPERATOR SUPER_ADMIN @@map("user_role") } enum UserStatus { PENDING_VERIFICATION ACTIVE SUSPENDED DEACTIVATED @@map("user_status") } enum ProfileType { CLOSING_OWNER FOUNDER VENDOR_MANAGER OPERATOR @@map("profile_type") } enum ConsentType { TERMS_OF_SERVICE PRIVACY_POLICY_REQUIRED PRIVACY_POLICY_MARKETING STORE_PUBLICATION_CONSENT MATCHED_INFO_DISCLOSURE THIRD_PARTY_MATCHED_PARTY NOTIFICATION_KAKAO @@map("consent_type") } enum StoreReviewStatus { DRAFT SUBMITTED REVIEWING APPROVED REJECTED @@map("store_review_status") } enum StorePublicationStatus { PRIVATE RESTRICTED PUBLISHED UNPUBLISHED @@map("store_publication_status") } enum StoreDealStatus { OPEN MATCHING RESERVED CONTRACTED CLOSED CANCELLED @@map("store_deal_status") } enum PhotoCategory { EXTERIOR INTERIOR KITCHEN EQUIPMENT FLOOR_PLAN DOCUMENT @@map("photo_category") } enum VisibilityScope { INTERNAL MATCHED_ONLY PUBLIC_SUMMARY @@map("visibility_scope") } enum MatchType { ACQUISITION DEMOLITION INTERIOR @@map("match_type") } enum MatchRequestStatus { OPEN REVIEWING ACCEPTED REJECTED CONTRACTING EXPIRED CANCELLED COMPLETED @@map("match_request_status") } enum MatchSourceType { USER_REQUEST OPERATOR_RECOMMENDATION SYSTEM_MATCH @@map("match_source_type") } enum SubsidyCaseStatus { DRAFT ELIGIBILITY_CHECKED DOCUMENTS_PENDING REVIEWING READY_TO_SUBMIT SUBMITTED APPROVED REJECTED CLOSED @@map("subsidy_case_status") } enum EligibilityResult { UNKNOWN ELIGIBLE CONDITIONALLY_ELIGIBLE NOT_ELIGIBLE @@map("eligibility_result") } enum ChecklistItemStatus { PENDING CHECKED NOT_APPLICABLE @@map("checklist_item_status") } enum DocumentReviewStatus { PENDING APPROVED REJECTED RESUBMIT_REQUIRED @@map("document_review_status") } enum VendorType { DEMOLITION INTERIOR BOTH @@map("vendor_type") } enum VendorCertificationStatus { APPLIED REVIEWING APPROVED REJECTED SUSPENDED EXPIRED @@map("vendor_certification_status") } enum ContractType { ACQUISITION DEMOLITION INTERIOR @@map("contract_type") } enum ContractStatus { DRAFT GENERATED SIGNING SIGNED ACTIVE COMPLETED CANCELLED TERMINATED @@map("contract_status") } enum EscrowStatus { NOT_STARTED DEPOSIT_PENDING HOLDING RELEASE_REVIEW RELEASED REFUNDED DISPUTED @@map("escrow_status") } enum EscrowTransactionType { DEPOSIT RELEASE REFUND ADJUSTMENT HOLD @@map("escrow_transaction_type") } enum EscrowTransactionStatus { PENDING PROCESSING COMPLETED FAILED CANCELLED @@map("escrow_transaction_status") } enum SignerRole { STORE_OWNER BUYER VENDOR OPERATOR WITNESS @@map("signer_role") } enum EvidenceType { ELECTRONIC_SIGNATURE SMS_OTP APP_CONSENT MANUAL_UPLOAD @@map("evidence_type") } enum InspectionType { PRE_CONTRACT MID_WORK FINAL_COMPLETION @@map("inspection_type") } enum InspectionStatus { REQUESTED SUBMITTED REVIEWING APPROVED REJECTED @@map("inspection_status") } enum DisputeStatus { OPEN INVESTIGATING MEDIATING RESOLVED CLOSED @@map("dispute_status") } enum PolicyType { PRIVACY_POLICY TERMS_OF_SERVICE STORE_LISTING_POLICY CONTRACT_TEMPLATE SUBSIDY_CHECKLIST @@map("policy_type") } enum PublishStatus { PENDING PUBLISHED FAILED @@map("publish_status") } enum RegionType { COUNTRY SIDO SIGUNGU ADMIN_DONG COMMERCIAL_AREA BETA_CLUSTER @@map("region_type") } enum UrgencyLevel { LOW MEDIUM HIGH CRITICAL @@map("urgency_level") } enum RestroomType { PRIVATE SHARED NONE @@map("restroom_type") } enum PiiLevel { NONE LOW MEDIUM HIGH @@map("pii_level") } // ============================================================================= // 1. 사용자/권한 // ============================================================================= /// 사용자 기본 계정과 로그인 주체 model User { id BigInt @id @default(autoincrement()) @map("id") publicId String @unique @default(cuid()) @map("public_id") email String @map("email") emailNormalized String @unique @map("email_normalized") phone String? @map("phone") phoneNormalized String? @map("phone_normalized") name String @map("name") passwordHash String? @map("password_hash") image String? @map("image") primaryRole UserRole @map("primary_role") status UserStatus @default(PENDING_VERIFICATION) @map("status") emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(6) lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations profiles UserProfile[] consents UserConsent[] stores Store[] vendors Vendor[] matchRequestsAsRequester MatchRequest[] @relation("MatchRequesterUser") matchRequestsAsOperator MatchRequest[] @relation("MatchOperatorAssignee") subsidyCasesAsApplicant SubsidyCase[] @relation("SubsidyApplicantUser") subsidyCasesAsOperator SubsidyCase[] @relation("SubsidyOperatorAssignee") subsidyChecklistCheckedBy SubsidyChecklistItem[] @relation("ChecklistCheckedByUser") subsidyDocsUploadedBy SubsidyDocument[] @relation("SubsidyDocUploadedByUser") subsidyDocsReviewedBy SubsidyDocument[] @relation("SubsidyDocReviewedByUser") storePhotosUploadedBy StorePhoto[] contractsCreatedBy Contract[] @relation("ContractCreatedByUser") contractsAsStoreOwner Contract[] @relation("ContractStoreOwnerUser") contractsAsBuyer Contract[] @relation("ContractBuyerUser") contractVersionsCreatedBy ContractVersion[] signaturesAsSigner SignatureEvidence[] escrowRequested EscrowTransaction[] @relation("EscrowRequestedByUser") escrowApproved EscrowTransaction[] @relation("EscrowApprovedByUser") inspectionsSubmittedBy InspectionRecord[] @relation("InspectionSubmittedByUser") inspectionsReviewedBy InspectionRecord[] @relation("InspectionReviewedByUser") disputesOpenedBy DisputeCase[] @relation("DisputeOpenedByUser") disputesAssignedOperator DisputeCase[] @relation("DisputeAssignedOperator") disputesResolvedBy DisputeCase[] @relation("DisputeResolvedByUser") certificationReviewedBy VendorCertification[] @relation("CertificationReviewedByUser") auditLogs AuditLog[] accounts Account[] invitesCreated InviteToken[] @relation("InviteCreator") @@index([primaryRole, status], map: "idx_user_role_status") @@map("users") } /// 역할별 추가 프로필 정보 확장 테이블 model UserProfile { id BigInt @id @default(autoincrement()) @map("id") userId BigInt @map("user_id") profileType ProfileType @map("profile_type") businessRegistrationNumber String? @map("business_registration_number") metadataJson Json? @map("metadata_json") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations user User @relation(fields: [userId], references: [id]) @@index([userId], map: "idx_user_profile_user_id") @@map("user_profiles") } /// 개인정보/마케팅/제3자 제공 동의를 버전 단위로 저장 model UserConsent { id BigInt @id @default(autoincrement()) @map("id") userId BigInt @map("user_id") consentType ConsentType @map("consent_type") policyVersionId BigInt @map("policy_version_id") isGranted Boolean @map("is_granted") grantedAt DateTime @map("granted_at") @db.Timestamptz(6) revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) // Relations user User @relation(fields: [userId], references: [id]) policyVersion PolicyVersion @relation(fields: [policyVersionId], references: [id]) @@index([userId, consentType], map: "idx_user_consent_user_type") @@map("user_consents") } /// Auth.js 소셜 로그인 계정 연동 model Account { id BigInt @id @default(autoincrement()) @map("id") userId BigInt @map("user_id") type String @map("type") provider String @map("provider") providerAccountId String @map("provider_account_id") refresh_token String? @map("refresh_token") @db.Text access_token String? @map("access_token") @db.Text expires_at Int? @map("expires_at") token_type String? @map("token_type") scope String? @map("scope") id_token String? @map("id_token") @db.Text session_state String? @map("session_state") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) @@map("accounts") } /// 이메일 인증 토큰 model VerificationToken { identifier String @map("identifier") token String @unique @map("token") expires DateTime @map("expires") @db.Timestamptz(6) @@id([identifier, token]) @@map("verification_tokens") } /// 운영자 초대 토큰 model InviteToken { id BigInt @id @default(autoincrement()) @map("id") email String @map("email") role UserRole @map("role") token String @unique @default(cuid()) @map("token") expires DateTime @map("expires") @db.Timestamptz(6) usedAt DateTime? @map("used_at") @db.Timestamptz(6) createdBy BigInt @map("created_by") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) creator User @relation("InviteCreator", fields: [createdBy], references: [id]) @@map("invite_tokens") } // ============================================================================= // 2. 매장 공급 도메인 // ============================================================================= /// 폐업자가 등록한 매장의 중심 aggregate model Store { id BigInt @id @default(autoincrement()) @map("id") publicId String @unique @default(cuid()) @map("public_id") ownerUserId BigInt @map("owner_user_id") listingTitle String @map("listing_title") publicSummary String? @map("public_summary") industryLeafId BigInt? @map("industry_leaf_id") regionClusterId BigInt? @map("region_cluster_id") regionLeafId BigInt? @map("region_leaf_id") roadAddress String @map("road_address") detailAddress String? @map("detail_address") latitude Decimal? @map("latitude") @db.Decimal(10, 7) longitude Decimal? @map("longitude") @db.Decimal(10, 7) reviewStatus StoreReviewStatus @default(DRAFT) @map("review_status") publicationStatus StorePublicationStatus @default(PRIVATE) @map("publication_status") dealStatus StoreDealStatus @default(OPEN) @map("deal_status") policyVersionId BigInt? @map("policy_version_id") publishedAt DateTime? @map("published_at") @db.Timestamptz(6) approvedAt DateTime? @map("approved_at") @db.Timestamptz(6) rejectedAt DateTime? @map("rejected_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations ownerUser User @relation(fields: [ownerUserId], references: [id]) industryLeaf IndustryTaxonomy? @relation(fields: [industryLeafId], references: [id]) regionCluster RegionHierarchy? @relation("StoreRegionCluster", fields: [regionClusterId], references: [id]) regionLeaf RegionHierarchy? @relation("StoreRegionLeaf", fields: [regionLeafId], references: [id]) policyVersion PolicyVersion? @relation(fields: [policyVersionId], references: [id]) lease StoreLease? facility StoreFacility? lifecycle StoreLifecycle? photos StorePhoto[] matchRequests MatchRequest[] subsidyCases SubsidyCase[] contracts Contract[] @@index([ownerUserId, createdAt], map: "idx_store_owner_created") @@index([reviewStatus, createdAt], map: "idx_store_review_created") @@index([regionClusterId, industryLeafId, publicationStatus, dealStatus, publishedAt], map: "idx_store_search") @@map("stores") } /// 임대/권리금 관련 정보 (Store 1:1 확장) /// CHECK constraint: amount fields >= 0 (custom SQL migration) model StoreLease { id BigInt @id @default(autoincrement()) @map("id") storeId BigInt @unique @map("store_id") depositAmount Decimal? @map("deposit_amount") @db.Decimal(14, 2) monthlyRentAmount Decimal? @map("monthly_rent_amount") @db.Decimal(14, 2) maintenanceFeeAmount Decimal? @map("maintenance_fee_amount") @db.Decimal(14, 2) premiumAmount Decimal? @map("premium_amount") @db.Decimal(14, 2) transferable Boolean? @map("transferable") leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamptz(6) remainingLeaseMonths Int? @map("remaining_lease_months") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations store Store @relation(fields: [storeId], references: [id]) @@map("store_leases") } /// F&B 필터링과 인수 판단에 필요한 설비 정보 (Store 1:1 확장) /// CHECK constraint: exclusiveAreaSqm > 0 (custom SQL migration) model StoreFacility { id BigInt @id @default(autoincrement()) @map("id") storeId BigInt @unique @map("store_id") exclusiveAreaSqm Decimal? @map("exclusive_area_sqm") @db.Decimal(8, 2) floorLevel Int? @map("floor_level") seatCount Int? @map("seat_count") hasGas Boolean? @map("has_gas") hasDrainage Boolean? @map("has_drainage") hasDuct Boolean? @map("has_duct") electricCapacityKw Decimal? @map("electric_capacity_kw") @db.Decimal(6, 1) kitchenEquipmentSummary String? @map("kitchen_equipment_summary") parkingCount Int? @map("parking_count") restroomType RestroomType? @map("restroom_type") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations store Store @relation(fields: [storeId], references: [id]) @@map("store_facilities") } /// 폐업/인수/철거 일정 정보 (Store 1:1 확장) model StoreLifecycle { id BigInt @id @default(autoincrement()) @map("id") storeId BigInt @unique @map("store_id") closingPlannedAt DateTime? @map("closing_planned_at") @db.Timestamptz(6) takeoverAvailableAt DateTime? @map("takeover_available_at") @db.Timestamptz(6) demolitionPreferredAt DateTime? @map("demolition_preferred_at") @db.Timestamptz(6) vacateByAt DateTime? @map("vacate_by_at") @db.Timestamptz(6) isCurrentlyOperating Boolean? @map("is_currently_operating") urgencyLevel UrgencyLevel? @map("urgency_level") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations store Store @relation(fields: [storeId], references: [id]) @@map("store_lifecycles") } /// 매장 사진과 공개 범위 (append-only) model StorePhoto { id BigInt @id @default(autoincrement()) @map("id") storeId BigInt @map("store_id") storageKey String @map("storage_key") photoCategory PhotoCategory @map("photo_category") visibilityScope VisibilityScope @default(INTERNAL) @map("visibility_scope") sortOrder Int @default(0) @map("sort_order") uploadedByUserId BigInt @map("uploaded_by_user_id") checksumSha256 String? @map("checksum_sha256") width Int? @map("width") height Int? @map("height") takenAt DateTime? @map("taken_at") @db.Timestamptz(6) isRepresentative Boolean @default(false) @map("is_representative") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations store Store @relation(fields: [storeId], references: [id]) uploadedByUser User @relation(fields: [uploadedByUserId], references: [id]) @@index([storeId, sortOrder], map: "idx_store_photo_store_sort") @@map("store_photos") } // ============================================================================= // 3. 매칭 도메인 // ============================================================================= /// 창업자/업체/운영자 추천 기반 매칭 요청 /// matchType에 따라 필요한 FK가 달라지므로 raw SQL CHECK 제약 추가 (custom SQL migration) /// partial unique index: 열린 상태에서 동일 사용자/동일 매장/동일 요청 타입 중복 금지 (custom SQL migration) model MatchRequest { id BigInt @id @default(autoincrement()) @map("id") publicId String @unique @default(cuid()) @map("public_id") storeId BigInt @map("store_id") matchType MatchType @map("match_type") requesterUserId BigInt? @map("requester_user_id") requesterVendorId BigInt? @map("requester_vendor_id") sourceType MatchSourceType @map("source_type") status MatchRequestStatus @default(OPEN) @map("status") operatorAssigneeId BigInt? @map("operator_assignee_id") message String? @map("message") decisionReasonCode String? @map("decision_reason_code") acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(6) rejectedAt DateTime? @map("rejected_at") @db.Timestamptz(6) closedAt DateTime? @map("closed_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations store Store @relation(fields: [storeId], references: [id]) requesterUser User? @relation("MatchRequesterUser", fields: [requesterUserId], references: [id]) requesterVendor Vendor? @relation(fields: [requesterVendorId], references: [id]) operatorAssignee User? @relation("MatchOperatorAssignee", fields: [operatorAssigneeId], references: [id]) contract Contract? @@index([storeId, status, createdAt], map: "idx_match_store_status_created") @@index([requesterUserId, status, createdAt], map: "idx_match_requester_status_created") @@index([requesterVendorId, status, createdAt], map: "idx_match_vendor_status_created") @@map("match_requests") } // ============================================================================= // 4. 지원금 도메인 // ============================================================================= /// 매장 기준 지원금 진행 건 /// partial unique index: 같은 storeId + programCode 조합의 active case는 1개 (custom SQL migration) model SubsidyCase { id BigInt @id @default(autoincrement()) @map("id") publicId String @unique @default(cuid()) @map("public_id") storeId BigInt @map("store_id") applicantUserId BigInt @map("applicant_user_id") programCode String @map("program_code") status SubsidyCaseStatus @default(DRAFT) @map("status") eligibilityResult EligibilityResult @default(UNKNOWN) @map("eligibility_result") policyVersionId BigInt? @map("policy_version_id") operatorAssigneeId BigInt? @map("operator_assignee_id") checklistVersionCode String? @map("checklist_version_code") submissionReadyAt DateTime? @map("submission_ready_at") @db.Timestamptz(6) submittedAt DateTime? @map("submitted_at") @db.Timestamptz(6) reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6) rejectionReasonCode String? @map("rejection_reason_code") eligibilitySnapshotJson Json? @map("eligibility_snapshot_json") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations store Store @relation(fields: [storeId], references: [id]) applicantUser User @relation("SubsidyApplicantUser", fields: [applicantUserId], references: [id]) policyVersion PolicyVersion? @relation(fields: [policyVersionId], references: [id]) operatorAssignee User? @relation("SubsidyOperatorAssignee", fields: [operatorAssigneeId], references: [id]) checklistItems SubsidyChecklistItem[] documents SubsidyDocument[] @@index([storeId, status, createdAt], map: "idx_subsidy_store_status_created") @@index([applicantUserId, status, createdAt], map: "idx_subsidy_applicant_status_created") @@index([operatorAssigneeId, status, updatedAt], map: "idx_subsidy_operator_status_updated") @@map("subsidy_cases") } /// 정책 버전에 따라 생성된 체크리스트 항목 model SubsidyChecklistItem { id BigInt @id @default(autoincrement()) @map("id") subsidyCaseId BigInt @map("subsidy_case_id") itemCode String @map("item_code") isRequired Boolean @default(true) @map("is_required") status ChecklistItemStatus @default(PENDING) @map("status") checkedAt DateTime? @map("checked_at") @db.Timestamptz(6) checkedByUserId BigInt? @map("checked_by_user_id") // Relations subsidyCase SubsidyCase @relation(fields: [subsidyCaseId], references: [id]) checkedByUser User? @relation("ChecklistCheckedByUser", fields: [checkedByUserId], references: [id]) @@index([subsidyCaseId], map: "idx_subsidy_checklist_case") @@map("subsidy_checklist_items") } /// 지원금 첨부 서류와 검토 상태 model SubsidyDocument { id BigInt @id @default(autoincrement()) @map("id") subsidyCaseId BigInt @map("subsidy_case_id") documentTypeCode String @map("document_type_code") storageKey String @map("storage_key") reviewStatus DocumentReviewStatus @default(PENDING) @map("review_status") uploadedByUserId BigInt @map("uploaded_by_user_id") uploadedAt DateTime @default(now()) @map("uploaded_at") @db.Timestamptz(6) reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6) reviewedByUserId BigInt? @map("reviewed_by_user_id") // Relations subsidyCase SubsidyCase @relation(fields: [subsidyCaseId], references: [id]) uploadedByUser User @relation("SubsidyDocUploadedByUser", fields: [uploadedByUserId], references: [id]) reviewedByUser User? @relation("SubsidyDocReviewedByUser", fields: [reviewedByUserId], references: [id]) @@index([subsidyCaseId], map: "idx_subsidy_doc_case") @@map("subsidy_documents") } // ============================================================================= // 5. 업체 도메인 // ============================================================================= /// 철거/인테리어 업체의 기본 프로필 model Vendor { id BigInt @id @default(autoincrement()) @map("id") publicId String @unique @default(cuid()) @map("public_id") ownerUserId BigInt @map("owner_user_id") vendorType VendorType @map("vendor_type") businessName String @map("business_name") contactName String @map("contact_name") contactPhoneNormalized String? @map("contact_phone_normalized") businessRegistrationNumber String? @map("business_registration_number") serviceIntro String? @map("service_intro") primaryRegionId BigInt? @map("primary_region_id") certificationStatus VendorCertificationStatus @default(APPLIED) @map("certification_status") latestCertificationId BigInt? @map("latest_certification_id") deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations ownerUser User @relation(fields: [ownerUserId], references: [id]) primaryRegion RegionHierarchy? @relation(fields: [primaryRegionId], references: [id]) coverageRegions VendorCoverageRegion[] certifications VendorCertification[] matchRequests MatchRequest[] contracts Contract[] @@index([ownerUserId], map: "idx_vendor_owner") @@index([vendorType, certificationStatus, createdAt], map: "idx_vendor_type_cert_created") @@map("vendors") } /// 업체 서비스 가능 지역 (다대다 조인) model VendorCoverageRegion { id BigInt @id @default(autoincrement()) @map("id") vendorId BigInt @map("vendor_id") regionId BigInt @map("region_id") isPrimary Boolean @default(false) @map("is_primary") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations vendor Vendor @relation(fields: [vendorId], references: [id]) region RegionHierarchy @relation(fields: [regionId], references: [id]) @@unique([vendorId, regionId], map: "uq_vendor_coverage_region") @@index([regionId, vendorId], map: "idx_vendor_coverage_region_region") @@map("vendor_coverage_regions") } /// 업체 인증 이력 model VendorCertification { id BigInt @id @default(autoincrement()) @map("id") vendorId BigInt @map("vendor_id") status VendorCertificationStatus @map("status") requestedScopeCode String? @map("requested_scope_code") documentChecklistJson Json? @map("document_checklist_json") appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(6) reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6) reviewedByUserId BigInt? @map("reviewed_by_user_id") reasonCode String? @map("reason_code") validUntil DateTime? @map("valid_until") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations vendor Vendor @relation(fields: [vendorId], references: [id]) reviewedByUser User? @relation("CertificationReviewedByUser", fields: [reviewedByUserId], references: [id]) @@index([vendorId, status], map: "idx_vendor_cert_vendor_status") @@map("vendor_certifications") } // ============================================================================= // 6. 신뢰 인프라 도메인 // ============================================================================= /// 매칭에서 생성된 계약의 현재 상태와 참여자 model Contract { id BigInt @id @default(autoincrement()) @map("id") publicId String @unique @default(cuid()) @map("public_id") matchRequestId BigInt @unique @map("match_request_id") storeId BigInt @map("store_id") contractType ContractType @map("contract_type") status ContractStatus @default(DRAFT) @map("status") createdByUserId BigInt @map("created_by_user_id") storeOwnerUserId BigInt @map("store_owner_user_id") buyerUserId BigInt? @map("buyer_user_id") vendorId BigInt? @map("vendor_id") policyVersionId BigInt? @map("policy_version_id") templateCode String? @map("template_code") currentVersionId BigInt? @map("current_version_id") escrowStatus EscrowStatus @default(NOT_STARTED) @map("escrow_status") signedAt DateTime? @map("signed_at") @db.Timestamptz(6) effectiveAt DateTime? @map("effective_at") @db.Timestamptz(6) completedAt DateTime? @map("completed_at") @db.Timestamptz(6) cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations matchRequest MatchRequest @relation(fields: [matchRequestId], references: [id]) store Store @relation(fields: [storeId], references: [id]) createdByUser User @relation("ContractCreatedByUser", fields: [createdByUserId], references: [id]) storeOwnerUser User @relation("ContractStoreOwnerUser", fields: [storeOwnerUserId], references: [id]) buyerUser User? @relation("ContractBuyerUser", fields: [buyerUserId], references: [id]) vendor Vendor? @relation(fields: [vendorId], references: [id]) policyVersion PolicyVersion? @relation(fields: [policyVersionId], references: [id]) versions ContractVersion[] signatures SignatureEvidence[] escrowTransactions EscrowTransaction[] inspections InspectionRecord[] disputes DisputeCase[] @@index([storeId, status, createdAt], map: "idx_contract_store_status_created") @@index([vendorId, status, createdAt], map: "idx_contract_vendor_status_created") @@index([buyerUserId, status, createdAt], map: "idx_contract_buyer_status_created") @@map("contracts") } /// 계약 문서 불변 버전 (append-only) model ContractVersion { id BigInt @id @default(autoincrement()) @map("id") contractId BigInt @map("contract_id") versionNo Int @map("version_no") templateCode String? @map("template_code") templateVersion String? @map("template_version") documentStorageKey String? @map("document_storage_key") documentSha256 String? @map("document_sha256") renderedSnapshotJson Json? @map("rendered_snapshot_json") changeSummary String? @map("change_summary") createdByUserId BigInt @map("created_by_user_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations contract Contract @relation(fields: [contractId], references: [id]) createdByUser User @relation(fields: [createdByUserId], references: [id]) signatures SignatureEvidence[] @@unique([contractId, versionNo], map: "uq_contract_version") @@map("contract_versions") } /// 전자서명 또는 동의 증적 (append-only) model SignatureEvidence { id BigInt @id @default(autoincrement()) @map("id") contractId BigInt @map("contract_id") contractVersionId BigInt @map("contract_version_id") signerRole SignerRole @map("signer_role") signerUserId BigInt @map("signer_user_id") evidenceType EvidenceType @map("evidence_type") providerCode String? @map("provider_code") providerEventId String? @unique @map("provider_event_id") signedAt DateTime @map("signed_at") @db.Timestamptz(6) signatureHash String? @map("signature_hash") payloadJson Json? @map("payload_json") ipAddress String? @map("ip_address") userAgent String? @map("user_agent") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations contract Contract @relation(fields: [contractId], references: [id]) contractVersion ContractVersion @relation(fields: [contractVersionId], references: [id]) signerUser User @relation(fields: [signerUserId], references: [id]) @@index([contractVersionId, signedAt], map: "idx_signature_version_signed") @@map("signature_evidences") } /// 결제/환불/정산/보류 관련 금전 이벤트 immutable ledger (append-only) /// CHECK constraint: amount >= 0 (custom SQL migration) model EscrowTransaction { id BigInt @id @default(autoincrement()) @map("id") contractId BigInt @map("contract_id") transactionType EscrowTransactionType @map("transaction_type") providerCode String? @map("provider_code") providerTransactionId String? @unique @map("provider_transaction_id") status EscrowTransactionStatus @default(PENDING) @map("status") amount Decimal @map("amount") @db.Decimal(14, 2) currencyCode String @default("KRW") @map("currency_code") requestedByUserId BigInt? @map("requested_by_user_id") approvedByUserId BigInt? @map("approved_by_user_id") idempotencyKey String @unique @map("idempotency_key") rawPayloadJson Json? @map("raw_payload_json") occurredAt DateTime @map("occurred_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations contract Contract @relation(fields: [contractId], references: [id]) requestedByUser User? @relation("EscrowRequestedByUser", fields: [requestedByUserId], references: [id]) approvedByUser User? @relation("EscrowApprovedByUser", fields: [approvedByUserId], references: [id]) @@index([contractId, occurredAt], map: "idx_escrow_contract_occurred") @@index([status, occurredAt], map: "idx_escrow_status_occurred") @@map("escrow_transactions") } /// 검수 요청과 검토 결과 model InspectionRecord { id BigInt @id @default(autoincrement()) @map("id") contractId BigInt @map("contract_id") inspectionType InspectionType @map("inspection_type") status InspectionStatus @default(REQUESTED) @map("status") submittedByUserId BigInt? @map("submitted_by_user_id") submittedAt DateTime? @map("submitted_at") @db.Timestamptz(6) reviewedByUserId BigInt? @map("reviewed_by_user_id") reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6) reviewMemo String? @map("review_memo") evidencePayloadJson Json? @map("evidence_payload_json") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations contract Contract @relation(fields: [contractId], references: [id]) submittedByUser User? @relation("InspectionSubmittedByUser", fields: [submittedByUserId], references: [id]) reviewedByUser User? @relation("InspectionReviewedByUser", fields: [reviewedByUserId], references: [id]) @@index([contractId, status], map: "idx_inspection_contract_status") @@map("inspection_records") } /// 분쟁 접수, 조사, 중재, 종료 상태 /// partial unique index: 계약당 active dispute는 1건 (custom SQL migration) model DisputeCase { id BigInt @id @default(autoincrement()) @map("id") contractId BigInt @map("contract_id") openedByUserId BigInt @map("opened_by_user_id") assignedOperatorId BigInt? @map("assigned_operator_id") status DisputeStatus @default(OPEN) @map("status") reasonCode String @map("reason_code") description String? @map("description") resolutionCode String? @map("resolution_code") resolutionSummary String? @map("resolution_summary") evidencePayloadJson Json? @map("evidence_payload_json") openedAt DateTime @default(now()) @map("opened_at") @db.Timestamptz(6) resolvedAt DateTime? @map("resolved_at") @db.Timestamptz(6) resolvedByUserId BigInt? @map("resolved_by_user_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) // Relations contract Contract @relation(fields: [contractId], references: [id]) openedByUser User @relation("DisputeOpenedByUser", fields: [openedByUserId], references: [id]) assignedOperator User? @relation("DisputeAssignedOperator", fields: [assignedOperatorId], references: [id]) resolvedByUser User? @relation("DisputeResolvedByUser", fields: [resolvedByUserId], references: [id]) @@index([contractId, status, openedAt], map: "idx_dispute_contract_status_opened") @@index([assignedOperatorId, status, openedAt], map: "idx_dispute_operator_status_opened") @@map("dispute_cases") } // ============================================================================= // 7. 운영/분석 도메인 // ============================================================================= /// 정책 버전을 모델에 귀속시키기 위한 기준 테이블 model PolicyVersion { id BigInt @id @default(autoincrement()) @map("id") policyType PolicyType @map("policy_type") versionCode String @map("version_code") contentHash String @map("content_hash") effectiveFrom DateTime @map("effective_from") @db.Timestamptz(6) isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations stores Store[] contracts Contract[] subsidyCases SubsidyCase[] userConsents UserConsent[] @@index([policyType, isActive], map: "idx_policy_type_active") @@map("policy_versions") } /// 누가 무엇을 왜 바꿨는지를 운영 감사 기준으로 남긴다 (append-only) model AuditLog { id BigInt @id @default(autoincrement()) @map("id") resourceType String @map("resource_type") resourceId String @map("resource_id") actionType String @map("action_type") actorUserId BigInt? @map("actor_user_id") actorRole String? @map("actor_role") reasonCode String? @map("reason_code") memo String? @map("memo") beforeJson Json? @map("before_json") afterJson Json? @map("after_json") requestId String? @map("request_id") correlationId String? @map("correlation_id") ipHash String? @map("ip_hash") userAgent String? @map("user_agent") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Relations actorUser User? @relation(fields: [actorUserId], references: [id]) @@index([resourceType, resourceId, createdAt], map: "idx_audit_resource_created") @@index([actorUserId, createdAt], map: "idx_audit_actor_created") @@index([actionType, createdAt], map: "idx_audit_action_created") @@map("audit_logs") } /// 도메인 이벤트를 append-only로 보관 model EventLog { id BigInt @id @default(autoincrement()) @map("id") aggregateType String @map("aggregate_type") aggregateId String @map("aggregate_id") eventName String @map("event_name") eventVersion Int @default(1) @map("event_version") eventKey String @unique @map("event_key") payloadJson Json @map("payload_json") actorUserId BigInt? @map("actor_user_id") causationId String? @map("causation_id") correlationId String? @map("correlation_id") piiLevel PiiLevel @default(NONE) @map("pii_level") occurredAt DateTime @map("occurred_at") @db.Timestamptz(6) recordedAt DateTime @default(now()) @map("recorded_at") @db.Timestamptz(6) @@index([aggregateType, aggregateId, occurredAt], map: "idx_event_aggregate_occurred") @@index([eventName, occurredAt], map: "idx_event_name_occurred") @@map("event_logs") } /// 외부 발행/재시도/실패 처리를 EventLog와 분리해 관리 model OutboxEvent { id BigInt @id @default(autoincrement()) @map("id") aggregateType String @map("aggregate_type") aggregateId String @map("aggregate_id") eventName String @map("event_name") payloadJson Json @map("payload_json") publishStatus PublishStatus @default(PENDING) @map("publish_status") availableAt DateTime @default(now()) @map("available_at") @db.Timestamptz(6) retryCount Int @default(0) @map("retry_count") lastError String? @map("last_error") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@index([publishStatus, availableAt], map: "idx_outbox_status_available") @@map("outbox_events") } /// 웹훅과 중복 요청 방지를 위한 키 저장소 model IdempotencyKey { id BigInt @id @default(autoincrement()) @map("id") scope String @map("scope") idempotencyKey String @map("idempotency_key") requestHash String? @map("request_hash") responseHash String? @map("response_hash") expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@unique([scope, idempotencyKey], map: "uq_idempotency_scope_key") @@map("idempotency_keys") } /// 기능 플래그 토글 model FeatureFlag { id BigInt @id @default(autoincrement()) @map("id") flagKey String @unique @map("flag_key") isEnabled Boolean @default(false) @map("is_enabled") description String? @map("description") enabledAt DateTime? @map("enabled_at") @db.Timestamptz(6) disabledAt DateTime? @map("disabled_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) @@map("feature_flags") } // ============================================================================= // 8. 마스터 데이터 // ============================================================================= /// 지역 계층 마스터 (단일 계층 테이블 + self relation) model RegionHierarchy { id BigInt @id @default(autoincrement()) @map("id") code String @unique @map("code") nameKo String @map("name_ko") fullNameKo String? @map("full_name_ko") regionType RegionType @map("region_type") parentId BigInt? @map("parent_id") depth Int @default(0) @map("depth") pathCode String? @map("path_code") sortOrder Int @default(0) @map("sort_order") isActive Boolean @default(true) @map("is_active") isBetaEnabled Boolean @default(false) @map("is_beta_enabled") latitude Decimal? @map("latitude") @db.Decimal(10, 7) longitude Decimal? @map("longitude") @db.Decimal(10, 7) externalCode String? @map("external_code") // Self relation parent RegionHierarchy? @relation("RegionParentChild", fields: [parentId], references: [id]) children RegionHierarchy[] @relation("RegionParentChild") // Relations storesAsCluster Store[] @relation("StoreRegionCluster") storesAsLeaf Store[] @relation("StoreRegionLeaf") vendorsPrimaryRegion Vendor[] vendorCoverageRegions VendorCoverageRegion[] @@index([parentId], map: "idx_region_parent") @@index([regionType, isActive], map: "idx_region_type_active") @@index([isBetaEnabled, isActive], map: "idx_region_beta_active") @@map("region_hierarchies") } /// 업종 분류 마스터 (단일 계층 테이블 + self relation) model IndustryTaxonomy { id BigInt @id @default(autoincrement()) @map("id") code String @unique @map("code") nameKo String @map("name_ko") parentId BigInt? @map("parent_id") depth Int @default(0) @map("depth") sortOrder Int @default(0) @map("sort_order") isLeaf Boolean @default(false) @map("is_leaf") isActive Boolean @default(true) @map("is_active") isBetaEnabled Boolean @default(false) @map("is_beta_enabled") externalCode String? @map("external_code") searchAliases String[] @map("search_aliases") // Self relation parent IndustryTaxonomy? @relation("IndustryParentChild", fields: [parentId], references: [id]) children IndustryTaxonomy[] @relation("IndustryParentChild") // Relations stores Store[] @@index([parentId], map: "idx_industry_parent") @@index([isLeaf, isActive], map: "idx_industry_leaf_active") @@index([isBetaEnabled, isActive], map: "idx_industry_beta_active") @@map("industry_taxonomies") }