"""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, 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.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 # 종료 step → 프론트 폼 종류(bot_chat_type). RSP=재협상, CM=재견적. _SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"} _REJECT_STEPS = {"협상실패"} # 가격 허용 범위 배수(목표가 기준). 벗어나면 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 # ---- 순수 헬퍼/매퍼 (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, card_uuid=None, card_type=None) -> chats: # bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재. # nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재 # → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업) 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, card_id=card_uuid, card_type=card_type if card_uuid else None, # CardType: 1=nego, 2=wild card_used_yn=True if card_uuid else None, 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).""" 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 # 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙). # 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다. if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value): res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) return res await self._ensure_in_progress(sess, quote) 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 res.custom = sess.custom or {} return res async def _ensure_in_progress(self, sess, quote) -> None: """협상생성(1) 세션을 채팅 진입만으로 협상중(2)으로 전이한다(participate 와 동일 전이). negodata 안내 메일/링크는 목록의 참여 버튼을 거치지 않고 chat 으로 바로 들어오는데, 오프닝 메시지는 협상중일 때만 seed 되므로 전이가 없으면 빈 채팅으로 멈춘다. 마감된 견적은 진입해도 대화가 불가하므로 전이하지 않는다.""" if sess.status != SessionStatus.CREATED.value: return if quote is None or quote.status == QuotationStatus.CLOSED.value: return 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): return err_type = await DB_SESSION_MNG.execute_lambda_run( [sessions.DBType()], [ lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value), lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value), ], ) if err_type != ErrorType.SUCCESS: return sess.status = SessionStatus.IN_PROGRESS.value # ---- 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 # 협상생성 상태로 바로 진입한 경우(메일 링크) 여기서도 전이한다 — # init 과 병렬로 호출돼 init 의 전이를 못 본 채 읽었을 수 있다. if not rows and sess.status == SessionStatus.CREATED.value: _, 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), ) await self._ensure_in_progress(sess, quote) # 비어 있고 협상중이면 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 = self._parse_price(user_input) if user_input_type == "price" else None if user_input_type == "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 # 직전 메시지(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 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) 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 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 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) # 합의가는 agent 가 내려준 settled_price 최우선 (와일드카드 1% 인하 수락 등 # 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다). summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price) # 카드 번호(turn.card_id) → UUID 변환. step 으로 nego/wild 갈라 각 테이블 조회(번호가 겹칠 수 있어 종류로 구분). # 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공) card_uuid = None card_type = None if turn.card_id: is_wild = bool(turn.step and turn.step.startswith("wild")) if is_wild: card_uuid = await DB_SESSION_MNG.execute_lambda( chats.DBType(), DBWRType.DB_READ.value, lambda s: self.chat_crud.get_wild_card_id_by_number(s, str(turn.card_id)), ) card_type = 2 else: card_uuid = await DB_SESSION_MNG.execute_lambda( chats.DBType(), DBWRType.DB_READ.value, lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)), ) card_type = 1 # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type) funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)] # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). if price is not None: funcs.append(lambda s: self.chat_crud.update_last_offer_price(s, sess.session_id, price)) new_status = sess.status if turn.chat_end: if turn.outcome == "success": new_status = SessionStatus.DONE.value # 입찰가 = agent 합의가(settled_price, 와일드카드 수락 등) → 이번 턴 가격(보통 None) # → 마지막 제시가 → 목표가 순으로 확정. bid = turn.settled_price or (price if price is not None else (last_price if last_price else sess.target_price)) # ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고. if not turn.settled_price and 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 위험") # 1:1 견적유형(1=renego, 3=new_nego) → 재협상, 1:N(2=requote, 4=new_quote) → 재견적. # agent 도 sessions.qt_type 으로 동일 판별(NegotiationContextLoader) — backend 로컬 용도. rq_type = "재협상" if sess.qt_type in (1, 3) else "재견적" target_price = int(sess.target_price or 0) # 협상 컨텍스트(앵커가/품목가/매출액/유통코드/파트너 유형/수용률)는 더 이상 계산·전송하지 않는다 — # agent 가 session_id 로 DB(negotiation.sessions·partner.items/suppliers·quotations)에서 직접 조회한다. # (앵커가 박제·무할인 폴백 정책 — schedules/anchoring/docs/개발용.md §9.2 — 은 agent loader 가 승계.) return AgentChatContext( tenant_id=tenant_id, rq_type=rq_type, target_price=target_price, client_step=client_step, ) 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()