직접 낙찰(담당자 오프라인 계약가)을 AI 자동 낙찰과 통계·화면에서 구분하기 위해
낙찰 방식을 전용 컬럼으로 박고, 계약가·사유를 성격대로 배치했다. 처음엔 계약가·사유를
sessions.custom.offline_award(JSONB) 한 뭉치에 넣었으나 성격이 갈려 정리한다.
DB (postgres-init: init.sql 정본 + alters/2026-08-11-award-type.sql 보정, 멱등)
- quotations.award_type SMALLINT — 낙찰 방식(1=자동/2=직접). 통계 조회·집계 축이라 컬럼
- quotations.custom JSONB — 직접 낙찰 사유·처리자·시각(award={reason,by,at}). 표시·감사용
- sessions.contract_price BIGINT — 직접 낙찰 계약가. bid_price/reject_price 와 같은 협력사
가격 축이라 세션에. 자동 낙찰은 NULL(투찰가가 곧 계약가)
- 기존 offline_award(JSONB) 데이터를 컬럼·견적 custom 으로 이관 후 키 제거
backend
- 자동 낙찰(close_and_decide)=AUTO, 직접 낙찰(claim_for_award)=MANUAL 로 award_type 기록
- 직접 낙찰: 계약가→세션 contract_price, 사유·처리자·시각→견적 custom.award (한 트랜잭션)
- 통계 계약가 = coalesce(contract_price, bid_price) — JSONB 캐스팅 제거(컬럼끼리, 인덱스·타입 안전)
- QuotationData 에 award_type·custom, SessionData 에 contract_price 노출
- 안 쓰게 된 merge_session_custom 제거
front
- 견적 상세 결과 밴드에 '직접 낙찰' 배지 + 낙찰 사유 표시(견적 custom.award.reason).
거부·미참여 낙찰은 이미 직접 낙찰을 함의하므로 '— 낙찰' 꼬리를 떼 중복 표기 제거
- 목록 결과 배지에 '낙찰(직접)' 표기 — AI 자동낙찰과 한눈에 구분
- offlineAward()→directAwardPrice()/awardMeta() 로 교체(세션 컬럼·견적 custom 에서 읽음)
- 협상현황 표: 부가정보(회사 필드)와 의견(custom.opinion)을 별도 컬럼으로 분리
- 미응찰 건 레일 4번 칸 라벨 '최저 투찰가'→'결과'(투찰 없을 때)
테스트: negodata 110건 통과. 프론트 tsc+eslint 통과. dev DB 적용·화면 확인.
225 lines
9.5 KiB
Python
225 lines
9.5 KiB
Python
import uuid
|
||
from datetime import datetime
|
||
from typing import Any, Optional
|
||
|
||
from pydantic import ConfigDict
|
||
|
||
from common.enums import AwardType, 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
|
||
award_type: Optional[AwardType] = None # 낙찰 방식(AwardType 1=자동/2=직접). 미낙찰이면 None
|
||
custom: Optional[dict] = None # 견적 부가정보. 직접 낙찰 시 award={reason,by,at}(사유·처리자·시각)
|
||
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 # 앵커링가(원). 목표가×(1000−anchoring_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
|
||
contract_price: Optional[int] = None # 직접 낙찰 계약가(원). 자동낙찰은 None(bid_price 가 계약가)
|
||
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
|