From d199c1dec149158aef727c8bed837249da5dc5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 18 Jun 2026 13:20:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=ED=98=91=EC=83=81=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80(reject)=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/crud/session_crud.py | 16 ++++ backend/router/v1/negotiation/protocol.py | 8 ++ backend/router/v1/negotiation/session.py | 18 ++++- backend/services/negotiation_service.py | 92 +++++++++++++++++------ 4 files changed, 108 insertions(+), 26 deletions(-) diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 8801ae3..c602101 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -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 diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 39b01bc..1101e0e 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -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 = "" # 거부 처리된 세션 diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 228b4a0..324a634 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -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)) diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index 268887b..10a11f6 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -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