o2o-negosium-original/negodata/backend/common/database/model/models.py
Mina Choi 7f1c0be2b3 [feat] negodata: 협력사 채팅 계정(supplier_users) 관리 — 발급·비번 재설정·활성상태
- 백엔드: supplier_users/supplier_user_tokens ORM 매핑, /v1/supplier/{id}/account 4종(조회·발급·재설정·상태), 회사 스코프 게이팅, 재설정·비활성 시 토큰 삭제로 기존 로그인 즉시 무효화
- 비밀번호: 발급=서버 자동생성(1회 반환), 재설정=커스텀 지정 가능(미지정 시 자동생성)
- 프론트: 협력사 상세 SupplierAccountManager 섹션 + 목록 '채팅 계정' 컬럼, orval 재생성(SupplierType 은 enumLabels 로컬 상수로 이동)
- 테스트: test_supplier_account.py 9건, 전체 스위트 green
2026-07-10 10:14:00 +09:00

316 lines
17 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
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_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=미발송)
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)