[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:
민헌 2026-06-25 11:07:54 +09:00
parent b4dc862912
commit 1a933409a3
10 changed files with 261 additions and 177 deletions

View File

@ -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")) # 소프트 삭제 여부

View File

@ -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 # 지정택배배송

View File

@ -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)

View File

@ -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):

View File

@ -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(

View File

@ -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 회피)

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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)