feat(backend): 협상 채팅(chat) API + agent 위임

- /v1/negotiation/sessions/{id}/chat/{init,messages,send} 추가
- agent(9500) 위임 어댑터(IAgentClient) + mock(use_mock) 격리 → agent 미연동 시 1402 graceful degrade
- negotiation.chats 메시지 영속화(meta JSONB) + 종료 시 세션 입찰/거부 확정(단일 트랜잭션)
- 동시전송 가드(유저 메시지 pre-claim/CHAT_IN_PROGRESS) + 실패 시 롤백, 마감/만료 분기, init 만료 정리
- ChatSender enum, chat 에러코드(1400~1403), chats ORM 모델, AgentConfig
- 테스트 10건(test_chat.py), AGENT_INTEGRATION.md 연동 규약 문서

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-18 14:24:57 +09:00
parent d199c1dec1
commit dcce82a630
14 changed files with 1124 additions and 2 deletions

72
AGENT_INTEGRATION.md Normal file
View File

@ -0,0 +1,72 @@
# Chat 연동 규약 (backend ↔ agent)
> 대상: **agent(9500) 담당자**. backend(9300)가 채팅 한 턴을 agent `POST /v1/chat` 으로 위임한다.
> backend/frontend 개발은 이 규약을 가정하고 완료했고, 현재는 `AgentConfig.use_mock=true` 로 내장 mock 을 쓴다.
> agent 가 준비되면 **아래 항목을 맞춘 뒤** backend `config.local.toml` 의 `[AgentConfig] use_mock=false` 로 전환하면 된다.
## 1. 호출 흐름
```
프론트(5173) → backend(9300) /v1/negotiation/sessions/{id}/chat/send → agent(9500) POST /v1/chat
```
- 인증·소유권·견적마감·가격범위 검증, 말풍선 영속화(negotiation.chats), 종료 시 세션 입찰확정은 **backend 책임**.
- 협상 로직(스텝 전이·카드선택·학습)은 **agent 책임**. backend 는 agent 응답을 그대로 말풍선으로 저장/전달한다.
## 2. backend → agent 요청 (`POST /v1/chat`)
```json
{
"session_id": "<negotiation.sessions.session_id>",
"rq_type": "재협상 | 재견적",
"user_input": "<버튼 텍스트 또는 가격문자열, 첫 턴(오프닝)은 null>",
"target_price": 100000,
"anchor_price": 99000
}
```
헤더: `X-Tenant-ID: <견적(갑) 회사 company_id>`
## 3. agent → backend 응답 (`Res_Chat`) ↔ 프론트 ChatMessage 매핑
| agent 필드 | backend/프론트 |
|---|---|
| `session_id` | 세션 키 |
| `step` | `step` |
| `client_step` | `display_step` |
| `script` | `script` (말풍선 텍스트) |
| `input_mode` | `next_input_mode` (confirm·yes_no·percent·price·delivery_type) |
| `input_options` | `next_input_type` (버튼 라벨 배열) |
| `chat_end` | `chat_end` |
| `outcome` | "success"=협상완료(DONE)+입찰가 확정 / 그 외=협상거부(REJECTED) |
| `card_id` | (저장만, 표시 범위 외) |
## 4. agent 쪽에서 맞춰줘야 하는 항목 ⚠️
1. **session_id honoring** — 첫 턴에 backend 가 보낸 `session_id`(우리 `negotiation.sessions.session_id`)를
**새 uuid 발급 없이 그대로 세션 키로 사용**해야 한다.
- 현재 `agent/services/chat_service.py` 는 새 세션 생성 시 `session_id=str(uuid.uuid4())` 로 무시한다 → `req.session_id` 우선 사용하도록 수정 필요.
- 이미 `learning.experience_logs.session_id` 가 `negotiation.sessions` 를 가리키도록 설계돼 있어 agent 입장에서도 올바른 방향.
2. **tenant 헤더** — backend 가 `X-Tenant-ID = company_id` 로 보낸다. agent 의 TenantMiddleware 가 이 키로 엔진 해석.
3. **신규 세션 컨텍스트** — `target_price`/`anchor_price`/`rq_type` 를 backend 가 견적 데이터로 채워 보낸다(기본값 의존 X).
4. **tenant_id 정밀 해석(backend 측 TODO와 짝)** — 현재 backend 는 `X-Tenant-ID` 를 빈 값으로 보낸다.
정확히는 **견적 작성자(갑) 회사 company_id** 여야 하며, `quotation.user_id → company.users.company_id` 조회로 채울 예정.
agent 가 기대하는 tenant 키 형식(company_id uuid 문자열 / `_base`)을 확정해주면 backend 가 맞춘다.
5. **(범위 외) indicator / summary / reject** — 이번 범위 미포함. agent 응답에 협상지표·최종요약·거부폼이 생기면
backend ChatMessage 의 예약 필드(`indicator_value`/`bot_chat_type`/summary)로 확장 협의.
## 5. mock → 실제 전환 체크리스트
- [x] backend `config.local.toml` → `[AgentConfig] use_mock=false` (전환 완료 — agent 미기동 시 1402 로 graceful degrade 확인)
- [x] backend `httpx` 의존 설치(`requirements.txt` 반영됨)
- [ ] 위 4-1 ~ 4-3 반영 (agent 측)
- [ ] tenant_id 해석(4-4) 합의 후 backend `chat_service._agent_context` 의 `tenant_id` 채우기
- [ ] agent(9500) 기동 후 양 서버 라이브 E2E
> 로컬에서 agent 없이 mock 으로 개발하려면 환경변수로 덮는다: `AGENT_USE_MOCK=true`
## 6. 참고 (backend 구현 위치)
- agent 어댑터: `backend/services/agent_client.py` (IAgentClient / Http / Mock)
- 오케스트레이션: `backend/services/chat_service.py`
- 계약(프로토콜): `backend/router/v1/negotiation/chat_protocol.py`
- 엔드포인트: `backend/router/v1/negotiation/chat.py`

