[refactor] negodata/backend: 공용 PageParams(page/size/skip) + 상태 enum·모델 정비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-18 11:00:15 +09:00
parent ece24f4067
commit 4587be5844
3 changed files with 133 additions and 17 deletions

View File

@ -25,29 +25,14 @@ class _DBTypeMixin:
return DBType.MAIN.value return DBType.MAIN.value
# ERD 공통 컬럼(created/updated/deleted). negodata 도메인 테이블은 단일 MAIN DB 에서 soft-delete 를 쓴다. # ERD 공통 컬럼
class MainTableMixin(_DBTypeMixin): class MainTableMixin(_DBTypeMixin):
created_at = Column(DateTime, nullable=False, server_default=_utc_now_sql()) created_at = Column(DateTime, nullable=False, server_default=_utc_now_sql())
updated_at = Column(DateTime, nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql()) updated_at = Column(DateTime, nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql())
deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False) deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False)
# 스캐폴드 예시 계정 (negosium 골격 유산) # ERD 도메인 모델
# negodata 인증은 ERD 의 users/companies 를 쓴다(아래). tbl_account 는 스캐폴드
# 테스트 호환을 위해 남겨둔다 (negodata_db 의 postgres-init 이 생성).
class tbl_account(_DBTypeMixin, MAIN_BASE):
__tablename__ = "tbl_account"
uid = Column(Integer, primary_key=True, autoincrement=True)
id = Column(String(45), nullable=False, unique=True)
pw = Column(String(255), nullable=False, default="")
nickname = Column(String(45), nullable=False, default="")
is_blocked = Column(Boolean, nullable=False, default=False)
last_login_at = Column(DateTime, nullable=False, server_default=_utc_now_sql())
create_at = Column(DateTime, server_default=_utc_now_sql())
# ERD 도메인 모델 (negosium.sql 기준, UUID PK)
class companies(MainTableMixin, MAIN_BASE): class companies(MainTableMixin, MAIN_BASE):
__tablename__ = "companies" __tablename__ = "companies"
@ -122,6 +107,31 @@ class suppliers(MainTableMixin, MAIN_BASE):
priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능 priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능
class nego_cards(MainTableMixin, MAIN_BASE):
__tablename__ = "nego_cards"
nego_card_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 등록 유저(o2o 기본 카드는 NULL)
name = Column(String(20), nullable=True) # 카드명
number = Column(String(10), nullable=True) # 식별번호(카드코드)
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
class wild_cards(MainTableMixin, MAIN_BASE):
__tablename__ = "wild_cards"
wild_card_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 등록 유저(o2o 기본 카드는 NULL)
name = Column(String(20), nullable=True) # 카드명
number = Column(String(10), nullable=True) # 식별번호(카드코드)
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
condition = Column(String(255), nullable=True) # 사용 조건(트리거)
available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑)
memo = Column(String(255), nullable=True) # 자유 메모
class quotation_settings(MainTableMixin, MAIN_BASE): class quotation_settings(MainTableMixin, MAIN_BASE):
__tablename__ = "quotation_settings" __tablename__ = "quotation_settings"
@ -159,3 +169,39 @@ class quotations(MainTableMixin, MAIN_BASE):
preferred_sp_name = Column(String(20), nullable=True) preferred_sp_name = Column(String(20), nullable=True)
equal_bid_yn = Column(Boolean, nullable=True) equal_bid_yn = Column(Boolean, nullable=True)
equal_bid_data = Column(JSONB, nullable=True) equal_bid_data = Column(JSONB, nullable=True)
class sessions(MainTableMixin, MAIN_BASE):
__tablename__ = "sessions"
session_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
quotation_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 소속 견적(quotations.qt_id)
item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 대상 상품(items.item_id)
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 대상 공급사(suppliers.supplier_id)
qt_number = Column(String(30), nullable=False) # 견적번호 스냅샷
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
target_price = Column(BigInteger, nullable=False) # 목표가(원)
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime, nullable=True) # 입찰 시각
end_time = Column(DateTime, nullable=False) # 세션 종료 시각
reject_reason = Column(String(255), nullable=True)
reject_price = Column(BigInteger, nullable=True)
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
class chats(MainTableMixin, MAIN_BASE):
__tablename__ = "chats"
chat_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 소속 세션(sessions.session_id)
card_id = Column(UUID(as_uuid=True), nullable=True) # 사용된 카드(nego_cards.nego_card_id)
seq = Column(Integer, nullable=False, default=1) # 세션 내 메시지 순번(프로토콜 index)
sender = Column(SmallInteger, nullable=False) # ChatSender 코드(1=bot, 2=partner)
target_price = Column(BigInteger, nullable=False) # 제시 목표가(원)
card_used_yn = Column(Boolean, nullable=True) # 카드 사용 여부
indicator_value = Column(Numeric(8, 6), nullable=True)
card_type = Column(SmallInteger, nullable=True) # 1=nego_card, 2=wild_card

