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, SupplierUserStatus, SupplierUserRole # 모든 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 # 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)/item_fields·supplier_fields(커스텀필드 정의) settings = Column(JSONB, nullable=True) 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) # 회사 커스텀필드 값. 정의(키/라벨/타입)는 companies.settings.item_fields, 여기는 {key: value}만 custom = Column(JSONB, nullable=True) class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): """인터넷 최저가 수집 이력(시도 단위, 성공/실패 모두 기록). 확정 대표값은 items.internet_lowest_price 에 박제된다(N건 이력 → 1건 대표 패턴). LPS 동기화 배치(services/lps_sync_service)가 lps_db.price_history 를 읽어 채운다.""" __tablename__ = "item_internet_lowest_prices" __table_args__ = {"schema": "partner"} lp_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) item_id = Column(UUID(as_uuid=True), nullable=False, index=True) lp_price = Column(BigInteger, nullable=True) # 수집한 최저가(원). 실패면 NULL website = Column(SmallInteger, nullable=False) # LowestPriceWebsite 코드 success_yn = Column(Boolean, nullable=False) fail_reason = Column(String(100), nullable=True) # 실패 사유(예: not_found) ai_model = Column(SmallInteger, nullable=True) # (예약) AI 모델 코드 — LPS 계약엔 미포함 crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함 lp_name = Column(String(300), nullable=True) # 찾은 상품명(판매 페이지 기준) — 근거 검증용 lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용 crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준) 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) # 총매출액 # 회사 커스텀필드 값. 정의는 companies.settings.supplier_fields, 여기는 {key: value}만 custom = Column(JSONB, nullable=True) class supplier_users(MainTableMixin, MAIN_BASE): __tablename__ = "supplier_users" __table_args__ = {"schema": "supplier"} su_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # partner.suppliers (no-FK) id = Column(String(20), nullable=False, index=True) # 로그인 아이디 password = Column(String(255), nullable=False) # bcrypt 해시 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=SupplierUserStatus.ACTIVE.value) role = Column(SmallInteger, nullable=False, default=SupplierUserRole.MANAGER.value) hide_service_info = Column(Boolean, nullable=False, server_default=text("false"), default=False) # 채팅 계정의 로그인 토큰(루트 backend 소유) — negodata 는 비번재설정/비활성 시 기존 로그인을 끊기 위한 삭제만 한다. class supplier_user_tokens(MainTableMixin, MAIN_BASE): __tablename__ = "supplier_user_tokens" __table_args__ = {"schema": "supplier"} sut_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) su_id = Column(UUID(as_uuid=True), nullable=False, index=True) # supplier_users.su_id type = Column(SmallInteger, nullable=False) token = Column(JSONB, nullable=False) issued_at = Column(DateTime(timezone=True), nullable=False) expired_at = Column(DateTime(timezone=True), nullable=False) 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=미발송) custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields) 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)