o2o-negosium-original/backend/services/chat_service.py
민헌 1d4e5816b2 [fix] 재협상 가격협상 흐름 정비 — 무한루프·카드 연출·요약카드
agent 가격협상 무한 루프 수정 + 선행 chat_server 의 가격협상 연출(카드
스크립트 + 협상지표 게이지) 복원, 버짓 확인 입력가 표시 버그, 요약카드
공급사 담당자/날짜 표시 정비.

- agent: 가격협상 루프를 라운드 상한(MAX_ROUNDS)으로 종료(카드선택 실패해도
  종료 보장 — 기존엔 카드소진에만 의존해 무한 입력). chat_server iteration>=3 동작 복원.
- agent: 가격협상 턴에 선택 카드 스크립트(scripts_cards.json)를 script 로 렌더 +
  협상지표(indicator.py, 1~99/PZ) 산출해 indicator_value/bot_chat_type 전달.
  Res_Chat 에 indicator/bot_chat_type, Req_Chat 에 item_price 추가.
- agent: 가격협상_확인 멘트에 할인율(discount_rate) 표시,
  가격협상_확인_버짓이 target 이 아니라 입력가(input_price)를 표시하도록 수정.
- backend: item_price(기존 공급가) 를 agent 로 전달. 요약카드 공급사 담당자
  정보를 supplier_users(빈 이메일) 대신 partner.suppliers.manager_* 에서 조회,
  ChatSummary.supplier_manager_phone 추가.
- frontend: 요약카드 협상 개시/종료 시각을 "YYYY년 MM월 dd일 HH시 MM분 SS초"(KST)
  로, 공급 계약 만료일(+1년) ISO 파싱, 우선협상 대상자에 공급사 연락처/이메일 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:52:34 +09:00

575 lines
31 KiB
Python

