import uuid from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, SmallInteger, BigInteger, Numeric, Float, String, Boolean, DateTime from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.sql import text from common.enums import DBType, UserStatus, UserRole, CompanyStatus # 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. MAIN_BASE = declarative_base() # 공통 mixin # DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다. def _utc_now_sql(): return text("(now() AT TIME ZONE 'utc')") class _DBTypeMixin: """모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택).""" @staticmethod def DBType(): return DBType.MAIN.value # ERD 공통 컬럼 class MainTableMixin(_DBTypeMixin): created_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql()) deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False) # ERD 도메인 모델 class companies(MainTableMixin, MAIN_BASE): __tablename__ = "companies" __table_args__ = {"schema": "company"} company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(100), nullable=False) business_number = Column(String(30), nullable=True) code = Column(Integer, nullable=True) # 내부 인덱스용 representative_name = Column(String(50), nullable=True) email = Column(String(255), nullable=True) contact_number = Column(String(20), nullable=True) website_url = Column(String(255), nullable=True) industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT) status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus class users(MainTableMixin, MAIN_BASE): __tablename__ = "users" __table_args__ = {"schema": "company"} user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) company_id = Column(UUID(as_uuid=True), nullable=False, index=True) id = Column(String(20), nullable=False, unique=True, index=True) # 로그인 아이디 password = Column(String(255), nullable=False) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) name = Column(String(50), nullable=True) email = Column(String(255), nullable=True) contact_number = Column(String(20), nullable=True) last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) class notifications(MainTableMixin, MAIN_BASE): __tablename__ = "notifications" __table_args__ = {"schema": "company"} notification_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 수신자(users.user_id) = 견적 작성자 type = Column(SmallInteger, nullable=False) # NotificationType: 1=success(낙찰) 2=regenerated(재생성) 3=failure(결렬) ref_qt_id = Column(UUID(as_uuid=True), nullable=True) # 관련 견적(quotations.qt_id) ref_session_id = Column(UUID(as_uuid=True), nullable=True) # 관련 세션(sessions.session_id) data = Column(JSONB, nullable=True) # 렌더 스냅샷(유형별) read_at = Column(DateTime(timezone=True), nullable=True) # 읽은 시각(NULL=안읽음) class items(MainTableMixin, MAIN_BASE): __tablename__ = "items" __table_args__ = {"schema": "partner"} item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) company_id = Column(UUID(as_uuid=True), nullable=False, index=True) user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 등록한 유저 name = Column(String(100), nullable=False) code = Column(String(30), nullable=True) category = Column(String(255), nullable=True) category_type = Column(Integer, nullable=False, default=1) # 카테고리 탐색용 자동 증가 숫자 image_url = Column(String(255), nullable=True) model_name = Column(String(100), nullable=True) spec = Column(String(255), nullable=True) manufacturer = Column(String(50), nullable=True) made_in = Column(String(100), nullable=True) price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용 internet_lowest_price = Column(BigInteger, nullable=True) purchase_price = Column(BigInteger, nullable=True) selling_price = Column(BigInteger, nullable=True) moq = Column(String(50), nullable=True) # 최소 주문 수량 lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간 quantity_unit = Column(String(50), nullable=True) # 단위 라벨(자유입력): EA/BOX/SET/ROLL ... delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드 vat_yn = Column(Boolean, nullable=True) delivery_fee_yn = Column(Boolean, nullable=True) class suppliers(MainTableMixin, MAIN_BASE): __tablename__ = "suppliers" __table_args__ = {"schema": "partner"} supplier_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) company_id = Column(UUID(as_uuid=True), nullable=False, index=True) user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 등록한 유저 name = Column(String(100), nullable=False) code = Column(String(20), nullable=True) manager_name = Column(String(50), nullable=True) manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 total_revenue = Column(BigInteger, nullable=True) # 총매출액 class supplier_items(MainTableMixin, MAIN_BASE): __tablename__ = "supplier_items" # (supplier_id, item_id) 유일성은 soft-delete 인지 부분 유니크 인덱스(uq_supplier_items, WHERE deleted=FALSE)로 DB에서 보장. __table_args__ = {"schema": "partner"} supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id # SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) class nego_cards(MainTableMixin, MAIN_BASE): __tablename__ = "nego_cards" __table_args__ = {"schema": "card"} 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) usage_type = Column(SmallInteger, nullable=False, default=1) class wild_cards(MainTableMixin, MAIN_BASE): __tablename__ = "wild_cards" __table_args__ = {"schema": "card"} 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) usage_type = Column(SmallInteger, nullable=False, default=1) condition = Column(String(255), nullable=True) # 사용 조건(트리거) available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑) memo = Column(String(255), nullable=True) # 자유 메모 class versions(MainTableMixin, MAIN_BASE): __tablename__ = "versions" __table_args__ = {"schema": "card"} version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 버전 생성 유저 code = Column(Integer, nullable=False, default=0) # 빠른 조회용 name = Column(String(10), nullable=False) # 버전명 class version_nego_cards(MainTableMixin, MAIN_BASE): __tablename__ = "version_nego_cards" __table_args__ = {"schema": "card"} vnc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id nego_card_id = Column(UUID(as_uuid=True), nullable=False, index=True) # nego_cards.nego_card_id class version_wild_cards(MainTableMixin, MAIN_BASE): __tablename__ = "version_wild_cards" __table_args__ = {"schema": "card"} vwc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id wild_card_id = Column(UUID(as_uuid=True), nullable=False, index=True) # wild_cards.wild_card_id class quotation_settings(MainTableMixin, MAIN_BASE): __tablename__ = "quotation_settings" __table_args__ = {"schema": "quotation"} qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저 target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용) card_count = Column(Integer, nullable=False, default=3) # 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨. class quotations(MainTableMixin, MAIN_BASE): __tablename__ = "quotations" __table_args__ = {"schema": "quotation"} qt_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), nullable=False, index=True) qt_setting_id = Column(UUID(as_uuid=True), nullable=False, index=True) version_id = Column(UUID(as_uuid=True), nullable=False, index=True) name = Column(String(50), nullable=False) number = Column(String(30), nullable=False) type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) / 3=new_nego(1:1) / 4=new_quote(1:N) round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가 status = Column(SmallInteger, nullable=False) # 진행 상태 코드 start_time = Column(DateTime(timezone=True), nullable=False) end_time = Column(DateTime(timezone=True), nullable=False) manager_name = Column(String(50), nullable=True) manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) memo = Column(String(100), nullable=True) md_price = Column(BigInteger, nullable=True) iteration = Column(Integer, nullable=False, default=0) preferred_sp_yn = Column(Boolean, nullable=True) preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) preferred_sp_name = Column(String(20), nullable=True) equal_bid_yn = Column(Boolean, nullable=True) equal_bid_data = Column(JSONB, nullable=True) close_reason = Column(SmallInteger, nullable=True) # CloseReason 코드. 마감 시 사유 기록(재생성 한도 카운팅·유찰 사유 구분). 미마감이면 NULL # 낙찰 기준(가격게이트) — 견적 단위. 마감 판정(close_and_decide)이 이 행값을 읽는다. 기준 미달이면 개찰(낙찰자 미정 마감). # 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰). mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰) over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰) class sessions(MainTableMixin, MAIN_BASE): __tablename__ = "sessions" __table_args__ = {"schema": "negotiation"} 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) # 목표가(원) anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준) anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함 status = Column(SmallInteger, nullable=False) # SessionStatus 코드 bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 end_time = Column(DateTime(timezone=True), nullable=False) # 세션 종료 시각 reject_reason = Column(String(255), nullable=True) reject_price = Column(BigInteger, nullable=True) reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송) class chats(MainTableMixin, MAIN_BASE): __tablename__ = "chats" __table_args__ = {"schema": "negotiation"} 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 meta = Column(JSONB, nullable=True) # 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end)