View File

@ -1,5 +1,5 @@
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Integer, String, Boolean, DateTime, SmallInteger, BigInteger from sqlalchemy import Column, Integer, String, Boolean, DateTime, SmallInteger, BigInteger, Numeric
from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text from sqlalchemy.sql import text
@ -155,6 +155,30 @@ class quotations(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class chats(MAIN_BASE):
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
@staticmethod
def DBType():
return DBType.NEGOTIATION.value
__tablename__ = "chats"
__table_args__ = {"schema": "negotiation"}
chat_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 채팅 식별자(PK)
session_id = Column(UUID(as_uuid=True), nullable=False) # 소속 세션(negotiation.sessions.session_id)
card_id = Column(UUID(as_uuid=True), nullable=True) # 사용된 카드(card.nego_cards/wild_cards)
seq = Column(Integer, nullable=False, server_default=text("1")) # 세션 내 메시지 순번
sender = Column(SmallInteger, nullable=False) # 발신자 (ChatSender: 1=BOT, 2=USER)
target_price = Column(BigInteger, nullable=False) # 제시 목표가(원)
card_used_yn = Column(Boolean, nullable=True) # 카드 사용 여부
indicator_value = Column(Numeric(8, 6), nullable=True) # 협상 지표값
card_type = Column(SmallInteger, nullable=True) # 카드 유형: 1=nego_card, 2=wild_card
meta = Column(JSONB, nullable=True) # 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end)
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, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class supplier_user_tokens(MAIN_BASE): class supplier_user_tokens(MAIN_BASE):
# 유저 인증 토큰. supplier_users 1 : N tokens. # 유저 인증 토큰. supplier_users 1 : N tokens.
@staticmethod @staticmethod

View File

@ -44,6 +44,12 @@ class ErrorType(Enum):
NEGO_DEADLINE_PASSED = auto() # 1303 견적 마감 시간 초과 NEGO_DEADLINE_PASSED = auto() # 1303 견적 마감 시간 초과
NEGO_NOT_FOUND = auto() # 1304 세션/견적 없음 NEGO_NOT_FOUND = auto() # 1304 세션/견적 없음
# 채팅(chat) 관련 에러
CHAT_NOT_IN_PROGRESS = 1400 # 협상중 상태가 아니라 대화 불가(미참여/완료/거부)
CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남
CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패
CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
@ -125,3 +131,12 @@ class QuotationStatus(Enum):
CREATED = 1 # 견적생성 CREATED = 1 # 견적생성
IN_PROGRESS = 2 # 견적진행중 IN_PROGRESS = 2 # 견적진행중
CLOSED = 3 # 견적마감 CLOSED = 3 # 견적마감
class ChatSender(Enum):
"""채팅 발신자 코드. negotiation.chats.sender.
BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다.
"""
BOT = 1 # 갑(바이어/agent) — bot 메시지
USER = 2 # 공급사(을) — user 입력

View File

@ -43,3 +43,10 @@ class JwtToken(ConfigModel):
refresh_key: str = "" refresh_key: str = ""
access_expire_min: int = 30 access_expire_min: int = 30
refresh_expire_day: int = 7 refresh_expire_day: int = 7
# 협상 에이전트(agent, 포트 9500) 접속 설정. backend 가 /chat 한 턴을 agent 로 위임할 때 사용.
class AgentConfig(ConfigModel):
base_url: str = "http://127.0.0.1:9500" # agent 서비스 베이스 URL
timeout_sec: float = 10.0 # 호출 타임아웃(초)
use_mock: bool = True # True 면 agent 미연동 — 내장 mock 응답 사용(agent 개발 중 통합 테스트용)

View File

@ -1,7 +1,7 @@
import os import os
from config.config_loader import Configs from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, AgentConfig
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. # 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local") APP_ENV = os.environ.get("APP_ENV", "local")
@ -19,6 +19,14 @@ web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig) log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig) main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken) jwt_token_config: JwtToken = configs.get(JwtToken)
agent_config: AgentConfig = configs.get(AgentConfig)
# agent 접속 env override (도커/배포에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
if os.environ.get("AGENT_BASE_URL"):
agent_config.base_url = os.environ["AGENT_BASE_URL"]
if os.environ.get("AGENT_USE_MOCK"):
agent_config.use_mock = os.environ["AGENT_USE_MOCK"].lower() in ("1", "true", "yes")
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. # DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

