데이터를 다룰 때 읽는 문서

데이터 구조

가장 두꺼운 문서입니다. 할인 전 금액과 할인 후 금액을 따로 두는 이유가 여기 적혀 있습니다.

46,673자 · 시스템이 만든 그대로입니다

사람이 손댄 곳 3군데
  • · 3단계 ‘화면 목록 다듬기’에서 화면 이름 몇 개를 손봤습니다. 하는 일이 겹쳐 보이는 이름을 갈라 적었을 뿐 문서 본문은 손대지 않았습니다.
  • · 사업계획서 검토에서 받은 지적 여덟 건 중 둘은 반영하지 않기로 하고 그 이유를 적어 남겼습니다. 반영하지 않은 것도 기록에 남아 심사에서 물으면 그대로 답이 됩니다.
  • · 아래 화면은 저장소에 연결되기 전 단계의 것입니다. 기능을 붙이는 작업은 이 사례를 정리하는 시점에 아직 진행 중이라, 동작하는 서비스 화면 대신 이 단계까지만 싣습니다. 없는 것을 있다고 하지 않기 위해 그대로 적어 둡니다.

로컬푸드 직매장 정산 자동화 데이터 구조 문서

1. 설계 범위와 기준

이 문서는 첫 버전에서 실제로 사용하는 데이터를 정의합니다. 대상은 하나의 로컬푸드 직매장입니다. 여러 매장 통합 관리는 포함하지 않습니다.

  • 정산 주차별 판매 원장과 정산서를 관리합니다.
  • 농가·품목별로 다른 정산 규칙과 적용 기간을 보관합니다.
  • 마감 완료된 정산 주차의 계산 근거와 금액은 바꾸지 않습니다.
  • 마감 후 오류는 원 정산서를 수정하지 않고 다음 정산 주차의 정정 항목으로 반영합니다.
  • 농가는 자기 농가의 정산서와 이의 제기만 조회할 수 있습니다.
  • 실제 은행 이체와 은행 이체용 지급 파일 생성은 SRS 첫 개발 범위에서 제외되어 있습니다. 다만 정산서의 지급액과 입금 예정일은 저장합니다.

가정: 데이터베이스는 PostgreSQL을 사용하고, Prisma ORM으로 접근합니다. 금액은 부동소수점이 아닌 정수 원 단위로 저장합니다. (NFR-001)


2. 주요 데이터와 책임

데이터 표사업 용어저장 목적관련 요구사항
users운영자·농가 로그인 계정로그인, 계정 상태, 역할과 농가 연결을 관리FR-001, FR-002, FR-003, FR-020~023
farmers농가농가 기본 정보와 입점 상태를 관리FR-003, FR-005, FR-016, FR-020
product_groups품목군품목을 분류하고 품목군 정산 규칙을 적용FR-005, FR-006, FR-012
products품목농가, 품목군, 품목명, 규격, 사용 상태를 관리FR-005, FR-008, FR-010~012
product_code_mappings상품코드 연결계산대 판매 파일의 상품코드를 품목에 연결하고 연결 이력을 보관FR-005, FR-008
settlement_rules정산 규칙수수료율, 할인 수수료, 반품·폐기 부담과 적용 기간을 관리FR-006, FR-010~012
settlement_weeks정산 주차판매 기간, 목요일 마감일, 입금 예정일, 정산 상태를 관리FR-004, FR-015~019
cashier_sales_files계산대 판매 파일원본 파일 정보, 업로드 이력과 처리 상태를 보관FR-007, FR-009, FR-014
cashier_sales_file_rows계산대 판매 파일 원본 행업로드 파일의 원문 행과 형식 오류를 보존FR-007, FR-009, FR-010
sales_ledgers판매 원장판매·할인·반품과 폐기를 정산 근거로 기록FR-009~014
disposals폐기유통기한 경과·미회수 재고 폐기와 부담 금액을 기록FR-011~014
settlement_errors정산 오류마감을 막는 미연결, 규칙 누락, 중복, 대조 오류를 관리FR-013~015, FR-017
settlements정산정산 주차별 농가의 계산 결과와 계산 시점 복사본을 저장FR-012, FR-014, FR-016
settlement_statements정산서농가에 제공할 정산서와 생성·마감 시점의 결과를 저장FR-016, FR-017, FR-020~022
correction_items정정 항목마감 후 오류를 원 정산서와 다음 정산 주차에 연결FR-018, FR-019
disputes이의 제기농가 이의 내용, 처리 결과와 안내 기록을 관리FR-022, FR-023
evidences증빙폐기와 이의 제기에 첨부한 사진의 메타데이터를 보관FR-011, FR-022, FR-023
reconciliations대조매장 매출과 정산 합계의 대조 결과를 저장FR-014, FR-015, FR-017
activity_logs주요 작업 이력로그인, 규칙 변경, 파일 업로드, 마감, 정정 처리의 이력을 보관FR-002, FR-006~009, FR-017~019, FR-023, FR-024

3. 데이터 관계도

그림을 그리는 중…

이 관계도에서 settlement_weeks가 주간 업무의 중심입니다. 계산대 판매 파일, 판매 원장, 폐기, 정산 오류, 대조와 농가별 정산은 모두 하나의 정산 주차에 연결됩니다.

마감 후에도 과거 계산 근거를 확인할 수 있도록 판매 원장과 정산에는 당시 적용된 정산 규칙과 계산 결과의 복사본을 저장합니다. (FR-012, FR-017, NFR-003)


4. 공통 저장 원칙

4.1 금액과 날짜

항목저장 방식이유관련 요구사항
금액Int 원 단위소수점 오차 없이 수수료와 지급액을 계산FR-012, FR-014, NFR-001
수수료율Decimal(7,4)예: 10%, 15%, 개별 약정 비율 저장FR-006, FR-012
판매일·마감일·입금 예정일DateTime의 날짜 기준 또는 @db.Date정산 주차와 적용 규칙을 판매일 기준으로 판단FR-004, FR-006, NFR-002
작업 시점DateTime업로드, 마감, 정정, 이의 처리 시점을 추적FR-007, FR-017~019, FR-023, FR-024

4.2 삭제와 사용 중지

  • 농가, 품목, 품목군, 로그인 계정은 정산 이력이 생긴 뒤 물리적으로 삭제하지 않습니다.
  • 대신 isActive, status, deactivatedAt 등으로 사용 중지 처리합니다.
  • 마감된 정산 주차와 연결된 판매 원장, 정산 규칙, 정산서, 정정 항목은 수정·삭제하지 않습니다. (FR-002, FR-005, FR-006, FR-017, NFR-003)

4.3 개인정보 최소화

첫 버전에서 농가에 필요한 정보는 다음으로 제한합니다.

  • 농가명
  • 농가 로그인용 아이디 또는 이메일
  • 비밀번호 해시값
  • 계정 상태

