o2o-negosium-original/negodata/backend/router/v1/quotation/protocol.py
Mina Choi 6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00

222 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class QuotationProtocol(WebPacketProtocol):
pass
class Req_CreateQuotation(QuotationProtocol):
qt_setting_id: uuid.UUID
version_id: Optional[uuid.UUID] = None # 미지정 시 기본 전략 버전(card.versions 시드)으로 채움
name: str = ""
type: int = 0
status: int = 0
start_time: Optional[datetime] = None # 미지정 시 생성 시각(UTC)
end_time: datetime
round: int = 1
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
memo: Optional[str] = None
md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
# 낙찰 기준 — 1:1 협상만 프론트가 2전략(앵커까지/목표까지)을 mid/over 로 전개해 전송(over 는 항상 OPEN).
# 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰').
mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리
over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리
done_ceiling_rate: Optional[int] = None # 협상 완료 상한율(‰) 견적 override. None 이면 회사 세팅값
class Req_RegenerateQuotation(QuotationProtocol):
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·번호는 원 견적에서 이어받음
# 아래 재지정값은 전부 미전송(None)이면 원 견적/직전 라운드 값을 그대로 승계한다.
card_ids: Optional[list[uuid.UUID]] = None # 다음 라운드 협상카드. 빈 리스트면 카드 없는 새 버전
target_price: Optional[int] = None # 목표가(원). 이 라운드의 모든 상품에 적용
end_time: Optional[datetime] = None # 마감기한. 미전송이면 원 견적과 같은 기간 길이로 생성 시각부터
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰). 완료 상한=목표가×(1+값/1000)
class Req_AwardQuotation(QuotationProtocol):
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(견적에 초청된 협력사 중 선택)
contract_price: int # 최종 계약가(원). 오프라인 협상 결과를 담당자가 확정해 넣는다
contract_note: Optional[str] = None # 계약가 근거 메모(오프라인 협상 요약)
class QuotationData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
qt_id: uuid.UUID
user_id: uuid.UUID
qt_setting_id: uuid.UUID
version_id: uuid.UUID
name: str
number: str
type: QuotationType
round: int = 1
status: QuotationStatus
start_time: datetime
end_time: datetime
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
memo: Optional[str] = None
md_price: Optional[int] = None
iteration: int = 0
preferred_sp_yn: Optional[bool] = None
preferred_sp_id: Optional[uuid.UUID] = None
preferred_sp_name: Optional[str] = None
equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = None
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
over_action: Optional[int] = None
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인으로 채움.
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Res_Quotation(Res_WebPacketProtocol):
quotation: Optional[QuotationData] = None
class Res_QuotationList(Res_PageProtocol):
quotations: list[QuotationData] = []
class Res_DeleteQuotation(Res_WebPacketProtocol):
pass
class SessionData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
session_id: uuid.UUID
qt_id: uuid.UUID
supplier_id: uuid.UUID
item_id: uuid.UUID
qt_number: str
qt_round: int
qt_type: QuotationType
target_price: int
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000anchoring_value)//1000
done_ceiling_price: Optional[int] = None # 완료 상한가(원). 생성 시 박제 = 목표가×(1+완료상한율/1000)
status: SessionStatus
bid_price: Optional[int] = None
bid_at: Optional[datetime] = None
end_time: datetime
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
class Res_CreateQuotation(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None # 생성된 견적 id(프론트가 상세 오버레이 오픈에 사용)
session_count: int = 0 # 함께 생성된 협상 세션 수(토스트 표시용). 세션 풀바디는 별도 GET 으로 조회
class Res_NotifySessions(Res_WebPacketProtocol):
sent: int = 0 # 발송 성공 세션 수
failed: int = 0 # 발송 시도했으나 실패한 세션 수
skipped: int = 0 # 담당자 이메일이 없어 건너뛴 세션 수
total: int = 0 # 대상 세션 총수
class Res_QuotationStatus(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
job_status: int = 0
message: str = ""
class Res_QuotationSessions(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
sessions: list[SessionData] = []
total: int = 0
class ChatMessageData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
chat_id: uuid.UUID
session_id: uuid.UUID
card_id: Optional[uuid.UUID] = None
index: int
sender: ChatSender
target_price: int
card_used_yn: Optional[bool] = None
indicator_value: Optional[float] = None
card_type: Optional[CardType] = None
script: Optional[str] = None
step: Optional[str] = None
class Res_SessionChat(Res_WebPacketProtocol):
session_id: Optional[uuid.UUID] = None
messages: list[ChatMessageData] = []
class Res_QuotationResult(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
winner_supplier_id: Optional[uuid.UUID] = None
winner_supplier_name: Optional[str] = None
is_equal_bid: Optional[bool] = None
equal_bid_data: Optional[Any] = None
result_count: int = 0
class QuotationCardData(WebPacketProtocol):
session_card_id: uuid.UUID
qt_id: Optional[uuid.UUID] = None
nego_card_id: Optional[uuid.UUID] = None
wild_card_id: Optional[uuid.UUID] = None
type: Optional[CardType] = None
number: Optional[str] = None
name: Optional[str] = None
script: Optional[str] = None # 협상 멘트(평문)
edit_script: Optional[Any] = None # 협상 멘트(Slate 서식본)
condition: Optional[str] = None # 와일드카드 전용: 사용 조건(트리거)
memo: Optional[str] = None # 와일드카드 전용: 메모
class Res_QuotationCards(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
cards: list[QuotationCardData] = []
class TargetCandidate(WebPacketProtocol):
basis: str
label: str
value: int
class Res_TargetBreakdown(Res_WebPacketProtocol):
is_new: bool = False
is_inherited: bool = False
md_price: Optional[int] = None
internet_lowest: Optional[int] = None
purchase: Optional[int] = None
selling: Optional[int] = None
fee: float = 0.0
margin: float = 0.0
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
candidates: list[TargetCandidate] = []
hidden_price_fields: list[str] = [] # 회사 설정으로 감춰 후보에서 뺀 가격 필드
chosen_basis: Optional[str] = None
target_price: int = 0
anchoring_price: Optional[int] = None