"""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 sqlalchemy import func, select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, quotation_settings, quotations, sessions, suppliers
from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
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 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=재견적.
# 협상완료/결과안내 → 요약카드, 협상실패 → 합의불가(거부) 폼.
_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 로 막는다.
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,
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 = await self._agent_context(sess)
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, bot_chat_type=turn.bot_chat_type)
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/meta) — 동시전송 가드 + seq 채번 + 입력-모드 검증
err_type, (max_seq, last_sender, last_meta) = 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
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지).
if not _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)
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 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
# ① client_step: backend 가 보는 직전 봇 step 을 agent 에 전달(desync 감지 힌트).
last_step = (last_meta or {}).get("step") if last_meta else None
# 상품은 이번 턴에서 1회만 로드해 agent 컨텍스트/요약 조립에 재사용한다(중복 조회 제거).
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),
)
item = item if err_type == ErrorType.SUCCESS else None
ctx = await self._agent_context(sess, client_step=last_step, item=item)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
if not turn.ok:
# 선점 유저 메시지 롤백(backend 일관성 유지).
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
# ② 타임아웃은 별도 코드. agent 가 이미 턴을 처리(step 전진)했을 수 있어, 단순 재시도 시 desync 위험.
# 근본 해결(agent 멱등 재시도 / 세션상태 조회 후 정합)은 agent 측 작업 — HANDOFF.md 참고.
if turn.timed_out:
LOG.w(f"[chat] agent 타임아웃 롤백 session_id={sess.session_id} step={last_step} "
f"— agent 가 이미 진행했을 수 있음(desync 위험). HANDOFF #2 참고")
res.result.SetResult(ErrorType.CHAT_AGENT_TIMEOUT)
else:
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
return res
# 폼 종류: 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)
# 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 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
summary = None
if bot_chat_type in ("summaryRSP", "summaryCM"):
final_price = price if price is not None else (sess.bid_price or sess.target_price)
summary = await self._build_summary(sess, quote, item, final_price, last_price)
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
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
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정.
bid = price if price is not None else (last_price if last_price else sess.target_price)
# ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고.
if price is None and not last_price:
LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} "
f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='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
# ---- 빌더 / 매퍼 ----------------------------------------------------
async def _agent_context(self, sess, client_step: Optional[str] = None, item=None) -> AgentChatContext:
# 핸드오프 #2: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
# 상품(partner.items)의 소유 회사가 갑(buyer)이므로 item.company_id 로 해석한다.
# item 은 호출부(send)에서 1회 로드해 넘겨주면 재사용한다(오프닝 seed 는 미전달 → 여기서 로드).
if item is None:
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),
)
item = item if err_type == ErrorType.SUCCESS else None
tenant_id = "" # 해석 실패 시 빈 값(agent 가 400) — 로깅으로 추적
if item is not None and item.company_id:
tenant_id = str(item.company_id)
else:
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
target_price = int(sess.target_price or 0)
# 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식.
# anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지).
anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess)
# revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어
# AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요).
# 기존 공급가(품목 기준가) — agent 가격협상_확인 인하율(discount_rate) 산출 입력.
item_price = int(item.price) if item is not None and item.price else 0
return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type,
target_price=target_price, anchor_price=anchor, partner_count=partner_count,
item_price=item_price, client_step=client_step,
)
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
"""견적설정 anchoring_value(비율) → anchor=round(target*(1-value)). 실패 시 target*0.99 폴백."""
if not target_price:
return 0
fallback = int(round(target_price * 0.99))
def _q(s):
stmt = (
select(quotation_settings.anchoring_value)
.join(quotations, quotations.qt_setting_id == quotation_settings.qt_setting_id)
.where(quotations.qt_id == sess.quotation_id, quotation_settings.deleted == False) # noqa: E712
.limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(quotation_settings.DBType(), DBWRType.DB_READ.value, _q)
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
LOG.w(f"[chat] anchoring_value 조회 실패 session_id={sess.session_id} — anchor=target*0.99 폴백")
return fallback
return int(round(target_price * (1.0 - float(rows[0]))))
async def _count_partners(self, sess) -> int:
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
def _q(s):
stmt = (
select(func.count())
.select_from(sessions)
.where(sessions.quotation_id == sess.quotation_id, sessions.deleted == False) # noqa: E712
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(sessions.DBType(), DBWRType.DB_READ.value, _q)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
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):
stmt = (
select(chats.target_price)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.USER.value,
chats.target_price > 0, chats.deleted == False) # noqa: E712
.order_by(chats.seq.desc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _q)
return int(rows[0]) if err_type == ErrorType.SUCCESS and rows and rows[0] else None
async def _delivery_choice(self, sess) -> Optional[str]:
"""재견적 '배송형태선택' 봇 단계(meta.input_mode='delivery_type') 직후 유저가 고른 배송형태 라벨.
없으면 None. (봇 프롬프트 seq 이후 첫 비삭제 유저 메시지의 script)"""
def _bot_seq(s):
stmt = (
select(chats.seq)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.BOT.value,
chats.meta["input_mode"].astext == "delivery_type", chats.deleted == False) # noqa: E712
.order_by(chats.seq.desc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _bot_seq)
if err_type != ErrorType.SUCCESS or not rows:
return None
bot_seq = rows[0]
def _user_after(s):
stmt = (
select(chats.meta["script"].astext)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.USER.value,
chats.seq > bot_seq, chats.deleted == False) # noqa: E712
.order_by(chats.seq.asc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _user_after)
return rows[0] if err_type == ErrorType.SUCCESS and rows and rows[0] else None
async def _build_summary(self, sess, quote, item, final_price: Optional[int], last_price: Optional[int]) -> dict:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
종료 스텝에서 1회만 호출. item/last_price 는 호출부(send)에서 1회 조회해 넘겨준다(중복 조회 제거)."""
# 우선협상 대상자(공급사) + 담당자 정보. 담당자명/이메일/연락처는 partner.suppliers 에 영속된 값을 쓴다
# (supplier_users 는 로그인 계정이라 이메일이 비어 있을 수 있어, 요약 카드엔 공급사 담당자 정보를 사용).
def _supplier(s):
stmt = (
select(suppliers.name, suppliers.manager_name, suppliers.manager_email, suppliers.manager_contact_number)
.where(suppliers.supplier_id == sess.supplier_id).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(suppliers.DBType(), DBWRType.DB_READ.value, _supplier)
if err_type == ErrorType.SUCCESS and rows:
supplier_name, sup_mgr_name, sup_mgr_email, sup_mgr_phone = rows[0]
else:
supplier_name = sup_mgr_name = sup_mgr_email = sup_mgr_phone = ""
# 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백). last_price 는 호출부에서 전달.
resolved_price = int(last_price if last_price else (final_price or 0))
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
def _iso(dt):
if dt is None:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds")
return ChatSummary(
md_name=quote.manager_name or "",
md_email=quote.manager_email or "",
md_phone_number=quote.manager_contact_number or "",
item_code=(item.code or "") if item else "",
item_name=(item.name or "") if item else "",
item_spec=(item.spec or "") if item else "",
item_moq=(item.moq or "") if item else "",
item_model=(item.model_name or "") if item else "",
item_maker=(item.manufacturer or "") if item else "",
item_isVAT=bool(item.vat_yn) if item and item.vat_yn is not None else False,
item_lead_time=(str(item.lead_time) if item and item.lead_time is not None else ""),
item_display_date=_iso(quote.start_time),
item_delivery_type=item_delivery_label,
final_price=resolved_price,
nego_start_date=_iso(quote.start_time),
nego_end_date=_iso(quote.end_time),
supplier_name=supplier_name or "",
supplier_manager_name=sup_mgr_name or "",
supplier_manager_email=sup_mgr_email or "",
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)