확인 필요: 농가 연락처, 사업자등록번호, 계좌번호는 첫 버전의 정산 조회·정산서 생성에 필수 정보가 아닙니다. 실제 이체 또는 지급 파일 생성 기능을 추가할 때 별도 보관 필요성과 접근 권한을 검토해야 합니다.


5. 표별 필드 정의

5.1 로그인 계정과 권한

users — 운영자·농가 로그인 계정

필드형식필수설명관련 요구사항
idUUID계정 식별값FR-001~003
loginId문자열로그인 아이디, 전체에서 중복 불가FR-001, FR-003
passwordHash문자열암호화된 비밀번호 값FR-001, NFR-005
role열거형STORE_OWNER, SETTLEMENT_MANAGER, CLOSING_MANAGER, FARMERFR-001, FR-002
farmerIdUUID아니오농가 계정인 경우 연결되는 농가FR-003, FR-020
status열거형ACTIVE, SUSPENDEDFR-002, FR-003
lastLoginAt날짜·시간아니오최근 로그인 성공 시점FR-001, FR-024
createdAt날짜·시간생성 시점FR-002, FR-024
updatedAt날짜·시간마지막 변경 시점FR-002, FR-024

제약 조건

  • role = FARMER이면 farmerId가 반드시 있어야 합니다.
  • 하나의 농가는 하나의 활성 농가 계정에만 연결합니다.
  • role = FARMER이 아닌 계정에는 farmerId를 저장하지 않습니다.
  • 계정 중지 상태에서는 새 로그인 세션을 만들 수 없습니다. (FR-001~003, NFR-004, NFR-005)

5.2 농가·품목·상품코드

farmers — 농가

필드형식필수설명관련 요구사항
idUUID농가 식별값FR-003, FR-005
name문자열농가명 또는 정산서 표시 이름FR-005, FR-016
isActive불리언현재 입점·사용 가능 여부FR-005
deactivatedAt날짜·시간아니오사용 중지 시점FR-005
createdAt날짜·시간등록 시점FR-005
updatedAt날짜·시간변경 시점FR-005

product_groups — 품목군

필드형식필수설명관련 요구사항
idUUID품목군 식별값FR-005, FR-006
name문자열예: 채소, 가공식품, 계란FR-005, FR-006
isActive불리언사용 여부FR-005
createdAt날짜·시간등록 시점FR-005

products — 품목

필드형식필수설명관련 요구사항
idUUID품목 식별값FR-005, FR-008
farmerIdUUID품목을 출하하는 농가FR-005
productGroupIdUUID아니오소속 품목군FR-005, FR-006
name문자열품목명FR-005, FR-010
specification문자열아니오규격, 예: 1봉, 10개입FR-005
isActive불리언판매·신규 연결 가능 여부FR-005
deactivatedAt날짜·시간아니오사용 중지 시점FR-005
createdAt날짜·시간등록 시점FR-005
updatedAt날짜·시간변경 시점FR-005

product_code_mappings — 상품코드 연결

필드형식필수설명관련 요구사항
idUUID연결 식별값FR-005, FR-008
productCode문자열계산대 판매 파일의 상품코드FR-005, FR-008
productIdUUID연결된 품목FR-005, FR-008
mappingSource열거형MANUAL, AUTO_CONFIRMED 등 연결 방식FR-008
isActive불리언현재 자동 연결에 사용할지 여부FR-008
createdByIdUUID직접 연결 또는 등록 담당자FR-008, FR-024
createdAt날짜·시간연결 시점FR-008, FR-024
endedAt날짜·시간아니오연결 종료 시점FR-008

같은 상품코드는 동시에 둘 이상의 사용 중인 품목에 연결할 수 없습니다. 직접 연결을 변경해도 과거 연결 기록은 남기며, 이후 판매에만 새 연결을 사용합니다. (FR-005, FR-008)


5.3 정산 규칙

settlement_rules — 정산 규칙

필드형식필수설명관련 요구사항
idUUID정산 규칙 식별값FR-006, FR-012
name문자열운영자가 알아볼 규칙 이름FR-006
farmerIdUUID아니오특정 농가 대상FR-006
productIdUUID아니오특정 품목 대상FR-006
productGroupIdUUID아니오특정 품목군 대상FR-006
commissionRateDecimal수수료율. 예: 0.1000은 10%FR-006, FR-012
discountCommissionBasis열거형BEFORE_DISCOUNT, AFTER_DISCOUNTFR-006, FR-012
returnBurdenParty열거형FARMER, STORE, NONEFR-006, FR-012
disposalBurdenParty열거형FARMER, STORE, NONEFR-006, FR-011~012
disposalCalculationMethod열거형MANUAL_AMOUNT, ORIGINAL_PRICE, ACTUAL_COSTFR-006, FR-011
effectiveFrom날짜적용 시작일FR-006, FR-012
effectiveTo날짜아니오적용 종료일FR-006
isConfirmed불리언농가·품목별 약정이 확정됐는지 여부FR-006
createdByIdUUID등록 담당자FR-006, FR-024
createdAt날짜·시간등록 시점FR-006, FR-024

규칙 적용 우선순위

판매 원장의 품목과 농가, 판매일을 기준으로 아래 순서의 규칙 하나를 선택합니다.

  1. 농가 + 개별 품목
  2. 농가 + 품목군
  3. 농가 전체
  4. 개별 품목
  5. 품목군
  6. 농가·품목·품목군이 없는 매장 공통 규칙

effectiveFrom ≤ 판매일 ≤ effectiveTo 조건을 만족해야 하며, 종료일이 없으면 계속 유효한 규칙입니다. 같은 우선순위에서 둘 이상의 규칙이 나오면 정산 오류를 생성합니다. (FR-006, FR-012, FR-013)


5.4 정산 주차와 계산대 판매 파일

settlement_weeks — 정산 주차

필드형식필수설명관련 요구사항
idUUID정산 주차 식별값FR-004~019
periodStartDate날짜판매 기간 시작일FR-004
periodEndDate날짜판매 기간 종료일FR-004
closingDate날짜목요일 정산 마감일FR-004, FR-017
expectedDepositDate날짜농가에 표시할 입금 예정일FR-004, FR-020
status열거형정산 진행 상태FR-004, FR-015~018
closedAt날짜·시간아니오실제 마감 완료 시점FR-017
closedByIdUUID아니오마감 권한자FR-017, FR-024
createdByIdUUID정산 주차 생성 담당자FR-004
createdAt날짜·시간생성 시점FR-004

status 값은 다음을 사용합니다.

  • PREPARING: 준비 중
  • PROCESSING: 처리 중
  • HAS_ERRORS: 오류 있음
  • READY_TO_CLOSE: 마감 가능
  • STATEMENTS_GENERATED: 정산서 생성
  • CLOSED: 마감 완료
  • CORRECTION_PENDING: 정정 반영 예정

CLOSED 상태에서는 판매 원장, 폐기, 정산 규칙 적용 결과, 정산 및 정산서를 변경하지 않습니다. (FR-017, NFR-003)