View File

@ -38,9 +38,11 @@ class ErrorType(Enum):
# 상품 관련 에러 # 상품 관련 에러
ITEM_NOT_FOUND = 1300 ITEM_NOT_FOUND = 1300
ITEM_CODE_DUPLICATE = auto()
# 협력사 관련 에러 # 협력사 관련 에러
SUPPLIER_NOT_FOUND = 1400 SUPPLIER_NOT_FOUND = 1400
SUPPLIER_CODE_DUPLICATE = auto()
# 견적 관련 에러 # 견적 관련 에러
QUOTATION_NOT_FOUND = 1500 QUOTATION_NOT_FOUND = 1500
@ -48,6 +50,9 @@ class ErrorType(Enum):
# 견적 설정 관련 에러 # 견적 설정 관련 에러
QUOTATION_SETTING_NOT_FOUND = 1600 QUOTATION_SETTING_NOT_FOUND = 1600
# 협상카드 관련 에러
CARD_NOT_FOUND = 1700
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
@ -102,6 +107,30 @@ class QuotationType(Enum):
REQUOTE = 2 REQUOTE = 2
class QuotationStatus(Enum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
CREATED = 1 # 견적생성
ACTIVE = 2 # 견적진행중
CLOSED = 3 # 견적마감
ON_HOLD = 4 # 협상보류
class SessionStatus(Enum):
"""negotiation.sessions.status 코드값. 협력사별 협상 세션 진행 상태."""
NEGOTIATING = 1 # 협상중
COMPLETED = 2 # 협상종료
REJECTED = 3 # 협상거부
class ChatSender(Enum):
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""
BOT = 1 # 구매대행 봇
PARTNER = 2 # 협력사
class DeliveryType(Enum): class DeliveryType(Enum):
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.""" """items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
@ -110,6 +139,13 @@ class DeliveryType(Enum):
PICKUP = 3 # 픽업배송 PICKUP = 3 # 픽업배송
class CardStatus(Enum):
"""nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE."""
ACTIVE = 1
INACTIVE = 2
# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드). # 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드).
ENUM_LABELS = { ENUM_LABELS = {
UserStatus.ACTIVE: "활성", UserStatus.ACTIVE: "활성",
@ -120,9 +156,20 @@ ENUM_LABELS = {
CompanyStatus.INACTIVE: "비활성", CompanyStatus.INACTIVE: "비활성",
QuotationType.RENEGO: "재협상", QuotationType.RENEGO: "재협상",
QuotationType.REQUOTE: "재견적", QuotationType.REQUOTE: "재견적",
QuotationStatus.CREATED: "견적생성",
QuotationStatus.ACTIVE: "견적진행중",
QuotationStatus.CLOSED: "견적마감",
QuotationStatus.ON_HOLD: "협상보류",
SessionStatus.NEGOTIATING: "협상중",
SessionStatus.COMPLETED: "협상종료",
SessionStatus.REJECTED: "협상거부",
ChatSender.BOT: "봇",
ChatSender.PARTNER: "협력사",
DeliveryType.PARTNER: "협력사배송", DeliveryType.PARTNER: "협력사배송",
DeliveryType.COURIER: "지정택배배송", DeliveryType.COURIER: "지정택배배송",
DeliveryType.PICKUP: "픽업배송", DeliveryType.PICKUP: "픽업배송",
CardStatus.ACTIVE: "적용",
CardStatus.INACTIVE: "대기",
} }
# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다. # 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다.
@ -131,5 +178,9 @@ DOMAIN_ENUMS = {
"user_role": UserRole, "user_role": UserRole,
"company_status": CompanyStatus, "company_status": CompanyStatus,
"quotation_type": QuotationType, "quotation_type": QuotationType,
"quotation_status": QuotationStatus,
"session_status": SessionStatus,
"chat_sender": ChatSender,
"delivery_type": DeliveryType, "delivery_type": DeliveryType,
"card_status": CardStatus,
} }

View File

@ -1,6 +1,7 @@
import json import json
from typing import Optional from typing import Optional
from fastapi import Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from common.enums import ErrorType from common.enums import ErrorType
@ -45,6 +46,24 @@ class Res_WebPacketProtocol(WebPacketProtocol):
msg: Optional[str] = None msg: Optional[str] = None
class Res_PageProtocol(Res_WebPacketProtocol):
# 목록 응답 공용 페이지 메타.
total: int = 0
page: int = 0
size: int = 0
class PageParams:
# 목록 엔드포인트 공용 쿼리 파라미터. 라우터에서 Depends() 로 주입한다.
def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
self.page = page
self.size = size
@property
def skip(self) -> int:
return (self.page - 1) * self.size
class UserInfo(StructModel): class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보.""" """JWT subject 로 인코딩되는 유저 식별 정보."""