From 1a933409a38e5f0e0495aa995c68c696ecfa5706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 25 Jun 2026 11:07:54 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20backend=20services/router=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=E2=80=94=20chat=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=C2=B7staticmethod=20=EC=A7=91=EC=95=BD=C2=B7protocol=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/common/database/model/models.py | 16 +- backend/common/enums.py | 24 +-- backend/router/router.py | 4 +- backend/router/v1/auth/protocol.py | 48 +++-- .../router/v1/{negotiation => chat}/chat.py | 5 +- .../chat_protocol.py => chat/protocol.py} | 26 +-- backend/router/v1/negotiation/protocol.py | 14 +- backend/services/chat_service.py | 204 +++++++++--------- backend/tests/test_chat.py | 40 ++++ backend/tests/test_negotiation.py | 57 +++++ 10 files changed, 261 insertions(+), 177 deletions(-) rename backend/router/v1/{negotiation => chat}/chat.py (87%) rename backend/router/v1/{negotiation/chat_protocol.py => chat/protocol.py} (67%) diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 7b7aecb..df2fbf8 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -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")) # 소프트 삭제 여부 diff --git a/backend/common/enums.py b/backend/common/enums.py index 7811a10..b600a04 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -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 # 지정택배배송 diff --git a/backend/router/router.py b/backend/router/router.py index fd1e71c..57bd7e1 100644 --- a/backend/router/router.py +++ b/backend/router/router.py @@ -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.. 를 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) diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index bd21a32..687efe2 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -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): diff --git a/backend/router/v1/negotiation/chat.py b/backend/router/v1/chat/chat.py similarity index 87% rename from backend/router/v1/negotiation/chat.py rename to backend/router/v1/chat/chat.py index bf632d3..d0cad93 100644 --- a/backend/router/v1/negotiation/chat.py +++ b/backend/router/v1/chat/chat.py @@ -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( diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/chat/protocol.py similarity index 67% rename from backend/router/v1/negotiation/chat_protocol.py rename to backend/router/v1/chat/protocol.py index 5fd30cb..d944fa2 100644 --- a/backend/router/v1/negotiation/chat_protocol.py +++ b/backend/router/v1/chat/protocol.py @@ -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 회피) diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 1101e0e..0134cfe 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -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") diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 4a947c9..3753bb9 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -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) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 3cc5011..853d4fe 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -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 diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index 5ea6ba2..8c68603 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -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)