cashier_sales_files — 계산대 판매 파일

필드형식필수설명관련 요구사항
idUUID파일 식별값FR-007, FR-009
settlementWeekIdUUID대상 정산 주차FR-007
originalFileName문자열업로드 당시 파일명FR-007
storageKey문자열원본 파일 저장 경로 또는 객체 저장소 키FR-007, NFR-006
fileHash문자열같은 파일 중복 업로드 검사값FR-007
fileFormat열거형첫 버전 지원 형식 예: CSV, XLSXFR-007
status열거형UPLOADED, VALIDATING, PARTIALLY_PROCESSED, PROCESSED, FAILEDFR-007
totalRowCount정수전체 행 수FR-007
validRowCount정수정상 행 수FR-007
errorRowCount정수오류 행 수FR-007
uploadedByIdUUID업로드 담당자FR-007, FR-024
uploadedAt날짜·시간업로드 시점FR-007
processedAt날짜·시간아니오처리 완료 시점FR-007

cashier_sales_file_rows — 계산대 판매 파일 원본 행

필드형식필수설명관련 요구사항
idUUID원본 행 식별값FR-007, FR-009
cashierSalesFileIdUUID소속 계산대 판매 파일FR-007
rowNumber정수원본 파일 행 번호FR-007, FR-010
rawDataJSON원본 열과 값을 그대로 보관FR-007, FR-010
transactionExternalId문자열아니오계산대 거래 고유값FR-009
saleDate날짜·시간아니오파일에서 읽은 판매일FR-007
productCode문자열아니오파일의 상품코드FR-008
productNameRaw문자열아니오파일의 원본 품목명FR-008
validationStatus열거형VALID, INVALID, PENDING_REVIEWFR-007
validationMessage문자열아니오열 누락, 날짜 범위 오류 등의 설명FR-007
createdAt날짜·시간저장 시점FR-007

원본 행은 수정하지 않습니다. 운영자가 직접 상품을 연결하거나 오류를 처리해도 원본 파일 내용과 원본 행은 그대로 보존합니다. (FR-007, FR-008, NFR-006)


5.5 판매 원장과 폐기

sales_ledgers — 판매 원장

필드형식필수설명관련 요구사항
idUUID판매 원장 식별값FR-009~014
settlementWeekIdUUID정산 대상 주차FR-009
cashierSalesFileRowIdUUID아니오원본 파일 행. 수기 조정은 비어 있을 수 있음FR-009, FR-014
farmerIdUUID아니오연결된 농가FR-008, FR-010
productIdUUID아니오연결된 품목FR-008, FR-010
transactionType열거형SALE, DISCOUNT_SALE, RETURN, MANUAL_ADJUSTMENTFR-009, FR-010, FR-014
transactionDate날짜·시간거래일FR-009, FR-012
externalTransactionId문자열아니오계산대 거래 고유값FR-009
quantityDecimal거래 수량FR-009, FR-010
regularSaleAmount정수할인 전 정상 판매금액FR-010, FR-012
actualSaleAmount정수할인 후 실제 판매금액 또는 반품 차감 전 기준값FR-010, FR-012
discountAmount정수정상 판매금액과 실제 판매금액 차이FR-010
returnAmount정수반품·취소 차감액FR-010, FR-012
netSalesAmount정수정산에 반영할 순 판매액FR-010, FR-012
mappingStatus열거형AUTO_MAPPED, MANUALLY_MAPPED, UNMAPPED, CONFLICTEDFR-008, FR-013
settlementStatus열거형PENDING, CALCULATED, ERROR, EXCLUDEDFR-010, FR-013
appliedRuleIdUUID아니오실제 적용한 정산 규칙FR-012
appliedRuleSnapshotJSON아니오계산 당시 수수료·부담 기준 복사본FR-012, FR-017
commissionBaseAmount정수아니오수수료 계산 기준 금액FR-012
commissionAmount정수아니오계산된 수수료FR-012
farmerReturnBurdenAmount정수농가 부담 반품 금액FR-012
createdAt날짜·시간생성 시점FR-009
updatedAt날짜·시간마감 전 마지막 처리 시점FR-008~013

disposals — 폐기

필드형식필수설명관련 요구사항
idUUID폐기 식별값FR-011~014
settlementWeekIdUUID대상 정산 주차FR-011
farmerIdUUID대상 농가FR-011
productIdUUID대상 품목FR-011
disposalType열거형EXPIRATION, UNCLAIMED_STOCK, OTHERFR-011
reasonDetail문자열아니오구체적 폐기 사유FR-011
quantityDecimal폐기 수량FR-011
burdenParty열거형FARMER, STORE, NONEFR-011
settlementRuleIdUUID아니오적용 규칙FR-011, FR-012
settlementRuleSnapshotJSON아니오적용 당시 부담 기준 복사본FR-011, FR-017
settlementImpactAmount정수정산 반영 금액FR-011, FR-012
notifiedFarmerAt날짜·시간아니오농가 안내 날짜FR-011
notificationMethod문자열아니오예: 전화, 대면 전달FR-011
handledByIdUUID처리 담당자FR-011, FR-024
handledAt날짜·시간처리 시점FR-011
createdAt날짜·시간등록 시점FR-011

폐기에 적용할 정산 규칙이 없으면 settlementImpactAmount를 자동 확정하지 않고 정산 오류로 남깁니다. 농가 부담 폐기이면서 안내 기록이 없으면 경고를 표시합니다. (FR-011, FR-013)


5.6 정산 오류와 대조

settlement_errors — 정산 오류

필드형식필수설명관련 요구사항
idUUID오류 식별값FR-013~015
settlementWeekIdUUID오류가 발생한 정산 주차FR-013
salesLedgerIdUUID아니오관련 판매 원장FR-013
disposalIdUUID아니오관련 폐기FR-013
errorType열거형오류 유형FR-013
severity열거형BLOCKING, WARNINGFR-013, FR-017
status열거형OPEN, RESOLVEDFR-013
message문자열오류 내용FR-013
resolutionNote문자열아니오해결 근거FR-013
resolvedByIdUUID아니오처리 담당자FR-013, FR-024
resolvedAt날짜·시간아니오처리 시점FR-013
createdAt날짜·시간오류 발견 시점FR-013

errorType에는 최소한 다음 값을 둡니다.

  • UNMAPPED_PRODUCT
  • RULE_NOT_FOUND
  • RULE_CONFLICT
  • DUPLICATE_TRANSACTION
  • UNCONFIRMED_DISCOUNT
  • UNCONFIRMED_RETURN
  • UNCONFIRMED_DISPOSAL
  • OUT_OF_PERIOD_TRANSACTION
  • RECONCILIATION_MISMATCH

BLOCKING 상태의 미해결 오류가 하나라도 있으면 정산서 생성 또는 정산 마감을 할 수 없습니다. (FR-013, FR-016, FR-017)

reconciliations — 대조