147
backend/crud/chat_crud.py Normal file
View File

@ -0,0 +1,147 @@
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Optional, Tuple
from sqlalchemy import asc, desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, sessions
from common.enums import ChatSender, ErrorType
from common.logger import LOG
# 협상 채팅 CRUD. 메시지 로그(negotiation.chats)와 종료 시 세션 입찰 확정(negotiation.sessions)을 다룬다.
# chats / sessions 모두 NEGOTIATION 논리 DB 라 한 트랜잭션(execute_lambda_run)으로 묶을 수 있다.
class IChatCRUD(ABC):
@abstractmethod
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
"""마지막 메시지의 (seq, sender). 없으면 (0, None). 동시전송 가드 + seq 채번에 사용."""
pass
@abstractmethod
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
pass
@abstractmethod
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
pass
@abstractmethod
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
pass
@abstractmethod
async def finalize_session(
self, cdb: AsyncSession, session_id, status: int,
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
) -> ErrorType:
pass
class ChatCRUD(IChatCRUD):
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
try:
# (session_id, seq) 유니크 인덱스가 정렬 스캔을 커버한다.
query = (
select(chats)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(asc(chats.seq))
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "list_by_session failed.")
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, rows
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
try:
query = (
select(chats.seq, chats.sender)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(desc(chats.seq))
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_last failed.")
if err_type != ErrorType.SUCCESS:
return err_type, (0, None)
if not rows:
return ErrorType.SUCCESS, (0, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, (0, None)
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, message, raise_error=False)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
# agent 실패 시 선점(pre-claim)한 유저 메시지를 되돌린다. 부분 유니크(WHERE deleted=FALSE)라 seq 가 다시 비워진다.
try:
query = update(chats).where(chats.chat_id == chat_id).values(deleted=True)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
# mock agent 진행(turn) 계산용. 실제 agent 는 자체 세션 상태로 진행하므로 무시한다.
try:
query = select(func.count()).select_from(chats).where(
chats.session_id == session_id,
chats.sender == ChatSender.BOT.value,
chats.deleted == False, # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_bot_messages failed.")
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (rows[0] if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
try:
query = select(items).where(items.item_id == item_id, items.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_item_by_id({item_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def finalize_session(
self, cdb: AsyncSession, session_id, status: int,
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
) -> ErrorType:
try:
values = {"status": status}
if bid_price is not None:
values["bid_price"] = bid_price
values["bid_at"] = datetime.now(timezone.utc)
if reject_reason is not None:
values["reject_reason"] = reject_reason[:255]
if reject_price is not None:
values["reject_price"] = reject_price
query = update(sessions).where(sessions.session_id == session_id).values(**values)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -7,3 +7,4 @@ python-jose[cryptography]
bcrypt bcrypt
orjson orjson
pydantic>=2.0 pydantic>=2.0
httpx # agent(협상 에이전트) 호출용 async HTTP 클라이언트

View File

@ -11,6 +11,7 @@ from common.utils.gtime import GTime
from config.server_configs import web_server_config from config.server_configs import web_server_config
import router.v1.auth.account import router.v1.auth.account
import router.v1.negotiation.session import router.v1.negotiation.session
import router.v1.negotiation.chat
API_SERVER_START_TIME = GTime.UTCStr() API_SERVER_START_TIME = GTime.UTCStr()
@ -57,3 +58,4 @@ async def healthz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include. # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router) app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.negotiation.session.router) app.include_router(router.v1.negotiation.session.router)
app.include_router(router.v1.negotiation.chat.router)

View File

@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends
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
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}})
@router.get(
path="/sessions/{session_id}/chat/init",
response_model=Res_ChatInit,
summary="채팅 진입(상품·견적 메타)",
description="채팅 화면 진입용. 상품/견적 정보 + 현재 세션 상태 + 마감 시각(타이머)을 반환한다. 소유(공급사) 검증.",
)
async def chat_init(
session_id: str,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: ChatService = Depends(),
):
return RemoveNoneResponse(await service.init(user_info, credentials.credentials, session_id))
@router.get(
path="/sessions/{session_id}/chat/messages",
response_model=Res_ChatMessages,
summary="대화 히스토리",
description="세션의 대화 말풍선 목록(seq 오름차순). 비어 있고 협상중이면 오프닝 메시지를 생성해 포함한다.",
)
async def chat_messages(
session_id: str,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: ChatService = Depends(),
):
return RemoveNoneResponse(await service.messages(user_info, credentials.credentials, session_id))
@router.post(
path="/sessions/{session_id}/chat/send",
response_model=Res_ChatSend,
summary="협상 한 턴 전송",
description="유저 입력을 보내고 agent 가 만든 봇 응답 1건을 반환한다(append-only). 종료 시 세션 입찰을 확정한다.",
)
async def chat_send(
session_id: str,
req: Req_ChatSend,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: ChatService = Depends(),
):
return RemoveNoneResponse(
await service.send(user_info, credentials.credentials, session_id, req.user_input_type, req.user_input)
)

View File

@ -0,0 +1,66 @@
"""채팅(chat) 라우터 프로토콜 — backend ↔ 프론트 계약.
agent Res_Chat → 이 ChatMessage 매핑:
step→step, client_step→display_step, script→script,
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end.
indicator/summary/reject 는 이번 범위 외(예약 필드, 기본 None).
"""
from typing import Optional
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
# 말풍선 한 건. sender 는 ChatSender 정수 코드(1=BOT, 2=USER)로 내려가고 라벨 매핑은 프론트가 한다.
class ChatMessage(WebPacketProtocol):
chat_id: str = ""
session_id: str = ""
seq: int = 0
sender: int = 0 # ChatSender 코드
script: str = ""
user_input_type: Optional[str] = None # 유저 입력 종류: 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 # 다음 입력 선택지
chat_end: bool = False
indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표
bot_chat_type: Optional[str] = None # (범위 외 예약) indicator|summary 등
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
class Res_ChatInit(Res_WebPacketProtocol):
session_id: str = ""
session_status: int = 0 # SessionStatus 코드
quotation_id: str = ""
quotation_end_time: str = "" # ISO 8601 (마감 시각)
quotation_memo: str = ""
item_id: str = ""
item_name: str = ""
item_code: str = ""
item_image: str = ""
item_price: int = 0
item_model_name: str = ""
item_maker_name: str = ""
item_spec: str = ""
item_lead_time: str = ""
item_min_order_quantity: str = ""
item_vat_yn: Optional[bool] = None
item_delivery_fee_yn: Optional[bool] = None
# 대화 히스토리(재진입 복원)
class Res_ChatMessages(Res_WebPacketProtocol):
items: list[ChatMessage] = []
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
class Req_ChatSend(WebPacketProtocol):
user_input_type: Optional[str] = None # text|percent|price
user_input: str = ""
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)
class Res_ChatSend(Res_WebPacketProtocol):
message: Optional[ChatMessage] = None
session_status: int = 0

View File

@ -0,0 +1,153 @@
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트.
backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임").
agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해
mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를
agent 완성 여부와 무관하게 통합 테스트할 수 있다.
agent 응답(Res_Chat) → AgentTurn 매핑:
step, client_step, script, input_mode(=next_input_mode), input_options(=next_input_type),
chat_end, outcome, card_id, indicator_value.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
from common.logger import LOG
from config.server_configs import agent_config
@dataclass
class AgentTurn:
"""agent 한 턴 응답(Res_Chat) 의 backend 표현."""
session_id: Optional[str] = None
step: str = ""
client_step: str = ""
script: str = ""
input_mode: Optional[str] = None # 프론트 next_input_mode 로 매핑
input_options: Optional[list[str]] = None # 프론트 next_input_type 로 매핑
chat_end: bool = False
outcome: Optional[str] = None # "success" | "failure" (종료 시)
card_id: Optional[str] = None
indicator_value: Optional[float] = None
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
@dataclass
class AgentChatContext:
"""새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다."""
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음)
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시)
extra: dict = field(default_factory=dict)
class IAgentClient(ABC):
@abstractmethod
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
"""협상 한 턴. session_id 없으면 새 세션 시작. user_input 으로 진행(버튼 텍스트/가격)."""
...
class HttpAgentClient(IAgentClient):
"""실제 agent(9500) 위임 구현. agent POST /v1/chat 호출."""
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
import httpx # 지연 import — mock 모드에서는 httpx 의존을 강제하지 않는다.
body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
"rq_type": ctx.rq_type,
"user_input": user_input,
"target_price": ctx.target_price,
"anchor_price": ctx.anchor_price,
}
headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2
try:
async with httpx.AsyncClient(base_url=agent_config.base_url, timeout=agent_config.timeout_sec) as cli:
resp = await cli.post("/v1/chat", json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
except Exception as ex:
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패: {ex}")
return AgentTurn(ok=False)
return AgentTurn(
session_id=data.get("session_id"),
step=data.get("step") or "",
client_step=data.get("client_step") or "",
script=data.get("script") or "",
input_mode=data.get("input_mode"),
input_options=data.get("input_options"),
chat_end=bool(data.get("chat_end", False)),
outcome=data.get("outcome"),
card_id=data.get("card_id"),
indicator_value=data.get("indicator_value"),
ok=True,
)
class MockAgentClient(IAgentClient):
"""agent 미연동용 결정론적 mock. ctx.turn(직전 봇 턴 수)으로 협상 단계를 진행한다.
플로우(핵심만): 0=인사(확인) → 1=품목안내(확인) → 2=가격협상(가격입력) → 3+=수락/종료.
"""
_SCRIPT = [
("서비스안내", "협상에 참여해 주셔서 감사합니다. 시작하시겠어요?", "confirm", ["네, 시작할게요"]),
("협상품목안내", "협상 품목을 확인해 주세요. 가격 협상을 진행할까요?", "confirm", ["가격 협상 진행"]),
("가격협상", "희망 공급가를 입력해 주세요.", "price", None),
]
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
sid = session_id or "mock-session"
turn = ctx.turn
# 공급사가 협상 포기/거부 의사를 밝히면 실패로 종료(거부)한다.
if user_input and ("포기" in user_input or "거부" in user_input):
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script="협상이 종료되었습니다.", input_mode=None, input_options=None,
chat_end=True, outcome="failure",
)
if turn < len(self._SCRIPT):
step, script, mode, options = self._SCRIPT[turn]
return AgentTurn(
session_id=sid, step=step, client_step=step, script=script,
input_mode=mode, input_options=options, chat_end=False,
)
# 가격 제시 이후: 목표가 이하면 수락 종료, 아니면 한 번 더 제안 요청
price = _parse_price(user_input)
if price is not None and ctx.target_price and price <= ctx.target_price:
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script=f"제안하신 {price:,}원으로 합의되었습니다. 감사합니다.",
input_mode=None, input_options=None, chat_end=True, outcome="success",
card_id="NGC-MOCK", indicator_value=100.0,
)
return AgentTurn(
session_id=sid, step="가격협상", client_step="가격협상",
script="조금 더 조정된 가격을 제안해 주시겠어요?",
input_mode="price", input_options=None, chat_end=False,
card_id="NGC-MOCK", indicator_value=50.0,
)
def _parse_price(text: Optional[str]) -> Optional[int]:
"""'1,500원' / '1500' 등에서 정수 가격을 파싱한다. 실패 시 None."""
if not text:
return None
digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None
def get_agent_client() -> IAgentClient:
"""config 에 따라 mock/실제 클라이언트를 반환한다(FastAPI Depends 용)."""
return MockAgentClient() if agent_config.use_mock else HttpAgentClient()

