o2o-negosium-original/negodata/backend/common/database/model/models.py
민헌 0cefe315c4 [feat] negodata/backend: 몰별 최저가(by_mall) 노출 + 강제 재검색(force) 연동
몰별 성공/실패 표시
  lps_db.price_history 에는 몰별 정보가 다 있는데(naver_lowest·coupang_lowest·by_mall),
  동기화가 final_source(이긴 몰) 하나만 남기고 나머지를 버려서 API 로는
  "네이버는 어땠는지"를 알 수 없었다. 실제로 네이버는 빈손이고 쿠팡만 성공하는
  케이스가 기본값처럼 나오는 중이라 화면에 드러낼 필요가 있다.
  → price_history.by_mall(JSONB)을 partner.item_internet_lowest_prices 로 그대로
    미러링한다(열린 스키마 — 오픈마켓 폴백이 늘어도 스키마 변경 불필요).
  - crud: 읽기 계약에 by_mall 추가 + SELECT 포함
  - model: item_internet_lowest_prices.by_mall
  - service: 언팩·저장
  - protocol: LowestPriceEntry.by_mall
  - init.sql: 테이블 정의 + 하단 보정 ALTER(기존 DB 반영용)

강제 재검색
  POST /v1/item/{id}/lowest-price?force=true → LPS 네거티브 캐시 우회.
  사용자가 '다시 검색'을 누른 경우에만 true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:55:38 +09:00

349 lines
20 KiB
Python

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) — 근거 검증용
# 몰별 최저가 스냅샷 — lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마).
# [{source, mall_name, price, shipping_fee, shipping_type, name, detail_url}, ...] 가격 오름차순.
# 매칭된 몰만 들어오므로, 특정 몰이 없으면 그 몰은 빈손이었다는 뜻이다(네이버 실패/쿠팡만 성공 구분).
by_mall = Column(JSONB, nullable=True)
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)