필드형식필수설명관련 요구사항
idUUID대조 식별값FR-014, FR-017
settlementWeekIdUUID대조 대상 정산 주차FR-014
cashierNetSalesAmount정수계산대 판매 파일 순 판매액FR-014
manualAdjustmentAmount정수근거가 연결된 수기 조정 합계FR-014
adjustedNetSalesAmount정수조정된 순 판매액FR-014
farmerPayoutAmount정수정정 제외 농가 지급액 합계FR-014
commissionAmount정수수수료 합계FR-014
farmerBurdenAmount정수농가 부담 반품·폐기 합계FR-014
storeCompensationAmount정수매장 부담 보전 합계FR-014
currentSettlementAmount정수현재 주차 정산 금액FR-014
differenceAmount정수매출 대조 차이FR-014
calculatedAt날짜·시간계산 시점FR-014
calculatedByIdUUID아니오수기 조정 포함 시 계산 실행 담당자FR-014, FR-024

대조 차이는 아래 방식으로 계산합니다.

조정된 순 판매액
= 계산대 판매 파일 순 판매액
± 확인된 수기 조정

현재 주차 정산 금액
= 농가 지급액 합계(정정 항목 제외)
+ 수수료 합계
+ 농가 부담 차감 합계
- 매장 부담 보전 합계

매출 대조 차이
= 조정된 순 판매액 - 현재 주차 정산 금액

differenceAmount = 0이고 미해결 필수 오류가 없을 때만 마감 가능 상태가 됩니다. (FR-014, FR-015, FR-017)


5.7 농가별 정산서와 마감

settlements — 정산

필드형식필수설명관련 요구사항
idUUID농가별 정산 식별값FR-012, FR-016
settlementWeekIdUUID소속 정산 주차FR-012
farmerIdUUID정산 대상 농가FR-012, FR-016
grossSalesAmount정수정상 판매금액 합계FR-012, FR-016
discountAmount정수할인금액 합계FR-012, FR-016
netSalesAmount정수반품 등을 반영한 순 판매액FR-012, FR-016
returnAmount정수반품 금액 합계FR-012, FR-016
commissionAmount정수수수료 합계FR-012, FR-016
farmerReturnBurdenAmount정수농가 부담 반품 금액 합계FR-012, FR-016
farmerDisposalBurdenAmount정수농가 부담 폐기 금액 합계FR-012, FR-016
storeCompensationAmount정수매장 부담 보전 금액 합계FR-012, FR-016
correctionAmount정수이번 정산에 반영된 정정 항목 합계FR-012, FR-018
payoutAmount정수최종 지급액FR-012, FR-016
calculationSnapshotJSON거래별 계산 근거와 적용 규칙 요약FR-012, FR-017
calculatedAt날짜·시간계산 시점FR-012
createdAt날짜·시간생성 시점FR-016
updatedAt날짜·시간마감 전 재계산 시점FR-016

농가 지급액은 다음 방식으로 계산합니다.

농가 지급액
= 해당 주차의 순 판매액
- 수수료
- 농가 부담 반품 금액
- 농가 부담 폐기 금액
+ 확정된 매장 부담 보전 금액
± 정정 항목

settlement_statements — 정산서

필드형식필수설명관련 요구사항
idUUID정산서 식별값FR-016~023
settlementIdUUID표시할 농가별 정산FR-016
statementNumber문자열농가와 운영자가 확인할 정산서 번호FR-016, FR-021
status열거형GENERATED, CLOSEDFR-016, FR-017
expectedDepositDateSnapshot날짜생성 시점 입금 예정일 복사본FR-016, FR-020
statementSnapshotJSON정산서에 표시한 항목과 금액의 복사본FR-016, FR-017
generatedAt날짜·시간정산서 생성 시점FR-016
generatedByIdUUID생성 담당자FR-016, FR-024
closedAt날짜·시간아니오정산 마감 완료 시점FR-017
closedByIdUUID아니오마감 권한자FR-017, FR-024

statementSnapshot에는 농가가 조회할 다음 정보를 넣습니다.

  • 이번 주 판매금액
  • 할인금액
  • 순 판매액
  • 수수료
  • 반품 금액과 농가 부담 금액
  • 폐기 금액과 농가 부담 금액
  • 정정 항목
  • 최종 지급액
  • 입금 예정일
  • 품목별 세부 내역

마감 후에는 이 복사본을 바꾸지 않습니다. 농가 화면은 마감된 정산서의 복사본을 읽어 보여 줍니다. (FR-017, FR-020, FR-021, NFR-003)


5.8 정정 항목

correction_items — 정정 항목

필드형식필수설명관련 요구사항
idUUID정정 항목 식별값FR-018, FR-019
originalStatementIdUUID오류가 발견된 원 정산서FR-018
targetSettlementWeekIdUUID다음 정산 반영 대상 주차FR-018
farmerIdUUID정정 대상 농가FR-018
amount정수증감액. 지급액 증가면 양수, 감소면 음수FR-018
reason문자열정정 사유FR-018, FR-019
status열거형DRAFT, CONFIRMED, APPLIED, CANCELLEDFR-018
createdByIdUUID작성 담당자FR-018, FR-024
confirmedByIdUUID아니오확정 담당자FR-018, FR-024
confirmedAt날짜·시간아니오확정 시점FR-018
appliedAt날짜·시간아니오다음 정산 계산 반영 시점FR-018
createdAt날짜·시간작성 시점FR-018

정정 항목은 원 정산서의 금액을 바꾸지 않습니다. CONFIRMED 상태의 정정 항목만 다음 정산 주차의 농가 지급액에 반영합니다. (FR-018, FR-019, NFR-003)


5.9 농가 이의 제기와 증빙

disputes — 이의 제기

필드형식필수설명관련 요구사항
idUUID이의 제기 식별값FR-022, FR-023
settlementStatementIdUUID대상 정산서FR-022
farmerIdUUID이의를 제기한 농가FR-022
salesLedgerIdUUID아니오특정 판매·할인·반품 거래 대상FR-022
disposalIdUUID아니오특정 폐기 대상FR-022
content문자열농가가 작성한 이의 내용FR-022
status열거형SUBMITTED, IN_REVIEW, RESOLVED, REJECTEDFR-022, FR-023
result문자열아니오운영자의 처리 결과FR-023
handledByIdUUID아니오처리 담당자FR-023
handledAt날짜·시간아니오처리일FR-023
farmerNotifiedAt날짜·시간아니오농가 안내 날짜FR-023
notificationMethod문자열아니오예: 전화, 대면, 정산서 확인FR-023
createdAt날짜·시간제출 시점FR-022
updatedAt날짜·시간상태 변경 시점FR-023

evidences — 증빙

