feat(backend): 협상 거부(reject) 기능

- POST /v1/negotiation/sessions/{session_id}/reject (Req_Reject/Res_Reject)
- 참여와 공통 검증(인증/소유/세션상태/견적마감/마감시간)을
  _load_actionable_session 헬퍼로 추출해 participate·reject 재사용
- 거부 불가 상태: 협상완료/미참여/협상거부 → NEGO_NOT_PARTICIPABLE
- 성공 시 세션을 협상거부(5)로 전이하고 reject_reason 저장(최대 255자)
- 빈 사유는 INVALID_REQUEST_DATA

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-18 13:20:29 +09:00
parent 4762da080d
commit d199c1dec1
4 changed files with 108 additions and 26 deletions

View File

@ -37,6 +37,10 @@ class ISessionCRUD(ABC):
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
pass
@abstractmethod
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
pass
class SessionCRUD(ISessionCRUD):
@staticmethod
@ -138,3 +142,15 @@ class SessionCRUD(ISessionCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
try:
query = (
update(sessions)
.where(sessions.session_id == session_id)
.values(status=status, reject_reason=reject_reason)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -23,3 +23,11 @@ class Res_SessionList(Res_WebPacketProtocol):
class Res_Participate(Res_WebPacketProtocol):
session_id: str = "" # 참여 성공한 세션 (채팅 진입용)
class Req_Reject(WebPacketProtocol):
reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력)
class Res_Reject(Res_WebPacketProtocol):
session_id: str = "" # 거부 처리된 세션

View File

@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
from services.negotiation_service import NegotiationService
from .protocol import Res_Participate, Res_SessionList
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
@ -45,3 +45,19 @@ async def participate(
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(await service.participate(user_info, credentials.credentials, session_id))
@router.post(
path="/sessions/{session_id}/reject",
response_model=Res_Reject,
summary="협상 거부",
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
)
async def reject(
session_id: str,
req: Req_Reject,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))

View File

@ -8,7 +8,7 @@ from common.database.model.models import sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import UserInfo
from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_SessionList
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
from services.auth_service import AuthService
@ -73,19 +73,19 @@ class NegotiationService:
res.page_size = page_size
return res
async def participate(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Participate:
res = Res_Participate()
# 1) 인증
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:
res.result.SetResult(err_type)
return res
return err_type, None, None
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
return ErrorType.NEGO_NOT_FOUND, None, None
# 2) 세션 조회
err_type, sess = await DB_SESSION_MNG.execute_lambda(
@ -94,18 +94,15 @@ class NegotiationService:
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
return ErrorType.NEGO_NOT_FOUND, None, None
# 3) 소유 검증 (세션 공급사 == 접속 유저 공급사)
if str(sess.supplier_id) != info.supplier_id:
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
return res
return ErrorType.NEGO_FORBIDDEN, None, None
# 4) 세션 상태 검증 (미참여/협상거부는 참여 불가)
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
# 4) 세션 상태 검증 (호출부가 지정한 불가 상태)
if sess.status in blocked_statuses:
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None
# 5) 견적 조회 + 마감 상태
err_type, quote = await DB_SESSION_MNG.execute_lambda(
@ -114,11 +111,9 @@ class NegotiationService:
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
return ErrorType.NEGO_NOT_FOUND, None, None
if quote.status == QuotationStatus.CLOSED.value:
res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED)
return res
return ErrorType.NEGO_QUOTATION_CLOSED, None, None
# 6) 마감 시간 초과 (견적 end_time < 현재). 협상생성(1)일 때만 session→미참여로 정리.
end = quote.end_time
@ -128,17 +123,32 @@ class NegotiationService:
if sess.status == SessionStatus.CREATED.value:
await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_status(s, session_id, SessionStatus.NOT_PARTICIPATED.value)],
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
)
res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED)
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
# 7) 참여 성공 — 협상생성(1)일 때만 상태 전이(협상중/완료는 무변경 진입)
# 참여 성공 — 협상생성(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, session_id, SessionStatus.IN_PROGRESS.value),
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),
],
)
@ -148,3 +158,35 @@ class NegotiationService:
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