[refactor] backend services/router 정리 — chat 분리·staticmethod 집약·protocol 문서화·테스트 보강
- router: 채팅 라우터/프로토콜을 negotiation/ 에서 chat/ 으로 분리(git mv, URL 유지). chat_protocol.py → chat/protocol.py (폴더당 protocol.py convention 복원), router 등록·import 갱신. - chat_service: 순수 헬퍼/매퍼를 모듈 함수 → 클래스 @staticmethod 로 이동하고 클래스 상단에 집약. - protocol: Req 모델 + 코드/enum 필드에 Field(description=...) 추가(Swagger 노출), Req 문자열 필드에 max_length(DB 컬럼 정합) 추가 → 초과 입력이 DB 오류 대신 422. - models: updated_at 에 onupdate 추가 → UPDATE 시 자동 갱신(negodata convention 일치). - tests: negotiation reject e2e 6케이스 + chat 순수 헬퍼 단위 4케이스 추가(45→55 passed). - enums: 장황한 주석/docstring 축약(코드값 변경 없음). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4dc862912
commit
1a933409a3
@ -30,7 +30,7 @@ class supplier_users(MAIN_BASE):
|
||||
status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive
|
||||
role = Column(SmallInteger, nullable=False, server_default=text("1")) # 권한: 1=user, 2=manager
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ class suppliers(MAIN_BASE):
|
||||
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
|
||||
priority = Column(String(10), nullable=True) # 우선순위 (고객사별 문자열 값 가능)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -87,7 +87,7 @@ class items(MAIN_BASE):
|
||||
internet_lowest_price_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼
|
||||
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -116,7 +116,7 @@ class sessions(MAIN_BASE):
|
||||
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
|
||||
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -151,7 +151,7 @@ class quotations(MAIN_BASE):
|
||||
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
|
||||
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -170,7 +170,7 @@ class quotation_settings(MAIN_BASE):
|
||||
anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value))
|
||||
card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -194,7 +194,7 @@ class chats(MAIN_BASE):
|
||||
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)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -214,5 +214,5 @@ class supplier_user_tokens(MAIN_BASE):
|
||||
issued_at = Column(DateTime(timezone=True), nullable=False) # 발급 시각
|
||||
expired_at = Column(DateTime(timezone=True), nullable=False) # 만료 시각
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
@ -81,10 +81,6 @@ class DBWRType(Enum):
|
||||
DB_WRITE = 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 도메인 코드값. 스키마는 SMALLINT 정수 코드(1부터)로 두고, 의미 매핑은 여기 enum 으로 한다.
|
||||
# (postgres-init/01-schema.sql: "코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다")
|
||||
# ============================================================
|
||||
class AccountStatus(Enum):
|
||||
"""계정 상태 코드. company.users / supplier.supplier_users 의 status 컬럼."""
|
||||
|
||||
@ -114,9 +110,7 @@ class QtType(Enum):
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
"""협상 세션 진행 상태 코드. negotiation.sessions.status.
|
||||
⚠️ 세션을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
|
||||
"""
|
||||
"""협상 세션 진행 상태 코드. negotiation.sessions.status"""
|
||||
|
||||
CREATED = 1 # 협상생성
|
||||
IN_PROGRESS = 2 # 협상중
|
||||
@ -126,9 +120,7 @@ class SessionStatus(Enum):
|
||||
|
||||
|
||||
class QuotationStatus(Enum):
|
||||
"""견적 진행 상태 코드. quotation.quotations.status.
|
||||
⚠️ 견적을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
|
||||
"""
|
||||
"""견적 진행 상태 코드. quotation.quotations.status """
|
||||
|
||||
CREATED = 1 # 견적생성
|
||||
IN_PROGRESS = 2 # 견적진행중
|
||||
@ -136,18 +128,14 @@ class QuotationStatus(Enum):
|
||||
|
||||
|
||||
class ChatSender(Enum):
|
||||
"""채팅 발신자 코드. negotiation.chats.sender.
|
||||
BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다.
|
||||
"""
|
||||
"""채팅 발신자 코드. negotiation.chats.sender """
|
||||
|
||||
BOT = 1 # 갑(바이어/agent) — bot 메시지
|
||||
USER = 2 # 공급사(을) — user 입력
|
||||
BOT = 1 # 공급사(갑) — bot 메시지
|
||||
USER = 2 # 협력사(을) — user 입력
|
||||
|
||||
|
||||
class DeliveryType(Enum):
|
||||
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type.
|
||||
재견적(CM) 협상의 '배송형태선택' 단계 라벨과 1:1 (SHARED_ENUMS §6, negodata 정의 채택).
|
||||
"""
|
||||
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type """
|
||||
|
||||
SUPPLIER = 1 # 협력사배송
|
||||
COURIER = 2 # 지정택배배송
|
||||
|
||||
@ -11,7 +11,7 @@ from common.utils.gtime import GTime
|
||||
from config.server_configs import web_server_config
|
||||
import router.v1.auth.account
|
||||
import router.v1.negotiation.session
|
||||
import router.v1.negotiation.chat
|
||||
import router.v1.chat.chat
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@ -58,4 +58,4 @@ async def healthz():
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.negotiation.session.router)
|
||||
app.include_router(router.v1.negotiation.chat.router)
|
||||
app.include_router(router.v1.chat.chat.router)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -7,28 +9,28 @@ class AuthProtocol(WebPacketProtocol):
|
||||
|
||||
|
||||
class Req_Login(AuthProtocol):
|
||||
id: str = ""
|
||||
pw: str = ""
|
||||
id: str = Field("", description="로그인 ID")
|
||||
pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시·검증)")
|
||||
|
||||
|
||||
class Res_Login(Res_WebPacketProtocol):
|
||||
su_id: str = ""
|
||||
name: str = "" # 유저 개인 이름
|
||||
supplier_id: str = "" # 소속 공급사(partner.suppliers)
|
||||
supplier_name: str = "" # 공급사명
|
||||
role: int = 0
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
su_id: str = Field("", description="유저 식별자(uuid)")
|
||||
name: str = Field("", description="유저 개인 이름")
|
||||
supplier_id: str = Field("", description="소속 공급사 uuid(partner.suppliers)")
|
||||
supplier_name: str = Field("", description="공급사명")
|
||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
access_token: str = Field("", description="액세스 토큰(JWT)")
|
||||
refresh_token: str = Field("", description="리프레시 토큰(JWT)")
|
||||
|
||||
|
||||
class Req_CreateAccount(AuthProtocol):
|
||||
supplier_id: str = "" # 소속 공급사(partner.suppliers.supplier_id)
|
||||
id: str = "" # 로그인 ID
|
||||
pw: str = ""
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
contact_number: str = ""
|
||||
role: int = 1 # 1=user, 2=manager (UserRole)
|
||||
supplier_id: str = Field("", max_length=36, description="소속 공급사 uuid(partner.suppliers.supplier_id)")
|
||||
id: str = Field("", max_length=20, description="로그인 ID")
|
||||
pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시)")
|
||||
name: str = Field("", max_length=50, description="이름")
|
||||
email: str = Field("", max_length=255, description="이메일")
|
||||
contact_number: str = Field("", max_length=20, description="연락처")
|
||||
role: int = Field(1, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
|
||||
|
||||
class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
@ -36,16 +38,16 @@ class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
access_token: str = ""
|
||||
access_token: str = Field("", description="재발급된 액세스 토큰(JWT)")
|
||||
|
||||
|
||||
class Res_Me(Res_WebPacketProtocol):
|
||||
su_id: str = ""
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
supplier_id: str = ""
|
||||
supplier_name: str = ""
|
||||
role: int = 0
|
||||
su_id: str = Field("", description="유저 식별자(uuid)")
|
||||
id: str = Field("", description="로그인 ID")
|
||||
name: str = Field("", description="유저 개인 이름")
|
||||
supplier_id: str = Field("", description="소속 공급사 uuid")
|
||||
supplier_name: str = Field("", description="공급사명")
|
||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
|
||||
|
||||
class Res_Logout(Res_WebPacketProtocol):
|
||||
|
||||
@ -4,9 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||
from services.chat_service import ChatService
|
||||
from .chat_protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from .protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}})
|
||||
# URL 은 협상 세션의 하위 리소스라 prefix 는 /v1/negotiation 유지(파일만 chat/ 로 분리).
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Chat"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -9,6 +9,8 @@ summary(요약카드 데이터)는 backend 가 비즈니스 데이터로 조립
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -43,25 +45,25 @@ class ChatMessage(WebPacketProtocol):
|
||||
chat_id: str = ""
|
||||
session_id: str = ""
|
||||
seq: int = 0
|
||||
sender: int = 0 # ChatSender 코드
|
||||
sender: int = Field(0, description="발신자 코드 (ChatSender: 1=BOT, 2=USER)")
|
||||
script: str = ""
|
||||
user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price
|
||||
user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price")
|
||||
step: str = ""
|
||||
display_step: str = "" # agent client_step
|
||||
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
|
||||
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
|
||||
display_step: str = Field("", description="agent client_step (표시용 단계)")
|
||||
next_input_mode: Optional[str] = Field(None, description="다음 입력 모드: confirm|yes_no|percent|price|delivery_type")
|
||||
next_input_type: Optional[list[str]] = Field(None, description="다음 입력 선택지(버튼 라벨)")
|
||||
chat_end: bool = False
|
||||
indicator_value: Optional[float] = None # 협상 지표(1~99). agent 가 가격협상 턴에 내려주면 표시.
|
||||
bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator
|
||||
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐
|
||||
indicator_value: Optional[float] = Field(None, description="협상 지표(1~99). 가격협상 턴에 표시")
|
||||
bot_chat_type: Optional[str] = Field(None, description="폼 종류: summaryRSP|summaryCM|rejectRSP|rejectCM|indicator")
|
||||
summary: Optional[ChatSummary] = Field(None, description="summaryRSP/summaryCM 일 때만 채워짐")
|
||||
|
||||
|
||||
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
|
||||
class Res_ChatInit(Res_WebPacketProtocol):
|
||||
session_id: str = ""
|
||||
session_status: int = 0 # SessionStatus 코드
|
||||
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
|
||||
quotation_id: str = ""
|
||||
quotation_end_time: str = "" # ISO 8601 (마감 시각)
|
||||
quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)")
|
||||
quotation_memo: str = ""
|
||||
item_id: str = ""
|
||||
item_name: str = ""
|
||||
@ -84,8 +86,8 @@ class Res_ChatMessages(Res_WebPacketProtocol):
|
||||
|
||||
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
|
||||
class Req_ChatSend(WebPacketProtocol):
|
||||
user_input_type: Optional[str] = None # text|percent|price
|
||||
user_input: str = ""
|
||||
user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price")
|
||||
user_input: str = Field("", description="버튼 선택 텍스트 또는 가격/퍼센트 문자열")
|
||||
|
||||
|
||||
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)
|
||||
@ -1,13 +1,15 @@
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
# 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다.
|
||||
class ListItem(WebPacketProtocol):
|
||||
session_id: str = ""
|
||||
session_status: int = 0 # SessionStatus 코드
|
||||
qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적)
|
||||
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
|
||||
qt_type: int = Field(0, description="견적 종류 코드 (1=재협상, 2=재견적)")
|
||||
qt_number: str = ""
|
||||
qt_end_time: str = "" # ISO 8601 (마감 시각)
|
||||
qt_end_time: str = Field("", description="견적 마감 시각 (ISO 8601)")
|
||||
item_code: str = ""
|
||||
item_name: str = ""
|
||||
model_name: str = ""
|
||||
@ -22,12 +24,12 @@ class Res_SessionList(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class Res_Participate(Res_WebPacketProtocol):
|
||||
session_id: str = "" # 참여 성공한 세션 (채팅 진입용)
|
||||
session_id: str = Field("", description="참여 성공한 세션 uuid (채팅 진입용)")
|
||||
|
||||
|
||||
class Req_Reject(WebPacketProtocol):
|
||||
reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력)
|
||||
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
||||
|
||||
|
||||
class Res_Reject(Res_WebPacketProtocol):
|
||||
session_id: str = "" # 거부 처리된 세션
|
||||
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
||||
|
||||
@ -24,58 +24,19 @@ from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||
from router.v1.negotiation.chat_protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||
from services.auth_service import AuthService
|
||||
|
||||
# 종료 스텝 → 프론트 폼 종류(bot_chat_type).
|
||||
# 폼 접미사: RSP=재협상(renegotiation), CM=재견적(requote). qt_type 1=재협상, 2=재견적.
|
||||
# 협상완료/결과안내 → 요약카드, 협상실패 → 합의불가(거부) 폼.
|
||||
# 종료 step → 프론트 폼 종류(bot_chat_type). RSP=재협상, CM=재견적.
|
||||
_SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"}
|
||||
_REJECT_STEPS = {"협상실패"}
|
||||
|
||||
|
||||
def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]:
|
||||
if not step:
|
||||
return None
|
||||
is_reneg = qt_type == 1 # 1=재협상(RSP), 그 외(2)=재견적(CM)
|
||||
if step in _SUMMARY_STEPS:
|
||||
return "summaryRSP" if is_reneg else "summaryCM"
|
||||
if step in _REJECT_STEPS:
|
||||
return "rejectRSP" if is_reneg else "rejectCM"
|
||||
return None
|
||||
|
||||
|
||||
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
|
||||
# 가격 허용 범위 배수(목표가 기준). 벗어나면 CHAT_PRICE_OUT_OF_RANGE.
|
||||
PRICE_FLOOR_RATIO = 0.3
|
||||
PRICE_CEIL_RATIO = 1.7
|
||||
|
||||
|
||||
def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool:
|
||||
"""직전 봇이 요구한 입력 모드(meta.input_mode)와 이번 유저 입력의 '타입'이 정합한지 검사.
|
||||
불일치(예: price 단계인데 버튼/텍스트, yes_no 단계인데 가격/퍼센트 숫자)면 False
|
||||
→ agent 로 넘기지 않고 CHAT_INPUT_MODE_MISMATCH(제자리걸음/오진행 방지).
|
||||
직전 봇 메시지/모드가 없으면(제약 없음) True.
|
||||
|
||||
주의: 버튼 선택형(confirm/yes_no/delivery_type)에서 '선택지 텍스트 일치'까지는 강제하지 않는다.
|
||||
- 프론트 버튼은 항상 올바른 라벨을 보내고, 거부 폼 등은 자유 텍스트(사유)를 보내기 때문.
|
||||
- 타입(가격/퍼센트 숫자) 오입력만 막아도 실제 stuck/오진행 케이스는 차단된다.
|
||||
"""
|
||||
if not last_meta:
|
||||
return True
|
||||
mode = last_meta.get("input_mode")
|
||||
if not mode:
|
||||
return True
|
||||
if mode == "price":
|
||||
return user_input_type == "price"
|
||||
if mode == "percent":
|
||||
return user_input_type == "percent"
|
||||
if mode in ("confirm", "yes_no", "delivery_type"):
|
||||
# 버튼 선택형 단계에 가격/퍼센트 '숫자 입력'이 오면 오입력 → 차단. 그 외 텍스트는 허용.
|
||||
return user_input_type not in ("price", "percent")
|
||||
return True
|
||||
|
||||
|
||||
class ChatService:
|
||||
def __init__(
|
||||
self,
|
||||
@ -89,6 +50,97 @@ class ChatService:
|
||||
self.chat_crud = chat_crud
|
||||
self.agent = agent
|
||||
|
||||
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
|
||||
@staticmethod
|
||||
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
@staticmethod
|
||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||
if not target_price:
|
||||
return price > 0
|
||||
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)
|
||||
|
||||
@staticmethod
|
||||
def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool:
|
||||
# 직전 봇 input_mode 와 이번 입력 타입 정합 검사(숫자 오입력만 차단).
|
||||
if not last_meta:
|
||||
return True
|
||||
mode = last_meta.get("input_mode")
|
||||
if not mode:
|
||||
return True
|
||||
if mode == "price":
|
||||
return user_input_type == "price"
|
||||
if mode == "percent":
|
||||
return user_input_type == "percent"
|
||||
if mode in ("confirm", "yes_no", "delivery_type"):
|
||||
return user_input_type not in ("price", "percent")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]:
|
||||
# 종료 step → 폼 종류. qt_type 1=재협상(RSP), 그 외=재견적(CM).
|
||||
if not step:
|
||||
return None
|
||||
is_reneg = qt_type == 1
|
||||
if step in _SUMMARY_STEPS:
|
||||
return "summaryRSP" if is_reneg else "summaryCM"
|
||||
if step in _REJECT_STEPS:
|
||||
return "rejectRSP" if is_reneg else "rejectCM"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_user_chat(sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.USER.value,
|
||||
target_price=int(price) if price is not None else 0,
|
||||
meta={"script": user_input, "user_input_type": user_input_type},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.BOT.value,
|
||||
target_price=int(sess.target_price or 0),
|
||||
indicator_value=turn.indicator_value,
|
||||
meta={
|
||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||
"chat_end": turn.chat_end, "card_id": turn.card_id,
|
||||
"bot_chat_type": bot_chat_type, "summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _chat_to_message(c: chats) -> ChatMessage:
|
||||
# chats 행 → 응답 ChatMessage (DB 재조회 없이).
|
||||
meta = c.meta or {}
|
||||
summary_d = meta.get("summary")
|
||||
return ChatMessage(
|
||||
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
|
||||
script=meta.get("script") or "",
|
||||
user_input_type=meta.get("user_input_type"),
|
||||
step=meta.get("step") or "",
|
||||
display_step=meta.get("client_step") or "",
|
||||
next_input_mode=meta.get("input_mode"),
|
||||
next_input_type=meta.get("input_options"),
|
||||
chat_end=bool(meta.get("chat_end", False)),
|
||||
indicator_value=float(c.indicator_value) if c.indicator_value is not None else None,
|
||||
bot_chat_type=meta.get("bot_chat_type"),
|
||||
summary=ChatSummary(**summary_d) if summary_d else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(r) -> ChatMessage:
|
||||
# DB 행(chats) → 응답 ChatMessage.
|
||||
return ChatService._chat_to_message(r)
|
||||
|
||||
# ---- 공통 전처리 ----------------------------------------------------
|
||||
async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
|
||||
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
|
||||
@ -235,9 +287,9 @@ class ChatService:
|
||||
return res
|
||||
|
||||
# 가격 입력이면 범위 검증
|
||||
price = _parse_price(user_input) if user_input_type == "price" else None
|
||||
price = self._parse_price(user_input) if user_input_type == "price" else None
|
||||
if user_input_type == "price":
|
||||
if price is None or not _in_price_range(price, sess.target_price):
|
||||
if price is None or not self._in_price_range(price, sess.target_price):
|
||||
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
|
||||
return res
|
||||
|
||||
@ -254,7 +306,7 @@ class ChatService:
|
||||
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
|
||||
return res
|
||||
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지).
|
||||
if not _input_matches_mode(last_meta, user_input, user_input_type):
|
||||
if not self._input_matches_mode(last_meta, user_input, user_input_type):
|
||||
LOG.i(f"[chat] 입력-모드 불일치 session_id={sess.session_id} "
|
||||
f"mode={last_meta.get('input_mode') if last_meta else None} input_type={user_input_type} input={user_input!r}")
|
||||
res.result.SetResult(ErrorType.CHAT_INPUT_MODE_MISMATCH)
|
||||
@ -301,7 +353,7 @@ class ChatService:
|
||||
|
||||
# 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도.
|
||||
# → agent 가 표현 계약을 책임지면 backend 의 step-이름 결합(_resolve_bot_chat_type)은 폴백으로만 남는다.
|
||||
bot_chat_type = turn.bot_chat_type or _resolve_bot_chat_type(sess.qt_type, turn.step)
|
||||
bot_chat_type = turn.bot_chat_type or self._resolve_bot_chat_type(sess.qt_type, turn.step)
|
||||
# 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 1회만 조회한다.
|
||||
need_last_price = bot_chat_type in ("summaryRSP", "summaryCM") or (turn.chat_end and turn.outcome == "success")
|
||||
last_price = await self._last_user_price(sess) if need_last_price else None
|
||||
@ -413,48 +465,6 @@ class ChatService:
|
||||
return 1
|
||||
return int(rows[0])
|
||||
|
||||
def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.USER.value,
|
||||
target_price=int(price) if price is not None else 0,
|
||||
meta={"script": user_input, "user_input_type": user_input_type},
|
||||
)
|
||||
|
||||
def _build_bot_chat(self, sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다.
|
||||
# indicator_value 는 전용 컬럼(분석/replay용)에도 적재. meta 는 순수 표시용.
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.BOT.value,
|
||||
target_price=int(sess.target_price or 0),
|
||||
indicator_value=turn.indicator_value,
|
||||
meta={
|
||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||
"chat_end": turn.chat_end, "card_id": turn.card_id,
|
||||
"bot_chat_type": bot_chat_type, "summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
def _chat_to_message(self, c: chats) -> ChatMessage:
|
||||
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
|
||||
meta = c.meta or {}
|
||||
summary_d = meta.get("summary")
|
||||
return ChatMessage(
|
||||
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
|
||||
script=meta.get("script") or "",
|
||||
user_input_type=meta.get("user_input_type"),
|
||||
step=meta.get("step") or "",
|
||||
display_step=meta.get("client_step") or "",
|
||||
next_input_mode=meta.get("input_mode"),
|
||||
next_input_type=meta.get("input_options"),
|
||||
chat_end=bool(meta.get("chat_end", False)),
|
||||
indicator_value=float(c.indicator_value) if c.indicator_value is not None else None,
|
||||
bot_chat_type=meta.get("bot_chat_type"),
|
||||
summary=ChatSummary(**summary_d) if summary_d else None,
|
||||
)
|
||||
|
||||
async def _last_user_price(self, sess) -> Optional[int]:
|
||||
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
|
||||
def _q(s):
|
||||
@ -554,21 +564,3 @@ class ChatService:
|
||||
supplier_manager_phone=sup_mgr_phone or "",
|
||||
delivery_type=delivery_label,
|
||||
).model_dump()
|
||||
|
||||
def _row_to_message(self, r) -> ChatMessage:
|
||||
"""DB 행(chats) → 응답 ChatMessage."""
|
||||
return self._chat_to_message(r)
|
||||
|
||||
|
||||
# ---- 가격 유틸 ----------------------------------------------------------
|
||||
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||
if not target_price:
|
||||
return price > 0
|
||||
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)
|
||||
|
||||
@ -14,6 +14,7 @@ import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from services.agent_client import AgentTurn, IAgentClient, get_agent_client
|
||||
from services.chat_service import ChatService
|
||||
|
||||
TEST_LOGIN_ID = "pytest_chat_user"
|
||||
TEST_PW = "pytest1234"
|
||||
@ -358,3 +359,42 @@ async def test_init_marks_expired_created_as_not_participated(client, chat_seed,
|
||||
assert body["result"]["success"] is True
|
||||
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
|
||||
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨
|
||||
|
||||
|
||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||
def test_parse_price():
|
||||
assert ChatService._parse_price("530,000원") == 530000 # 콤마/통화기호 제거
|
||||
assert ChatService._parse_price("abc") is None
|
||||
assert ChatService._parse_price("") is None
|
||||
assert ChatService._parse_price(None) is None
|
||||
|
||||
|
||||
def test_in_price_range():
|
||||
assert ChatService._in_price_range(100000, 100000) is True
|
||||
assert ChatService._in_price_range(29000, 100000) is False # floor(0.3) 미만
|
||||
assert ChatService._in_price_range(180000, 100000) is False # ceil(1.7) 초과
|
||||
assert ChatService._in_price_range(50000, None) is True # 목표가 없으면 양수면 통과
|
||||
assert ChatService._in_price_range(0, None) is False
|
||||
|
||||
|
||||
def test_input_matches_mode():
|
||||
f = ChatService._input_matches_mode
|
||||
assert f(None, "x", "text") is True # 직전 메타 없음 → 제약 없음
|
||||
assert f({}, "x", "price") is True # input_mode 없음
|
||||
assert f({"input_mode": "price"}, "100", "price") is True
|
||||
assert f({"input_mode": "price"}, "예", "text") is False # price 단계에 텍스트
|
||||
assert f({"input_mode": "percent"}, "5", "percent") is True
|
||||
assert f({"input_mode": "yes_no"}, "예", "text") is True
|
||||
assert f({"input_mode": "yes_no"}, "100", "price") is False # 버튼 단계에 가격 숫자
|
||||
assert f({"input_mode": "delivery_type"}, "픽업", "text") is True
|
||||
|
||||
|
||||
def test_resolve_bot_chat_type():
|
||||
f = ChatService._resolve_bot_chat_type
|
||||
assert f(1, "협상완료") == "summaryRSP" # 재협상
|
||||
assert f(2, "협상완료") == "summaryCM" # 재견적
|
||||
assert f(1, "협상실패") == "rejectRSP"
|
||||
assert f(2, "협상실패") == "rejectCM"
|
||||
assert f(2, "결과제출") == "summaryCM"
|
||||
assert f(1, "가격협상") is None # 일반 step
|
||||
assert f(1, None) is None
|
||||
|
||||
@ -97,6 +97,22 @@ async def _participate(client, token, session_id):
|
||||
return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
|
||||
async def _reject(client, token, session_id, reason):
|
||||
return await client.post(
|
||||
f"/v1/negotiation/sessions/{session_id}/reject",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"reject_reason": reason},
|
||||
)
|
||||
|
||||
|
||||
async def _session_reject(db_engine, session_id):
|
||||
async with db_engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"),
|
||||
{"sid": session_id},
|
||||
)).first()
|
||||
|
||||
|
||||
async def _session_status(db_engine, session_id):
|
||||
async with db_engine.begin() as conn:
|
||||
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
|
||||
@ -209,3 +225,44 @@ async def test_participate_session_not_found(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _participate(client, token, str(uuid.uuid4()))
|
||||
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
||||
|
||||
|
||||
# ---- 거부 -------------------------------------------------------------------
|
||||
async def test_reject_success(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid = nego_seed["sids"]["B"] # 협상중 → 거부 가능
|
||||
r = await _reject(client, token, sid, "단종 상품입니다")
|
||||
assert r.json()["result"]["success"] is True
|
||||
assert r.json()["session_id"] == str(sid)
|
||||
status, reason = await _session_reject(db_engine, sid)
|
||||
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
||||
|
||||
|
||||
async def test_reject_empty_reason(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
||||
assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA
|
||||
|
||||
|
||||
async def test_reject_forbidden_other_supplier(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["X"], "사유") # 타 공급사 세션
|
||||
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||
|
||||
|
||||
async def test_reject_not_participable_when_done(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["C"], "사유") # 협상완료(3) → 거부 불가
|
||||
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
||||
|
||||
|
||||
async def test_reject_session_not_found(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, str(uuid.uuid4()), "사유")
|
||||
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
||||
|
||||
|
||||
async def test_reject_requires_auth(client, nego_seed):
|
||||
sid = nego_seed["sids"]["B"]
|
||||
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user