o2o-negosium-original/backend/services/negotiation_service.py
Mina Choi db07a60cfa [fix] negosium·negodata·landing: 채팅 진입·부가정보 저장 오류 수정, 모바일 여백·스크롤·노치 대응
- 채팅 링크로 바로 진입하면 세션이 협상생성(1)에 머물러 오프닝 메시지가 seed 되지 않던 문제:
  chat init/messages 에서 participate 와 동일하게 협상중(2)으로 전이(마감 견적·마감시간 초과는 제외)
- 부가정보 저장이 1301 로 실패하던 문제: '협상완료' 요약은 chat_end=false 라 세션이 아직 협상중이므로,
  마지막 말풍선이 summaryRSP/CM 이면 협상중 세션도 저장 허용
- 채팅 언마운트 시 messages 캐시도 제거 — staleTime:Infinity 라 빈 목록이 남아 재진입해도 빈 화면이던 문제
- 채팅 스크롤을 컨테이너 기준 scrollTo 로 변경(rAF 후 실행) — 요약·폼이 뒤늦게 레이아웃되면 중간에 멈추던 문제
- 모바일 채팅 상단 여백 제거(pt-[64px] 는 우측 협상절차 카드가 있는 lg 이상만)
- 노치 대응: viewport-fit=cover + safe-t/b/x 유틸 신설, 헤더·하단 액션 덱·드로어·랜딩 헤더에 적용, h-screen→100dvh
2026-07-23 12:50:40 +09:00

270 lines
12 KiB
Python

import uuid
from datetime import datetime, timezone
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, sessions
from common.enums import 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.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, 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) -> 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),
)
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),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.items = [
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=r[9] or {},
)
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
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) -> 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
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.session_id = str(sess.session_id)
return res