From c3d96fb7d39d594d6566d0b25d80d346c5874271 Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 19 Jun 2026 08:40:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=201=EC=B0=A8=20=EB=B3=91=ED=95=A9=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/negotiation/chat/service/chat_engine.py | 18 ++- .../router/v1/negotiation/chat_protocol.py | 28 ++++- backend/services/chat_service.py | 119 +++++++++++++++++- frontend/src/apis/chat/chat.type.ts | 4 +- 4 files changed, 160 insertions(+), 9 deletions(-) diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index e4a3cdf..25f2e35 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -16,6 +16,12 @@ MAX_ROUNDS = 3 _PRICE_MODES = ("price",) _CHOICE_MODES = ("yes_no", "confirm", "delivery_type") +# 최종 타결/결렬 스텝. 재협상=협상완료(우선협상 타결), 재견적=결과제출(투찰확정). 둘 다 협상실패=결렬. +# 이 스텝들은 chat_end=False(뒤에 협상종료가 옴)라, outcome 을 컨텍스트에 적재했다가 +# 실제 종료(chat_end=협상종료) 시점에 확정 보고한다 → backend 가 chat_end 에서 DONE/REJECTED 를 옳게 가른다. +_SUCCESS_STEPS = ("협상완료", "결과제출") +_FAILURE_STEPS = ("협상실패",) + # 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등 # 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이 # 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.) @@ -177,8 +183,16 @@ class ChatEngine: return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") node = self.scripts[step_key] session.step = step_key - session.ended = bool(node.get("chat_end")) - outcome = "success" if step_key == "협상완료" else "failure" if step_key == "협상실패" else None + chat_end = bool(node.get("chat_end")) + session.ended = chat_end + # 최종 성공/실패를 통과 시점에 기록하고, 실제 종료(chat_end) 시점에만 outcome 으로 확정 보고. + # (중간 성공/실패 스텝에서 보고하면 backend 종료확정 타이밍(chat_end)과 어긋나고, + # ChatService 종료학습도 두 번 도는 문제가 생긴다.) + if step_key in _SUCCESS_STEPS: + session.context["final_outcome"] = "success" + elif step_key in _FAILURE_STEPS: + session.context["final_outcome"] = "failure" + outcome = session.context.get("final_outcome") if chat_end else None return StepView( step=step_key, script=self.repo.format_script(node.get("script", ""), self._vars(session)), diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/negotiation/chat_protocol.py index e143a7c..c6e38bc 100644 --- a/backend/router/v1/negotiation/chat_protocol.py +++ b/backend/router/v1/negotiation/chat_protocol.py @@ -11,6 +11,31 @@ from typing import Optional from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +# 협상 결과 요약 카드(summaryRSP/summaryCM)에 표시할 데이터. 종료 스텝에서만 채워 내려간다. +# 필드명은 프론트 ChatSummary 타입과 1:1 (item_isVAT 등 camelCase 유지). +class ChatSummary(WebPacketProtocol): + md_name: str = "" + md_email: str = "" + md_phone_number: str = "" + item_code: str = "" + item_name: str = "" + item_spec: str = "" + item_moq: str = "" + item_model: str = "" + item_maker: str = "" + item_isVAT: bool = False + item_lead_time: str = "" + item_display_date: str = "" + item_delivery_type: str = "" + final_price: int = 0 + nego_start_date: str = "" + nego_end_date: str = "" + supplier_name: str = "" + supplier_manager_name: str = "" + supplier_manager_email: str = "" + delivery_type: Optional[str] = None + + # 말풍선 한 건. sender 는 ChatSender 정수 코드(1=BOT, 2=USER)로 내려가고 라벨 매핑은 프론트가 한다. class ChatMessage(WebPacketProtocol): chat_id: str = "" @@ -25,7 +50,8 @@ class ChatMessage(WebPacketProtocol): next_input_type: Optional[list[str]] = None # 다음 입력 선택지 chat_end: bool = False indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표 - bot_chat_type: Optional[str] = None # (범위 외 예약) indicator|summary 등 + bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator + summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐 # 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용) diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 605126b..8aaffb4 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -15,16 +15,35 @@ from typing import Optional from fastapi import Depends +from sqlalchemy import select + from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import chats, items, quotations, sessions +from common.database.model.models import chats, items, quotations, sessions, supplier_users, suppliers 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 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 @@ -240,14 +259,23 @@ class ChatService: res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE) return res + # 종료 스텝이면 폼 종류(summary/reject)를 부여하고, 요약카드면 데이터까지 조립한다. + bot_chat_type = _resolve_bot_chat_type(sess.qt_type, turn.step) + 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, final_price) + # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. - bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn) + 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 - bid = price if price is not None else sess.target_price + # 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정. + last_price = await self._last_user_price(sess) + bid = price if price is not None else (last_price if last_price 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 @@ -295,7 +323,8 @@ class ChatService: meta={"script": user_input, "user_input_type": user_input_type}, ) - def _build_bot_chat(self, sess, seq: int, turn) -> chats: + 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)에서도 폼이 재현된다. return chats( chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, sender=ChatSender.BOT.value, @@ -304,12 +333,14 @@ class ChatService: "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 "", @@ -319,8 +350,86 @@ class ChatService: next_input_mode=meta.get("input_mode"), next_input_type=meta.get("input_options"), chat_end=bool(meta.get("chat_end", False)), + 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 _build_summary(self, sess, quote, final_price: Optional[int]) -> dict: + """협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가). + 종료 스텝에서 1회만 호출.""" + 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 + + def _supplier_name(s): + stmt = select(suppliers.name).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_name) + supplier_name = rows[0] if err_type == ErrorType.SUCCESS and rows else "" + + # 공급사 담당자(로그인 계정) 이름/이메일 + def _supplier_user(s): + stmt = ( + select(supplier_users.name, supplier_users.email) + .where(supplier_users.supplier_id == sess.supplier_id, supplier_users.deleted == False) # noqa: E712 + .limit(1) + ) + return DB_SESSION_MNG.execute(s, stmt) + + err_type, su_rows = await DB_SESSION_MNG.execute_lambda(supplier_users.DBType(), DBWRType.DB_READ.value, _supplier_user) + sup_mgr_name, sup_mgr_email = (su_rows[0][0], su_rows[0][1]) if err_type == ErrorType.SUCCESS and su_rows else ("", "") + + # 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백) + last_price = await self._last_user_price(sess) + resolved_price = int(last_price if last_price else (final_price or 0)) + + 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="", + 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 "", + delivery_type=None, + ).model_dump() + def _row_to_message(self, r) -> ChatMessage: """DB 행(chats) → 응답 ChatMessage.""" return self._chat_to_message(r) diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts index dc6227b..3d8fcb7 100644 --- a/frontend/src/apis/chat/chat.type.ts +++ b/frontend/src/apis/chat/chat.type.ts @@ -4,6 +4,7 @@ import type { ApiResult } from '@/apis/types' import type { ChatInitData, ChatMessage, + ChatSummary, NextInputMode, UserInputType, } from '@/features/chat/types' @@ -23,6 +24,7 @@ export interface ChatMessageWire { chat_end?: boolean indicator_value?: number | null bot_chat_type?: string | null + summary?: ChatSummary | null } export interface ChatInitResponse { @@ -75,7 +77,7 @@ export function mapMessage(w: ChatMessageWire): ChatMessage { next_input_type: w.next_input_type ?? null, step: w.step ?? '', display_step: w.display_step ?? '', - summary: null, // (범위 외) 최종 요약 카드는 추후 + summary: w.summary ?? null, // 종료 스텝(협상완료/결과안내)에서 백엔드가 채워 내려줌 indicator_value: w.indicator_value ?? null, } }