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, notifications, sessions from common.enums import ( CloseReason, DBWRType, ErrorType, NotificationType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, RenegotiationStatus, 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.protocol import ( ListItem, Req_ExtraInfo, Req_Renegotiation, Res_ExtraInfo, Res_Participate, Res_Reject, Res_Renegotiation, Res_SessionList, ) from services.auth_service import AuthService class NegotiationService: """협상 도메인 비즈니스 로직. - 인증(계정 활성 + 저장 토큰 대조)은 AuthService.authenticate 로 위임(재사용). - 목록은 로그인 유저의 supplier_id 로만 조회한다. """ # 부가정보 입력 폼을 띄우는 요약 말풍선 종류. 이 말풍선이 나온 뒤면 협상은 타결된 것으로 본다. _SUMMARY_BOT_TYPES = ("summaryRSP", "summaryCM") def __init__( self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD), chat_crud: IChatCRUD = Depends(ChatCRUD), ): self.auth = auth self.session_crud = session_crud self.chat_crud = chat_crud async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int, keyword: str = None, result: int = None) -> Res_SessionList: res = Res_SessionList() # 1) 인증 (활성 + 저장된 access 토큰 대조) err_type, info = await self.auth.authenticate(user_info, access_token) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res supplier_id = uuid.UUID(info.supplier_id) offset = (page - 1) * page_size # 2) 목록 조회 (NEGOTIATION Read 세션, sessions ⨝ items) err_type, rows = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size, keyword, result), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 3) 총개수 (페이지네이션용) err_type, total = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type, keyword, result), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 같은 견적번호(체인)의 최대 차수 — 이미 다음 라운드가 있으면 재협상 요청 대상이 아니다. max_rounds: dict = {} for number in {r[3] for r in rows if r[3]}: _e, mx = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s, n=number: self.session_crud.chain_max_round(s, n), ) max_rounds[number] = mx or 0 res.items = [self._to_list_item(r, max_rounds) for r in rows] res.total = total res.page = page res.page_size = page_size return res async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo: """협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용. _load_actionable_session 은 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 수 있음) 쓰지 않고 직접 검증한다. """ res = Res_ExtraInfo() # 1) 인증 err_type, info = await self.auth.authenticate(user_info, access_token) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res try: session_id = uuid.UUID(session_id_str) except (ValueError, TypeError): res.result.SetResult(ErrorType.NEGO_NOT_FOUND) return res # 2) 세션 조회 + 소유 검증 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: res.result.SetResult(ErrorType.NEGO_NOT_FOUND) return res if str(sess.supplier_id) != info.supplier_id: res.result.SetResult(ErrorType.NEGO_FORBIDDEN) return res # 3) 협상완료(타결) 세션만 부가정보 입력 허용. # 단 '협상완료' 요약 말풍선은 chat_end=false 라 세션이 아직 협상중(2)이다 # (동의 → '협상종료' 턴에서야 완료로 전이). 폼은 요약 시점에 뜨므로 그 구간도 허용한다. if sess.status != SessionStatus.DONE.value: if sess.status != SessionStatus.IN_PROGRESS.value or not await self._is_after_summary(sess.session_id): res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) return res # 4) 저장(supplier_id 가드 crud) err_type = await DB_SESSION_MNG.execute_lambda_run( [sessions.DBType()], [lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.session_id = str(session_id) return res @staticmethod def _to_list_item(r, max_rounds: dict) -> ListItem: """세션 행 → 목록 아이템. 재협상 요청 가능 여부는 서버가 판정해 내려준다(프론트가 규칙을 몰라도 되게).""" custom = r[9] or {} renego = custom.get("renegotiation") or {} status = renego.get("status") or 0 is_last_round = (r[12] or 0) >= max_rounds.get(r[3], 0) renegotiable = ( r[10] == QuotationStatus.CLOSED.value and r[11] in RENEGOTIABLE_CLOSE_REASONS and is_last_round and status not in ( RenegotiationStatus.PENDING.value, RenegotiationStatus.APPROVED.value, RenegotiationStatus.REJECTED.value, ) ) return ListItem( session_id=str(r[0]), session_status=r[1], qt_type=r[2], qt_number=r[3], qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "", item_code=r[5] or "", item_name=r[6] or "", model_name=r[7] or "", maker_name=r[8] or "", custom=custom, renegotiable=renegotiable, renegotiation_status=status, renegotiation_memo=renego.get("memo") or "", result=NegotiationService._to_result(r[10], r[11], r[13], r[14]), has_chat=bool(r[15]), ) @staticmethod def _to_result(qt_status, close_reason, winner_id, my_id) -> int: """공급사 관점 협상 결과(SessionResult). 견적 마감 전이면 0(미정). 낙찰 건은 낙찰자가 나면 1(낙찰)·아니면 2(미낙찰), 개찰(OPEN_*) 마감은 3(결렬=재협상 대상).""" if qt_status != QuotationStatus.CLOSED.value: return 0 if close_reason == CloseReason.AWARDED.value: return 1 if winner_id is not None and str(winner_id) == str(my_id) else 2 if close_reason in RENEGOTIABLE_CLOSE_REASONS: return 3 return 0 async def request_renegotiation( self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_Renegotiation ) -> Res_Renegotiation: """결렬(개찰) 마감 건에 대해 공급사가 재협상을 요청한다(IMK #15). 전용 테이블 없이 sessions.custom.renegotiation 에 기록하고, 견적 작성자에게 알림을 남긴다.""" res = Res_Renegotiation() err_type, info, sess, quote = await self._load_renegotiable(user_info, access_token, session_id_str) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 심사 대기·승인·반려 건은 재요청을 막는다(전용 테이블이 없어 유니크 대신 여기서 검증). # 반려는 담당자가 이미 판단한 결과라 같은 건으로 다시 올릴 수 없다. 철회(CANCELED)만 재요청 허용. current = (sess.custom or {}).get("renegotiation") or {} if current.get("status") in ( RenegotiationStatus.PENDING.value, RenegotiationStatus.APPROVED.value, RenegotiationStatus.REJECTED.value, ): res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) return res payload = { "status": RenegotiationStatus.PENDING.value, "reason": (req.reason or "").strip(), "desired_price": req.desired_price, "requested_at": datetime.now(timezone.utc).isoformat(), } err_type = await DB_SESSION_MNG.execute_lambda_run( [sessions.DBType()], [lambda s: self.session_crud.merge_session_custom( s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": payload} )], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res await self._notify_renegotiation(quote, sess, info, payload) res.session_id = str(sess.session_id) res.status = RenegotiationStatus.PENDING.value return res async def cancel_renegotiation(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Renegotiation: """공급사가 자기 요청을 철회한다. 심사 대기(PENDING) 중에만 가능.""" res = Res_Renegotiation() err_type, info, sess, _quote = await self._load_renegotiable(user_info, access_token, session_id_str) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res current = (sess.custom or {}).get("renegotiation") or {} if current.get("status") != RenegotiationStatus.PENDING.value: res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) return res patch = {**current, "status": RenegotiationStatus.CANCELED.value} err_type = await DB_SESSION_MNG.execute_lambda_run( [sessions.DBType()], [lambda s: self.session_crud.merge_session_custom( s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": patch} )], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.session_id = str(sess.session_id) res.status = RenegotiationStatus.CANCELED.value return res async def _load_renegotiable(self, user_info: UserInfo, access_token: str, session_id_str: str): """재협상 요청 자격 검증 — 인증 → 본인 세션 → 결렬(개찰) 마감 → 마지막 라운드. 성공 시 (SUCCESS, info, sess, quote).""" err_type, info = await self.auth.authenticate(user_info, access_token) if err_type != ErrorType.SUCCESS: return err_type, None, None, None try: session_id = uuid.UUID(session_id_str) except (ValueError, TypeError): return ErrorType.NEGO_NOT_FOUND, None, None, 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, None, None if str(sess.supplier_id) != info.supplier_id: return ErrorType.NEGO_FORBIDDEN, None, None, None err_type, quote = await DB_SESSION_MNG.execute_lambda( sessions.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: return ErrorType.NEGO_NOT_FOUND, None, None, None # 낙찰됐거나 아직 진행 중인 건은 요청 대상이 아니다. if quote.status != QuotationStatus.CLOSED.value or quote.close_reason not in RENEGOTIABLE_CLOSE_REASONS: return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None # 이미 다음 라운드가 만들어졌으면 요청할 이유가 없다. _e, max_round = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s: self.session_crud.chain_max_round(s, quote.number), ) if max_round and quote.round < max_round: return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None return ErrorType.SUCCESS, info, sess, quote async def _notify_renegotiation(self, quote, sess, info, payload: dict) -> None: """견적 작성자 인박스에 재협상 요청 알림을 남긴다. 부가 효과라 실패해도 본 흐름을 막지 않는다.""" notif = notifications( user_id=quote.user_id, type=NotificationType.RENEGO_REQUESTED.value, ref_qt_id=quote.qt_id, ref_session_id=sess.session_id, data={ "supplier_name": info.supplier_name, "qt_number": quote.number, "qt_round": quote.round, "reason": payload.get("reason"), "desired_price": payload.get("desired_price"), }, ) err = await DB_SESSION_MNG.execute_lambda_run( [notifications.DBType()], [lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)], ) if err != ErrorType.SUCCESS: LOG.e_no_callstack(f"[renego] 알림 기록 실패 qt={quote.qt_id} session={sess.session_id}") async def _is_after_summary(self, session_id) -> bool: """마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지.""" err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda( chats.DBType(), DBWRType.DB_READ.value, lambda s: self.chat_crud.get_last(s, session_id), ) if err_type != ErrorType.SUCCESS or not last_meta: return False return last_meta.get("bot_chat_type") in self._SUMMARY_BOT_TYPES async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple): """참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증. 성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다. blocked_statuses 에 해당하는 세션 상태면 NEGO_NOT_PARTICIPABLE 로 막는다. """ # 1) 인증 (활성 + 저장된 access 토큰 대조) err_type, info = await self.auth.authenticate(user_info, access_token) if err_type != ErrorType.SUCCESS: return err_type, None, None try: session_id = uuid.UUID(session_id_str) except (ValueError, TypeError): return ErrorType.NEGO_NOT_FOUND, None, None # 2) 세션 조회 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, None # 3) 소유 검증 (세션 공급사 == 접속 유저 공급사) if str(sess.supplier_id) != info.supplier_id: return ErrorType.NEGO_FORBIDDEN, None, None # 4) 세션 상태 검증 (호출부가 지정한 불가 상태) if sess.status in blocked_statuses: return ErrorType.NEGO_NOT_PARTICIPABLE, None, None # 5) 견적 조회 + 마감 상태 err_type, quote = await DB_SESSION_MNG.execute_lambda( sessions.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: return ErrorType.NEGO_NOT_FOUND, None, None # 협상완료 세션은 결과 열람용 재진입(무변경)이므로 견적마감·마감시간 검증을 건너뛴다. # reject 는 blocked_statuses 로 DONE 을 이미 막으므로 이 분기는 participate 에만 닿는다. if sess.status == SessionStatus.DONE.value: return ErrorType.SUCCESS, sess, quote if quote.status == QuotationStatus.CLOSED.value: return ErrorType.NEGO_QUOTATION_CLOSED, None, None # 6) 마감 시간 초과 (견적 end_time < 현재). 협상생성(1)일 때만 session→미참여로 정리. 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): if 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)], ) return ErrorType.NEGO_DEADLINE_PASSED, None, None return ErrorType.SUCCESS, sess, quote async def participate(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Participate: res = Res_Participate() # 미참여/협상거부 상태는 참여 불가 err_type, sess, _ = await self._load_actionable_session( user_info, access_token, session_id_str, (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 참여 성공 — 협상생성(1)일 때만 상태 전이(협상중/완료는 무변경 진입) if sess.status == SessionStatus.CREATED.value: 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: res.result.SetResult(err_type) return res res.session_id = str(sess.session_id) return res async def reject( self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str, reject_price: Optional[int] = None, opinion: Optional[str] = None, ) -> Res_Reject: res = Res_Reject() # 거부 사유 필수 reason = (reject_reason or "").strip()[:255] if not reason: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res # 협상완료/미참여/협상거부 상태는 거부 불가 (참여와 공통 검증 재사용) err_type, sess, _ = await self._load_actionable_session( user_info, access_token, session_id_str, (SessionStatus.DONE.value, SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장. # 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리. funcs = [ lambda s: self.session_crud.update_session_reject( s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price, ) ] note = (opinion or "").strip()[:255] if note: funcs.append( lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note}) ) err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.session_id = str(sess.session_id) return res