- GET /v1/negotiation/sessions: 로그인 공급사의 세션 목록(필터/정렬/페이지네이션)
· qt_end_time 은 견적(quotation.end_time) 기준, sessions⨝items⨝quotations 조인
· status/qt_type 은 정수 코드로 응답(라벨 매핑은 프론트)
- POST /v1/negotiation/sessions/{session_id}/participate: 협상 참여
· 검증: 소유(공급사 대조)→세션상태→견적마감→마감시간, 에러코드 1300~1304
· 협상생성→협상중, 견적→견적진행중 (협상중/완료는 무변경 진입)
· 마감초과 시 협상생성 세션만 미참여로 정리
- DBType.NEGOTIATION/QUOTATION, items/sessions/quotations 모델
- QtType/SessionStatus/QuotationStatus enum, AuthService.authenticate 공통화
- 협상 e2e 테스트(test_negotiation.py)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
6.1 KiB
Python
151 lines
6.1 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 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 services.auth_service import AuthService
|
|
|
|
|
|
class NegotiationService:
|
|
"""협상 도메인 비즈니스 로직.
|
|
- 인증(계정 활성 + 저장 토큰 대조)은 AuthService.authenticate 로 위임(재사용).
|
|
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
|
|
"""
|
|
|
|
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)):
|
|
self.auth = auth
|
|
self.session_crud = session_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 "",
|
|
)
|
|
for r in rows
|
|
]
|
|
res.total = total
|
|
res.page = page
|
|
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) 인증
|
|
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
|
|
|
|
# 3) 소유 검증 (세션 공급사 == 접속 유저 공급사)
|
|
if str(sess.supplier_id) != info.supplier_id:
|
|
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
|
|
return res
|
|
|
|
# 4) 세션 상태 검증 (미참여/협상거부는 참여 불가)
|
|
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
|
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
|
return res
|
|
|
|
# 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:
|
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
|
return res
|
|
if quote.status == QuotationStatus.CLOSED.value:
|
|
res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED)
|
|
return res
|
|
|
|
# 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, session_id, SessionStatus.NOT_PARTICIPATED.value)],
|
|
)
|
|
res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED)
|
|
return res
|
|
|
|
# 7) 참여 성공 — 협상생성(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_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
|