diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 98aee18..1ee8139 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -25,29 +25,14 @@ class _DBTypeMixin: return DBType.MAIN.value -# ERD 공통 컬럼(created/updated/deleted). negodata 도메인 테이블은 단일 MAIN DB 에서 soft-delete 를 쓴다. +# ERD 공통 컬럼 class MainTableMixin(_DBTypeMixin): 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()) deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False) -# 스캐폴드 예시 계정 (negosium 골격 유산) -# 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) +# ERD 도메인 모델 class companies(MainTableMixin, MAIN_BASE): __tablename__ = "companies" @@ -122,6 +107,31 @@ class suppliers(MainTableMixin, MAIN_BASE): 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): __tablename__ = "quotation_settings" @@ -159,3 +169,39 @@ class quotations(MainTableMixin, MAIN_BASE): preferred_sp_name = Column(String(20), nullable=True) equal_bid_yn = Column(Boolean, 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 diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 9a627ef..21d115a 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -38,9 +38,11 @@ class ErrorType(Enum): # 상품 관련 에러 ITEM_NOT_FOUND = 1300 + ITEM_CODE_DUPLICATE = auto() # 협력사 관련 에러 SUPPLIER_NOT_FOUND = 1400 + SUPPLIER_CODE_DUPLICATE = auto() # 견적 관련 에러 QUOTATION_NOT_FOUND = 1500 @@ -48,6 +50,9 @@ class ErrorType(Enum): # 견적 설정 관련 에러 QUOTATION_SETTING_NOT_FOUND = 1600 + # 협상카드 관련 에러 + CARD_NOT_FOUND = 1700 + # 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) @@ -102,6 +107,30 @@ class QuotationType(Enum): 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): """items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.""" @@ -110,6 +139,13 @@ class DeliveryType(Enum): PICKUP = 3 # 픽업배송 +class CardStatus(Enum): + """nego_cards.status 코드값. 와일드카드의 협상 적용 여부(수동 승인). 일반 협상카드는 상시 ACTIVE.""" + + ACTIVE = 1 + INACTIVE = 2 + + # 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드). ENUM_LABELS = { UserStatus.ACTIVE: "활성", @@ -120,9 +156,20 @@ ENUM_LABELS = { CompanyStatus.INACTIVE: "비활성", QuotationType.RENEGO: "재협상", QuotationType.REQUOTE: "재견적", + QuotationStatus.CREATED: "견적생성", + QuotationStatus.ACTIVE: "견적진행중", + QuotationStatus.CLOSED: "견적마감", + QuotationStatus.ON_HOLD: "협상보류", + SessionStatus.NEGOTIATING: "협상중", + SessionStatus.COMPLETED: "협상종료", + SessionStatus.REJECTED: "협상거부", + ChatSender.BOT: "봇", + ChatSender.PARTNER: "협력사", DeliveryType.PARTNER: "협력사배송", DeliveryType.COURIER: "지정택배배송", DeliveryType.PICKUP: "픽업배송", + CardStatus.ACTIVE: "적용", + CardStatus.INACTIVE: "대기", } # 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다. @@ -131,5 +178,9 @@ DOMAIN_ENUMS = { "user_role": UserRole, "company_status": CompanyStatus, "quotation_type": QuotationType, + "quotation_status": QuotationStatus, + "session_status": SessionStatus, + "chat_sender": ChatSender, "delivery_type": DeliveryType, + "card_status": CardStatus, } diff --git a/negodata/backend/common/models/gmodel.py b/negodata/backend/common/models/gmodel.py index 694ab6a..cfa6d5d 100644 --- a/negodata/backend/common/models/gmodel.py +++ b/negodata/backend/common/models/gmodel.py @@ -1,6 +1,7 @@ import json from typing import Optional +from fastapi import Query from pydantic import BaseModel, Field from common.enums import ErrorType @@ -45,6 +46,24 @@ class Res_WebPacketProtocol(WebPacketProtocol): 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): """JWT subject 로 인코딩되는 유저 식별 정보."""