o2o-negosium-original/negodata/backend/router/v1/quotation/quotation.py
Mina Choi 5f648965e2 [fix] negodata: 견적 회사 스코프 적용 (목록·단건·세션 IDOR 차단) + 거짓 주석 제거
quotations 에 company_id 컬럼이 없어 견적 조회가 회사 무관하게 전부 노출되던 멀티테넌트 누수를 차단. 작성자(user_id)→users.company_id 서브쿼리로 회사 스코프를 걸어 목록(/list)과 단건·세션·협력사 엔드포인트(_fetch 가드)에서 남의 회사 견적을 NOT_FOUND 처리. 스케줄러/내부 호출은 company_id 없이 우회. 라우터/서비스의 '회사 스코핑 안 함' 거짓 주석 정정.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:01:27 +09:00

123 lines
6.9 KiB
Python

from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.quotation_service import QuotationService
from .protocol import (
Req_CreateQuotation,
Req_RegenerateQuotation,
Res_CreateQuotation,
Res_DeleteQuotation,
Res_LastSupplierType,
Res_NotifySessions,
Res_Quotation,
Res_QuotationCards,
Res_QuotationList,
Res_QuotationResult,
Res_QuotationSessions,
Res_QuotationStatus,
Res_SessionChat,
Res_TargetBreakdown,
)
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프(작성자 회사 기준).
# 라우팅 주의: 정적/하위 경로(/list, /create, /{qt_id}/status ...)를 /{qt_id} 보다 먼저 선언해야
# /{qt_id} 가 /list 등을 가로채지 않는다.
router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {"description": "Not found"}})
# ----- 실제 엔드포인트 (DB) -----
@router.get(path="/list", response_model=Res_QuotationList, summary="견적 목록")
async def list_quotations(
service: QuotationService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
search: str | None = Query(None, description="견적명/견적번호 검색"),
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
mine: bool = Query(False, description="내 견적만 보기(작성자=로그인 유저)"),
pg: PageParams = Depends(),
):
owner = user_info.user_id if mine else None
return RemoveNoneResponse(await service.list_quotations(user_info.company_id, owner, search, status, type, start_from, start_to, pg))
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
async def create_quotation(
req: Req_CreateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.create_quotation(user_info.user_id, req))
@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감")
async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id))
@router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)")
async def regenerate_quotation(
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids))
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
@router.get(path="/{qt_id}/status", response_model=Res_QuotationStatus, summary="견적 상태 조회")
async def get_quotation_status(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_status(str(qt_id), user_info.company_id))
@router.get(path="/{qt_id}/sessions", response_model=Res_QuotationSessions, summary="참여현황")
async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_sessions(str(qt_id), user_info.company_id))
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id))
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_chats(str(session_id), user_info.company_id))
@router.get(path="/session/{session_id}/target-breakdown", response_model=Res_TargetBreakdown, summary="세션 목표가 산정내역")
async def get_target_breakdown(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id), user_info.company_id))
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id))
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_result(str(qt_id), user_info.company_id))
@router.get(path="/{qt_id}/cards", response_model=Res_QuotationCards, summary="견적 사용 카드")
async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_cards(str(qt_id), user_info.company_id))
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id))
@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")
async def get_supplier_last_type(supplier_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_last_supplier_type(str(supplier_id), user_info.company_id))
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
@router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회")
async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_quotation(str(qt_id), user_info.company_id))