필드형식필수설명관련 요구사항
idUUID증빙 식별값FR-011, FR-022
disputeIdUUID아니오이의 제기 증빙인 경우 연결FR-022, FR-023
disposalIdUUID아니오폐기 증빙인 경우 연결FR-011
storageKey문자열사진 파일 저장 경로 또는 객체 저장소 키FR-011, FR-022
originalFileName문자열원본 파일명FR-011, FR-022
mimeType문자열허용된 이미지 형식인지 확인FR-022, NFR-006
fileSize정수파일 크기 검사FR-022, NFR-006
uploadedByIdUUID제출자 또는 등록 담당자FR-011, FR-022
createdAt날짜·시간업로드 시점FR-011, FR-022

증빙 파일은 이미지 형식과 최대 크기를 제한하고, 로그인한 사용자 권한에 따라 제한된 주소로만 조회하게 합니다. (FR-022, NFR-004, NFR-006)


5.10 주요 작업 이력

activity_logs — 주요 작업 이력

필드형식필수설명관련 요구사항
idUUID작업 이력 식별값FR-024
actorUserIdUUID아니오작업한 로그인 계정. 시스템 작업은 비어 있을 수 있음FR-024
actionType열거형작업 유형FR-024
targetType문자열대상 데이터 종류FR-024
targetIdUUID아니오대상 데이터 식별값FR-024
settlementWeekIdUUID아니오관련 정산 주차FR-017~019, FR-024
beforeDataJSON아니오변경 전 주요 값FR-002, FR-006, FR-024
afterDataJSON아니오변경 후 주요 값FR-002, FR-006, FR-024
reason문자열아니오정정·상태 변경 사유FR-018, FR-023, FR-024
createdAt날짜·시간작업 시점FR-024

기록 대상 작업 예시는 다음과 같습니다.

  • 로그인 성공·실패
  • 계정 생성, 권한 변경, 중지
  • 농가·품목·상품코드 등록과 사용 중지
  • 정산 규칙 생성과 종료일 변경
  • 계산대 판매 파일 업로드와 처리 결과
  • 상품코드 직접 연결과 변경
  • 폐기 등록
  • 오류 해결
  • 정산서 생성
  • 정산 마감
  • 정정 항목 생성·확정·반영
  • 이의 제기 처리와 농가 안내 기록

마감 후 자료의 직접 변경을 허용하지 않고, 필요한 후속 처리는 정정 항목과 작업 이력으로 남깁니다. (FR-017~019, FR-024, NFR-003, NFR-007)


6. 데이터 생명주기

그림을 그리는 중…

정산 주차는 판매 파일을 처리하고 오류와 대조 차이를 모두 해결한 뒤에만 마감할 수 있습니다. 마감 이후에는 원 정산서와 계산 근거를 유지하며, 차액은 다음 정산 주차에 정정 항목으로 반영합니다. (FR-007~019)


7. 핵심 무결성 규칙

규칙구현 기준관련 요구사항
정산 주차 기간 중복 방지같은 매장에서 periodStartDate~periodEndDate가 겹치는 정산 주차 생성 차단FR-004
상품코드 중복 연결 방지활성 상태의 동일 productCode는 하나의 품목에만 연결FR-005, FR-008
정산 규칙 기간 중복 방지같은 적용 대상 조합의 유효 기간이 겹치면 저장 차단FR-006
규칙 없는 거래 계산 방지적용 규칙이 없거나 충돌하면 settlement_errors 생성 후 계산·마감 차단FR-006, FR-012, FR-013
중복 거래 반영 방지externalTransactionId와 원본 파일 정보를 검사해 중복 의심 거래 제외FR-009
마감 전 오류 해결 의무미해결 BLOCKING 오류 또는 대조 차이가 있으면 마감 차단FR-013, FR-014, FR-017
마감 후 불변성CLOSED 정산 주차의 판매 원장·정산·정산서·규칙 적용 결과 수정 차단FR-017, NFR-003
정정의 다음 주차 반영정정 항목은 원 정산서를 참조하고, 다음 정산 주차에만 반영FR-018, FR-019
농가 자료 격리농가 로그인 계정은 연결된 farmerId의 정산서·이의 제기만 조회FR-003, FR-020~022, NFR-004

8. 필요한 인덱스

인덱스 또는 제약사용 목적관련 요구사항
usersloginId 고유 인덱스로그인 계정 조회FR-001, FR-003
usersfarmerId 고유 인덱스, 농가 계정에만 적용하나의 계정을 여러 농가에 연결하지 않음FR-003
products(farmerId, isActive)농가별 사용 품목 조회FR-005, FR-008
product_code_mappings활성 productCode 부분 고유 인덱스자동 상품 연결과 중복 방지FR-005, FR-008
settlement_rules(farmerId, productId, productGroupId, effectiveFrom, effectiveTo)판매일 기준 규칙 탐색과 중복 검사FR-006, FR-012
settlement_weeks(periodStartDate, periodEndDate)기간 중복 검사FR-004
cashier_sales_files(settlementWeekId, fileHash) 고유 인덱스같은 파일 중복 업로드 차단FR-007
cashier_sales_file_rows(cashierSalesFileId, rowNumber) 고유 인덱스원본 행 식별FR-007
sales_ledgers(settlementWeekId, mappingStatus, settlementStatus)미연결·오류 거래 목록 조회FR-008, FR-013
sales_ledgersexternalTransactionId 인덱스중복 거래 검사FR-009
settlements(settlementWeekId, farmerId) 고유 인덱스주차별 농가 정산 하나만 생성FR-012, FR-016
settlement_statementsstatementNumber 고유 인덱스정산서 조회·내려받기FR-016, FR-021
correction_items(targetSettlementWeekId, farmerId, status)다음 주차 정정 반영 계산FR-018
disputes(farmerId, status, createdAt)농가 본인 이의 제기 조회FR-022, FR-023
settlement_errors(settlementWeekId, status, severity)마감 차단 오류 수 계산FR-013, FR-015, FR-017
activity_logs(targetType, targetId, createdAt)대상별 변경 이력 조회FR-019, FR-024

9. Prisma 모델 초안

아래 초안은 PostgreSQL 기준입니다. 정산 규칙의 기간 중복 검증, 농가 계정의 조건부 고유 제약, 마감 상태의 수정 차단은 Prisma 모델만으로 충분하지 않으므로 데이터베이스 제약과 서버 로직에서 함께 처리합니다.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum UserRole {
  STORE_OWNER
  SETTLEMENT_MANAGER
  CLOSING_MANAGER
  FARMER
}

enum UserStatus {
  ACTIVE
  SUSPENDED
}

enum SettlementWeekStatus {
  PREPARING
  PROCESSING
  HAS_ERRORS
  READY_TO_CLOSE
  STATEMENTS_GENERATED
  CLOSED
  CORRECTION_PENDING
}

enum FileFormat {
  CSV
  XLSX
}

enum SalesFileStatus {
  UPLOADED
  VALIDATING
  PARTIALLY_PROCESSED
  PROCESSED
  FAILED
}

enum FileRowValidationStatus {
  VALID
  INVALID
  PENDING_REVIEW
}

enum ProductCodeMappingSource {
  MANUAL
  AUTO_CONFIRMED
}

