협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.
agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
(Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다
negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)
negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림
테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
140 lines
7.9 KiB
Python
140 lines
7.9 KiB
Python
from datetime import datetime
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
from common.enums import UserRole
|
|
from common.models.gmodel import PageParams, UserInfo
|
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
|
from services.quotation import QuotationService
|
|
from .protocol import (
|
|
Req_AwardQuotation,
|
|
Req_CreateQuotation,
|
|
Req_RegenerateQuotation,
|
|
Res_CreateQuotation,
|
|
Res_DeleteQuotation,
|
|
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(),
|
|
):
|
|
# 일반(USER)은 mine 파라미터와 무관하게 본인 견적만 — 타 담당자 견적 조회는 OWNER 이상만.
|
|
force_mine = (user_info.role or 0) < UserRole.OWNER.value
|
|
owner = user_info.user_id if (mine or force_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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@router.post(path="/award/{qt_id}", response_model=Res_Quotation, summary="개찰 견적 직접 낙찰")
|
|
async def award_quotation(
|
|
qt_id: UUID, req: Req_AwardQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
|
):
|
|
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
|
|
return RemoveNoneResponse(
|
|
await service.award_quotation(
|
|
str(qt_id), user_info.company_id, user_info.user_id, user_info.role,
|
|
req.winner_supplier_id, req.contract_price, req.contract_note,
|
|
)
|
|
)
|
|
|
|
|
|
@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, user_info.user_id, user_info.role,
|
|
card_ids=req.card_ids, target_price=req.target_price,
|
|
end_time=req.end_time, done_ceiling_rate=req.done_ceiling_rate,
|
|
)
|
|
)
|
|
|
|
|
|
# ----- 견적 상세 (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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
@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, user_info.user_id, user_info.role))
|
|
|
|
|
|
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
|
|
@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, user_info.user_id, user_info.role))
|