View File

@ -0,0 +1,334 @@
"""ChatService — 채팅 페이지 오케스트레이션.
backend 가 협상 한 턴을 agent(9500) 로 위임하고, 말풍선 로그(negotiation.chats)를 영속화하며,
종료 시 세션 상태(negotiation.sessions)를 전이한다. agent 는 외부 고정 계약(agent_client 어댑터).
- init : 상품/견적 메타 + 현재 세션 상태 (타이머용 마감 시각 포함)
- messages : 대화 히스토리 복원. 비어 있고 협상중이면 agent 오프닝 한 턴을 seed(지연 생성).
- send : (검증 → 유저 메시지 저장 → agent 위임 → 봇 메시지 저장 → 종료 시 입찰 확정) 단일 트랜잭션.
append-only — 새 봇 메시지 1건만 반환(전체 refetch 회피).
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, quotations, sessions
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
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, Res_ChatInit, Res_ChatMessages, Res_ChatSend
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
from services.auth_service import AuthService
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7
class ChatService:
def __init__(
self,
auth: AuthService = Depends(AuthService),
session_crud: ISessionCRUD = Depends(SessionCRUD),
chat_crud: IChatCRUD = Depends(ChatCRUD),
agent: IAgentClient = Depends(get_agent_client),
):
self.auth = auth
self.session_crud = session_crud
self.chat_crud = chat_crud
self.agent = agent
# ---- 공통 전처리 ----------------------------------------------------
async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
return err_type, None
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
return ErrorType.NEGO_NOT_FOUND, None
err_type, sess = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_session_by_id(s, session_id),
)
if err_type != ErrorType.SUCCESS or sess is None:
return ErrorType.NEGO_NOT_FOUND, None
if str(sess.supplier_id) != info.supplier_id:
return ErrorType.NEGO_FORBIDDEN, None
return ErrorType.SUCCESS, sess
# ---- init -----------------------------------------------------------
async def init(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatInit:
res = Res_ChatInit()
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type, quote = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
if err_type != ErrorType.SUCCESS or item is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
# 마감 시간 초과 + 협상생성이면 미참여로 정리 (participate 와 동일 일관성).
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc) and sess.status == SessionStatus.CREATED.value:
await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
)
sess.status = SessionStatus.NOT_PARTICIPATED.value
res.session_id = str(sess.session_id)
res.session_status = sess.status
res.quotation_id = str(sess.quotation_id)
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
res.quotation_memo = quote.memo or ""
res.item_id = str(item.item_id)
res.item_name = item.name or ""
res.item_code = item.code or ""
res.item_image = item.image_url or ""
res.item_price = item.price or 0
res.item_model_name = item.model_name or ""
res.item_maker_name = item.manufacturer or ""
res.item_spec = item.spec or ""
res.item_lead_time = str(item.lead_time) if item.lead_time is not None else ""
res.item_min_order_quantity = item.moq or ""
res.item_vat_yn = item.vat_yn
res.item_delivery_fee_yn = item.delivery_fee_yn
return res
# ---- messages -------------------------------------------------------
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
res = Res_ChatMessages()
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type, rows = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.list_by_session(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
opening = await self._seed_opening(sess)
if opening is not None:
res.items = [opening]
return res
res.items = [self._row_to_message(r) for r in rows]
return res
async def _seed_opening(self, sess) -> Optional[ChatMessage]:
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어)."""
ctx = self._agent_context(sess, turn=0)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=None, ctx=ctx)
if not turn.ok:
return None
bot = self._build_bot_chat(sess, seq=1, turn=turn)
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)]
)
return self._chat_to_message(bot)
# ---- send (핵심) ----------------------------------------------------
async def send(self, user_info: UserInfo, access_token: str, session_id_str: str, user_input_type: Optional[str], user_input: str) -> Res_ChatSend:
res = Res_ChatSend()
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 협상중이 아니면 대화 불가
if sess.status != SessionStatus.IN_PROGRESS.value:
res.result.SetResult(ErrorType.CHAT_NOT_IN_PROGRESS)
return res
# 견적 마감/시간 검증
err_type, quote = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
if quote.status == QuotationStatus.CLOSED.value:
res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED)
return res
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc):
res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED)
return res
# 가격 입력이면 범위 검증
price = _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):
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
return res
# 직전 메시지(seq/sender) — 동시전송 가드 + seq 채번
err_type, (max_seq, last_sender) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 직전이 유저 메시지면 이전 턴이 아직 처리 중(봇 응답 미도착) → 중복 전송 거절
if last_sender == ChatSender.USER.value:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res
err_type, turn_no = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.count_bot_messages(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
# 경합에서 밀리면(같은 seq 충돌) agent 를 호출하지 않고 CHAT_IN_PROGRESS 로 거절 → 중복 진행 방지.
user_msg = self._build_user_chat(sess, seq=max_seq + 1, user_input=user_input, user_input_type=user_input_type, price=price)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, user_msg)]
)
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
ctx = self._agent_context(sess, turn=turn_no)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
if not turn.ok:
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
return res
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn)
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
new_status = sess.status
if turn.chat_end:
if turn.outcome == "success":
new_status = SessionStatus.DONE.value
bid = price if price is not None else sess.target_price
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
else:
new_status = SessionStatus.REJECTED.value
funcs.append(lambda s: self.chat_crud.finalize_session(
s, sess.session_id, new_status,
reject_reason=(user_input or None), reject_price=price,
))
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
if err_type != ErrorType.SUCCESS:
# 봇 저장 실패 시에도 선점 유저 메시지를 롤백해 stuck(CHAT_IN_PROGRESS) 방지.
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
res.result.SetResult(err_type)
return res
res.message = self._chat_to_message(bot_msg)
res.session_status = new_status
return res
# ---- 빌더 / 매퍼 ----------------------------------------------------
def _agent_context(self, sess, turn: int) -> AgentChatContext:
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
# 현재 quotation.user_id 만 보유 → 정확한 company_id 해석(company.users 조회)은 agent 연동 시 보완.
tenant_id = "" # mock 은 무시. 실제 연동 시 quotation 의 buyer company_id 로 채운다.
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
anchor = int(sess.target_price * 0.99) if sess.target_price else 0
return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type,
target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn,
)
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) -> chats:
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),
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,
},
)
def _chat_to_message(self, c: chats) -> ChatMessage:
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
meta = c.meta or {}
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)),
)
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)