enum DiscountCommissionBasis {
  BEFORE_DISCOUNT
  AFTER_DISCOUNT
}

enum BurdenParty {
  FARMER
  STORE
  NONE
}

enum DisposalCalculationMethod {
  MANUAL_AMOUNT
  ORIGINAL_PRICE
  ACTUAL_COST
}

enum TransactionType {
  SALE
  DISCOUNT_SALE
  RETURN
  MANUAL_ADJUSTMENT
}

enum MappingStatus {
  AUTO_MAPPED
  MANUALLY_MAPPED
  UNMAPPED
  CONFLICTED
}

enum LedgerSettlementStatus {
  PENDING
  CALCULATED
  ERROR
  EXCLUDED
}

enum DisposalType {
  EXPIRATION
  UNCLAIMED_STOCK
  OTHER
}

enum SettlementErrorType {
  UNMAPPED_PRODUCT
  RULE_NOT_FOUND
  RULE_CONFLICT
  DUPLICATE_TRANSACTION
  UNCONFIRMED_DISCOUNT
  UNCONFIRMED_RETURN
  UNCONFIRMED_DISPOSAL
  OUT_OF_PERIOD_TRANSACTION
  RECONCILIATION_MISMATCH
}

enum ErrorSeverity {
  BLOCKING
  WARNING
}

enum ErrorStatus {
  OPEN
  RESOLVED
}

enum StatementStatus {
  GENERATED
  CLOSED
}

enum CorrectionStatus {
  DRAFT
  CONFIRMED
  APPLIED
  CANCELLED
}

enum DisputeStatus {
  SUBMITTED
  IN_REVIEW
  RESOLVED
  REJECTED
}

model User {
  id                    String        @id @default(uuid()) @db.Uuid
  loginId               String        @unique
  passwordHash          String
  role                  UserRole
  farmerId              String?       @unique @db.Uuid
  status                UserStatus    @default(ACTIVE)
  lastLoginAt           DateTime?
  createdAt             DateTime      @default(now())
  updatedAt             DateTime      @updatedAt

  farmer                Farmer?       @relation("FarmerUser", fields: [farmerId], references: [id])
  createdSettlementWeeks SettlementWeek[] @relation("SettlementWeekCreator")
  closedSettlementWeeks SettlementWeek[] @relation("SettlementWeekCloser")
  uploadedSalesFiles    CashierSalesFile[] @relation("SalesFileUploader")
  createdMappings       ProductCodeMapping[]
  createdRules          SettlementRule[]
  handledDisposals      Disposal[]
  resolvedErrors        SettlementError[]
  generatedStatements   SettlementStatement[] @relation("StatementGenerator")
  closedStatements      SettlementStatement[] @relation("StatementCloser")
  createdCorrections    CorrectionItem[] @relation("CorrectionCreator")
  confirmedCorrections  CorrectionItem[] @relation("CorrectionConfirmer")
  handledDisputes       Dispute[] @relation("DisputeHandler")
  uploadedEvidences     Evidence[]
  activityLogs          ActivityLog[]

  @@index([role, status])
}

model Farmer {
  id             String    @id @default(uuid()) @db.Uuid
  name           String
  isActive       Boolean   @default(true)
  deactivatedAt  DateTime?
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt

  user           User?     @relation("FarmerUser")
  products       Product[]
  rules          SettlementRule[]
  ledgers        SalesLedger[]
  disposals      Disposal[]
  settlements    Settlement[]
  corrections    CorrectionItem[]
  disputes       Dispute[]

  @@index([isActive, name])
}

model ProductGroup {
  id          String    @id @default(uuid()) @db.Uuid
  name        String    @unique
  isActive    Boolean   @default(true)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  products    Product[]
  rules       SettlementRule[]
}

model Product {
  id                String       @id @default(uuid()) @db.Uuid
  farmerId          String       @db.Uuid
  productGroupId    String?      @db.Uuid
  name              String
  specification     String?
  isActive          Boolean      @default(true)
  deactivatedAt     DateTime?
  createdAt         DateTime     @default(now())
  updatedAt         DateTime     @updatedAt

  farmer            Farmer       @relation(fields: [farmerId], references: [id])
  productGroup      ProductGroup? @relation(fields: [productGroupId], references: [id])
  codeMappings      ProductCodeMapping[]
  rules             SettlementRule[]
  ledgers           SalesLedger[]
  disposals         Disposal[]

  @@index([farmerId, isActive])
  @@index([productGroupId, isActive])
}

model ProductCodeMapping {
  id              String                   @id @default(uuid()) @db.Uuid
  productCode     String
  productId       String                   @db.Uuid
  mappingSource   ProductCodeMappingSource
  isActive        Boolean                  @default(true)
  createdById     String                   @db.Uuid
  createdAt       DateTime                 @default(now())
  endedAt         DateTime?

  product         Product                  @relation(fields: [productId], references: [id])
  createdBy       User                     @relation(fields: [createdById], references: [id])

  @@index([productCode, isActive])
  @@index([productId])
}

model SettlementRule {
  id                        String                    @id @default(uuid()) @db.Uuid
  name                      String
  farmerId                  String?                   @db.Uuid
  productId                 String?                   @db.Uuid
  productGroupId            String?                   @db.Uuid
  commissionRate            Decimal                   @db.Decimal(7, 4)
  discountCommissionBasis   DiscountCommissionBasis
  returnBurdenParty         BurdenParty
  disposalBurdenParty       BurdenParty
  disposalCalculationMethod DisposalCalculationMethod
  effectiveFrom             DateTime                  @db.Date
  effectiveTo               DateTime?                 @db.Date
  isConfirmed               Boolean                   @default(false)
  createdById               String                    @db.Uuid
  createdAt                 DateTime                  @default(now())
  updatedAt                 DateTime                  @updatedAt

  farmer                    Farmer?                   @relation(fields: [farmerId], references: [id])
  product                   Product?                  @relation(fields: [productId], references: [id])
  productGroup              ProductGroup?             @relation(fields: [productGroupId], references: [id])
  createdBy                 User                      @relation(fields: [createdById], references: [id])
  ledgers                   SalesLedger[]
  disposals                 Disposal[]

  @@index([farmerId, productId, productGroupId, effectiveFrom, effectiveTo])
  @@index([effectiveFrom, effectiveTo, isConfirmed])
}

model SettlementWeek {
  id                    String               @id @default(uuid()) @db.Uuid
  periodStartDate       DateTime             @db.Date
  periodEndDate         DateTime             @db.Date
  closingDate           DateTime             @db.Date
  expectedDepositDate   DateTime             @db.Date
  status                SettlementWeekStatus @default(PREPARING)
  closedAt              DateTime?
  closedById            String?              @db.Uuid
  createdById           String               @db.Uuid
  createdAt             DateTime             @default(now())
  updatedAt             DateTime             @updatedAt

  createdBy             User                 @relation("SettlementWeekCreator", fields: [createdById], references: [id])
  closedBy              User?                @relation("SettlementWeekCloser", fields: [closedById], references: [id])
  salesFiles            CashierSalesFile[]
  ledgers               SalesLedger[]
  disposals             Disposal[]
  errors                SettlementError[]
  settlements           Settlement[]
  corrections           CorrectionItem[]
  reconciliations       Reconciliation[]
  activityLogs          ActivityLog[]

  @@index([periodStartDate, periodEndDate])
  @@index([status, closingDate])
}

