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:
parent
4762da080d
commit
d199c1dec1
@ -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
|
||||
|
||||
@ -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 = "" # 거부 처리된 세션
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user