235
backend/tests/test_chat.py Normal file
View File

@ -0,0 +1,235 @@
"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료).
agent 는 config.use_mock=true 로 내장 MockAgentClient 를 쓴다(결정론적 플로우).
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
"""
import uuid
import bcrypt
import pytest_asyncio
from sqlalchemy import text
TEST_LOGIN_ID = "pytest_chat_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
MARK = "PYTESTCHAT-"
@pytest_asyncio.fixture
async def chat_seed(db_engine):
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
supplier_id = uuid.uuid4()
other_supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# (code, session.status, qt_type, 마감까지 h, quotation.status, 소속 공급사)
specs = [
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
]
sids, qids = {}, {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.chats WHERE session_id IN (SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"))
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text(
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '채팅담당자', now(), 1, 1)"
),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], qids[code] = session_id, qt_id
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, model_name, manufacturer, moq, spec) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 100000, :model, '테스트제조사', '10', '규격A')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, memo) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs), '메모')"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now() + make_interval(hours => 2))"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
def _h(token):
return {"Authorization": f"Bearer {token}"}
async def _init(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/init", headers=_h(token))
async def _messages(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/messages", headers=_h(token))
async def _send(client, token, sid, user_input, user_input_type=None):
body = {"user_input": user_input, "user_input_type": user_input_type}
return await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", headers=_h(token), json=body)
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()
async def _session_bid(db_engine, session_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT bid_price FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
async def _session_reject(db_engine, session_id):
async with db_engine.begin() as conn:
r = (await conn.execute(text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).first()
return r[0], r[1]
# ---- init -------------------------------------------------------------------
async def test_chat_init_returns_meta(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert body["session_status"] == 2
assert body["item_name"] == "상품 P" and body["item_price"] == 100000
assert body["item_maker_name"] == "테스트제조사"
assert body["quotation_end_time"] # 타이머용 마감 시각
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
# ---- messages (오프닝 seed) -------------------------------------------------
async def test_messages_seeds_opening(client, chat_seed):
token = await _login_token(client)
body = (await _messages(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert len(body["items"]) == 1
msg = body["items"][0]
assert msg["sender"] == 1 # ChatSender.BOT (봇)
assert msg["next_input_mode"] == "confirm"
assert msg["script"]
# ---- send (협상 진행 → 종료) ------------------------------------------------
async def test_send_flow_to_completion(client, chat_seed, db_engine):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝(턴0) seed
r1 = (await _send(client, token, sid, "네, 시작할게요")).json()
assert r1["result"]["success"] is True
assert r1["message"]["next_input_mode"] == "confirm" # 품목안내
assert r1["session_status"] == 2
r2 = (await _send(client, token, sid, "가격 협상 진행")).json()
assert r2["message"]["next_input_mode"] == "price" # 가격입력 요청
r3 = (await _send(client, token, sid, "90000", user_input_type="price")).json()
assert r3["result"]["success"] is True
assert r3["message"]["chat_end"] is True
assert r3["session_status"] == 3 # 협상완료(DONE)
assert await _session_status(db_engine, sid) == 3
assert await _session_bid(db_engine, sid) == 90000 # 입찰가 확정
async def test_send_price_out_of_range(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid)
# 목표가 100000 → 허용 [30000, 170000]. 10 은 하한 미만.
body = (await _send(client, token, sid, "10", user_input_type="price")).json()
assert body["result"]["code"] == 1401 # CHAT_PRICE_OUT_OF_RANGE
async def test_send_not_in_progress(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["C"] # 협상생성(미참여 전 단계)
body = (await _send(client, token, sid, "네")).json()
assert body["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
async def test_send_requires_auth(client, chat_seed):
sid = chat_seed["sids"]["P"]
r = await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", json={"user_input": "네"})
assert r.status_code in (401, 403)
# ---- 보완: 거부 저장 / 동시전송 가드 / init 만료 정리 ------------------------
async def test_send_rejection_persists_reason(client, chat_seed, db_engine):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝
body = (await _send(client, token, sid, "협상 포기합니다")).json()
assert body["result"]["success"] is True
assert body["message"]["chat_end"] is True
assert body["session_status"] == 5 # 협상거부(REJECTED)
status, reason = await _session_reject(db_engine, sid)
assert status == 5 and reason == "협상 포기합니다" # 거부 사유 저장
async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine):
"""직전 메시지가 USER(이전 턴 처리 중)면 중복 전송을 거절한다 → CHAT_IN_PROGRESS."""
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝(seq=1, BOT)
# 봇 응답이 아직 안 온 상태를 모사: USER 메시지를 마지막(seq=2)으로 직접 삽입
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 2, 2, 0)"),
{"sid": sid},
)
body = (await _send(client, token, sid, "네")).json()
assert body["result"]["code"] == 1403 # CHAT_IN_PROGRESS
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
token = await _login_token(client)
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
body = (await _init(client, token, sid)).json()
assert body["result"]["success"] is True
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨

View File

@ -310,6 +310,7 @@ CREATE TABLE IF NOT EXISTS negotiation.chats (
card_used_yn BOOLEAN NULL, -- 카드 사용 여부 card_used_yn BOOLEAN NULL, -- 카드 사용 여부
indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
card_type SMALLINT NULL, -- 카드 유형: 1=nego_card, 2=wild_card card_type SMALLINT NULL, -- 카드 유형: 1=nego_card, 2=wild_card
meta JSONB NULL, -- 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end). 구조화 컬럼(price/card/indicator) 외 가변 UI 필드만 보관.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부