model CashierSalesFile {
  id               String          @id @default(uuid()) @db.Uuid
  settlementWeekId String          @db.Uuid
  originalFileName String
  storageKey       String
  fileHash         String
  fileFormat       FileFormat
  status           SalesFileStatus @default(UPLOADED)
  totalRowCount    Int             @default(0)
  validRowCount    Int             @default(0)
  errorRowCount    Int             @default(0)
  uploadedById     String          @db.Uuid
  uploadedAt       DateTime        @default(now())
  processedAt      DateTime?

  settlementWeek   SettlementWeek  @relation(fields: [settlementWeekId], references: [id])
  uploadedBy       User            @relation("SalesFileUploader", fields: [uploadedById], references: [id])
  rows             CashierSalesFileRow[]
  ledgers          SalesLedger[]

  @@unique([settlementWeekId, fileHash])
  @@index([settlementWeekId, status])
}

model CashierSalesFileRow {
  id                    String                   @id @default(uuid()) @db.Uuid
  cashierSalesFileId    String                   @db.Uuid
  rowNumber             Int
  rawData               Json
  transactionExternalId String?
  saleDate              DateTime?
  productCode           String?
  productNameRaw        String?
  validationStatus      FileRowValidationStatus  @default(PENDING_REVIEW)
  validationMessage     String?
  createdAt             DateTime                 @default(now())

  cashierSalesFile      CashierSalesFile         @relation(fields: [cashierSalesFileId], references: [id])
  ledgers               SalesLedger[]

  @@unique([cashierSalesFileId, rowNumber])
  @@index([transactionExternalId])
  @@index([productCode, validationStatus])
}

model SalesLedger {
  id                       String                 @id @default(uuid()) @db.Uuid
  settlementWeekId         String                 @db.Uuid
  cashierSalesFileId       String?                @db.Uuid
  cashierSalesFileRowId    String?                @db.Uuid
  farmerId                 String?                @db.Uuid
  productId                String?                @db.Uuid
  transactionType          TransactionType
  transactionDate          DateTime
  externalTransactionId    String?
  quantity                 Decimal                @db.Decimal(12, 3)
  regularSaleAmount        Int                    @default(0)
  actualSaleAmount         Int                    @default(0)
  discountAmount           Int                    @default(0)
  returnAmount             Int                    @default(0)
  netSalesAmount           Int                    @default(0)
  mappingStatus            MappingStatus          @default(UNMAPPED)
  settlementStatus         LedgerSettlementStatus @default(PENDING)
  appliedRuleId            String?                @db.Uuid
  appliedRuleSnapshot      Json?
  commissionBaseAmount     Int?
  commissionAmount         Int?
  farmerReturnBurdenAmount Int                    @default(0)
  createdAt                DateTime               @default(now())
  updatedAt                DateTime               @updatedAt

  settlementWeek           SettlementWeek         @relation(fields: [settlementWeekId], references: [id])
  cashierSalesFile         CashierSalesFile?      @relation(fields: [cashierSalesFileId], references: [id])
  cashierSalesFileRow      CashierSalesFileRow?   @relation(fields: [cashierSalesFileRowId], references: [id])
  farmer                   Farmer?                @relation(fields: [farmerId], references: [id])
  product                  Product?               @relation(fields: [productId], references: [id])
  appliedRule              SettlementRule?        @relation(fields: [appliedRuleId], references: [id])
  errors                   SettlementError[]
  disputes                 Dispute[]

  @@index([settlementWeekId, mappingStatus, settlementStatus])
  @@index([settlementWeekId, farmerId, productId])
  @@index([externalTransactionId])
}

model Disposal {
  id                      String       @id @default(uuid()) @db.Uuid
  settlementWeekId        String       @db.Uuid
  farmerId                String       @db.Uuid
  productId               String       @db.Uuid
  disposalType            DisposalType
  reasonDetail            String?
  quantity                Decimal      @db.Decimal(12, 3)
  burdenParty             BurdenParty
  settlementRuleId        String?      @db.Uuid
  settlementRuleSnapshot  Json?
  settlementImpactAmount  Int
  notifiedFarmerAt        DateTime?
  notificationMethod      String?
  handledById             String       @db.Uuid
  handledAt               DateTime
  createdAt               DateTime     @default(now())
  updatedAt               DateTime     @updatedAt

  settlementWeek          SettlementWeek @relation(fields: [settlementWeekId], references: [id])
  farmer                  Farmer       @relation(fields: [farmerId], references: [id])
  product                 Product      @relation(fields: [productId], references: [id])
  settlementRule          SettlementRule? @relation(fields: [settlementRuleId], references: [id])
  handledBy               User         @relation(fields: [handledById], references: [id])
  evidences               Evidence[]
  errors                  SettlementError[]
  disputes                Dispute[]

  @@index([settlementWeekId, farmerId, productId])
}

model SettlementError {
  id                String              @id @default(uuid()) @db.Uuid
  settlementWeekId  String              @db.Uuid
  salesLedgerId     String?             @db.Uuid
  disposalId        String?             @db.Uuid
  errorType         SettlementErrorType
  severity          ErrorSeverity
  status            ErrorStatus         @default(OPEN)
  message           String
  resolutionNote    String?
  resolvedById      String?             @db.Uuid
  resolvedAt        DateTime?
  createdAt         DateTime            @default(now())

  settlementWeek    SettlementWeek      @relation(fields: [settlementWeekId], references: [id])
  salesLedger       SalesLedger?        @relation(fields: [salesLedgerId], references: [id])
  disposal          Disposal?           @relation(fields: [disposalId], references: [id])
  resolvedBy        User?               @relation(fields: [resolvedById], references: [id])

  @@index([settlementWeekId, status, severity])
}

model Reconciliation {
  id                      String          @id @default(uuid()) @db.Uuid
  settlementWeekId        String          @db.Uuid
  cashierNetSalesAmount   Int
  manualAdjustmentAmount  Int             @default(0)
  adjustedNetSalesAmount  Int
  farmerPayoutAmount      Int
  commissionAmount        Int
  farmerBurdenAmount      Int
  storeCompensationAmount Int
  currentSettlementAmount Int
  differenceAmount        Int
  calculatedAt            DateTime        @default(now())
  calculatedById          String?         @db.Uuid

  settlementWeek          SettlementWeek  @relation(fields: [settlementWeekId], references: [id])

  @@index([settlementWeekId, calculatedAt])
}

model Settlement {
  id                           String          @id @default(uuid()) @db.Uuid
  settlementWeekId             String          @db.Uuid
  farmerId                     String          @db.Uuid
  grossSalesAmount             Int             @default(0)
  discountAmount               Int             @default(0)
  netSalesAmount               Int             @default(0)
  returnAmount                 Int             @default(0)
  commissionAmount             Int             @default(0)
  farmerReturnBurdenAmount     Int             @default(0)
  farmerDisposalBurdenAmount   Int             @default(0)
  storeCompensationAmount      Int             @default(0)
  correctionAmount             Int             @default(0)
  payoutAmount                 Int             @default(0)
  calculationSnapshot          Json
  calculatedAt                 DateTime        @default(now())
  createdAt                    DateTime        @default(now())
  updatedAt                    DateTime        @updatedAt

  settlementWeek               SettlementWeek  @relation(fields: [settlementWeekId], references: [id])
  farmer                       Farmer          @relation(fields: [farmerId], references: [id])
  statement                    SettlementStatement?

  @@unique([settlementWeekId, farmerId])
  @@index([farmerId, createdAt])
}

model SettlementStatement {
  id                          String          @id @default(uuid()) @db.Uuid
  settlementId                String          @unique @db.Uuid
  statementNumber             String          @unique
  status                      StatementStatus @default(GENERATED)
  expectedDepositDateSnapshot DateTime        @db.Date
  statementSnapshot           Json
  generatedAt                 DateTime        @default(now())
  generatedById               String          @db.Uuid
  closedAt                    DateTime?
  closedById                  String?         @db.Uuid

  settlement                  Settlement      @relation(fields: [settlementId], references: [id])
  generatedBy                 User            @relation("StatementGenerator", fields: [generatedById], references: [id])
  closedBy                    User?           @relation("StatementCloser", fields: [closedById], references: [id])
  corrections                 CorrectionItem[]
  disputes                    Dispute[]

  @@index([status, expectedDepositDateSnapshot])
}

model CorrectionItem {
  id                     String           @id @default(uuid()) @db.Uuid
  originalStatementId    String           @db.Uuid
  targetSettlementWeekId String           @db.Uuid
  farmerId               String           @db.Uuid
  amount                 Int
  reason                 String
  status                 CorrectionStatus @default(DRAFT)
  createdById            String           @db.Uuid
  confirmedById          String?          @db.Uuid
  confirmedAt            DateTime?
  appliedAt              DateTime?
  createdAt              DateTime         @default(now())
  updatedAt              DateTime         @updatedAt

  originalStatement      SettlementStatement @relation(fields: [originalStatementId], references: [id])
  targetSettlementWeek   SettlementWeek      @relation(fields: [targetSettlementWeekId], references: [id])
  farmer                 Farmer              @relation(fields: [farmerId], references: [id])
  createdBy              User                @relation("CorrectionCreator", fields: [createdById], references: [id])
  confirmedBy            User?               @relation("CorrectionConfirmer", fields: [confirmedById], references: [id])

  @@index([targetSettlementWeekId, farmerId, status])
  @@index([originalStatementId])
}

model Dispute {
  id                    String        @id @default(uuid()) @db.Uuid
  settlementStatementId String        @db.Uuid
  farmerId              String        @db.Uuid
  salesLedgerId         String?       @db.Uuid
  disposalId            String?       @db.Uuid
  content               String
  status                DisputeStatus @default(SUBMITTED)
  result                String?
  handledById           String?       @db.Uuid
  handledAt             DateTime?
  farmerNotifiedAt      DateTime?
  notificationMethod    String?
  createdAt             DateTime      @default(now())
  updatedAt             DateTime      @updatedAt

  settlementStatement   SettlementStatement @relation(fields: [settlementStatementId], references: [id])
  farmer                Farmer              @relation(fields: [farmerId], references: [id])
  salesLedger           SalesLedger?        @relation(fields: [salesLedgerId], references: [id])
  disposal              Disposal?           @relation(fields: [disposalId], references: [id])
  handledBy             User?               @relation("DisputeHandler", fields: [handledById], references: [id])
  evidences             Evidence[]

  @@index([farmerId, status, createdAt])
  @@index([settlementStatementId, status])
}

model Evidence {
  id               String    @id @default(uuid()) @db.Uuid
  disputeId        String?   @db.Uuid
  disposalId       String?   @db.Uuid
  storageKey       String
  originalFileName String
  mimeType         String
  fileSize         Int
  uploadedById     String    @db.Uuid
  createdAt        DateTime  @default(now())

  dispute          Dispute?  @relation(fields: [disputeId], references: [id])
  disposal         Disposal? @relation(fields: [disposalId], references: [id])
  uploadedBy       User      @relation(fields: [uploadedById], references: [id])

  @@index([disputeId])
  @@index([disposalId])
}

model ActivityLog {
  id               String    @id @default(uuid()) @db.Uuid
  actorUserId      String?   @db.Uuid
  actionType       String
  targetType       String
  targetId         String?
  settlementWeekId String?   @db.Uuid
  beforeData       Json?
  afterData        Json?
  reason           String?
  createdAt        DateTime  @default(now())

  actorUser        User?     @relation(fields: [actorUserId], references: [id])
  settlementWeek   SettlementWeek? @relation(fields: [settlementWeekId], references: [id])

  @@index([targetType, targetId, createdAt])
  @@index([settlementWeekId, createdAt])
  @@index([actorUserId, createdAt])
}

10. 구현 시 확인할 항목

확인 항목데이터 구조 영향관련 요구사항
계산대 판매 파일의 실제 열 구성cashier_sales_file_rows.rawData에서 추출할 거래일, 거래 고유값, 상품코드, 판매금액, 할인금액, 반품금액 열을 확정해야 함FR-007, FR-009, FR-010
거래 고유값이 없는 파일의 중복 판별 기준externalTransactionId가 없을 때 거래일·상품코드·금액·수량 등의 조합을 중복 검사 기준으로 정해야 함FR-009
수수료 원 단위 반올림 기준절사, 반올림, 올림 중 매장 전체 공통 방식을 정하고 계산 로직에 고정해야 함FR-012, NFR-001
음수 지급액 처리지급액이 음수일 때 마감 허용 여부와 다음 정산 이월 방식이 필요함FR-012
농가 최초 로그인과 비밀번호 복구임시 비밀번호, 관리자 발급, 본인 확인 방식 중 하나를 정해야 함FR-001, FR-003, NFR-005
폐기 통지 기록의 마감 필수 여부현재는 농가 부담 폐기 시 경고로 설계했으며, 마감 차단 조건으로 할지는 확정 필요FR-011, FR-013
증빙 보관 기간과 삭제 기준증빙 사진의 저장 기간, 접근 범위, 백업·삭제 절차를 정해야 함FR-011, FR-022, NFR-004, NFR-011
데이터 구조 — 로컬푸드 직매장 위탁판매·정산 관리 | Prometheon