협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
81 lines
2.9 KiB
Python
81 lines
2.9 KiB
Python
from pydantic import Field
|
||
|
||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||
|
||
|
||
class StatisticsProtocol(WebPacketProtocol):
|
||
pass
|
||
|
||
|
||
class StatKpi(WebPacketProtocol):
|
||
total_savings: int = 0 # 총 절감액(목표가 대비, 낙찰세션 target−bid 합)
|
||
savings_rate: float = 0.0 # 평균 절감률 = 절감합/목표합
|
||
award_rate: float = 0.0 # 낙찰률 = 낙찰 마감 / 전체 마감
|
||
anchor_reach_rate: float = 0.0 # 평균 앵커 도달률 = (목표−투찰)/(목표−앵커)
|
||
savings_delta_mom: int = 0 # 전월 대비 절감액 증감
|
||
closed_count: int = 0 # 마감 견적 수(창)
|
||
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
||
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
|
||
offline_award_count: int = 0 # 낙찰 중 오프라인 협상 결과를 담당자가 반영한 건수(절감액엔 포함)
|
||
|
||
|
||
class StatMonthPoint(WebPacketProtocol):
|
||
month: str # 'YYYY-MM'
|
||
savings: int = 0
|
||
rate: float = 0.0 # 절감률
|
||
|
||
|
||
class StatMarkupPoint(WebPacketProtocol):
|
||
month: str # 'YYYY-MM'
|
||
rate: float = 0.0 # 인상억제율(직전 라운드 투찰가 대비 인하율)
|
||
|
||
|
||
class StatOutcome(WebPacketProtocol):
|
||
awarded: int = 0 # CloseReason 1
|
||
open_price: int = 0 # 5 가격 미달
|
||
open_equal: int = 0 # 6 동가
|
||
open_noshow: int = 0 # 7 미응찰
|
||
open_reject: int = 0 # 8 거부
|
||
|
||
|
||
class StatParticipation(WebPacketProtocol):
|
||
bid: int = 0 # 응찰(SessionStatus DONE)
|
||
no_participate: int = 0 # 미응찰(NOT_PARTICIPATED)
|
||
rejected: int = 0 # 거부(REJECTED)
|
||
|
||
|
||
class StatTypeRow(WebPacketProtocol):
|
||
label: str
|
||
award_rate: float = 0.0
|
||
avg_savings: int = 0
|
||
count: int = 0
|
||
|
||
|
||
class StatCategory(WebPacketProtocol):
|
||
category: str
|
||
savings: int = 0
|
||
count: int = 0
|
||
|
||
|
||
class StatCardUsage(WebPacketProtocol):
|
||
type: str # 'nego' | 'wild'
|
||
label: str
|
||
uses: int = 0
|
||
avg_drop: int = 0 # 사용 직후 상대 제시가 평균 하락. TODO: v1은 0(유형별 빈도만) — chats seq 델타 계산은 다음 단계.
|
||
|
||
|
||
class StatScope(WebPacketProtocol):
|
||
kpi: StatKpi = Field(default_factory=StatKpi)
|
||
trend: list[StatMonthPoint] = []
|
||
markup_trend: list[StatMarkupPoint] = [] # 월별 인상억제율(재협상)
|
||
outcome: StatOutcome = Field(default_factory=StatOutcome)
|
||
participation: StatParticipation = Field(default_factory=StatParticipation)
|
||
type_split: list[StatTypeRow] = []
|
||
categories: list[StatCategory] = []
|
||
cards: list[StatCardUsage] = []
|
||
|
||
|
||
class Res_StatisticsSummary(Res_WebPacketProtocol):
|
||
company: StatScope = Field(default_factory=StatScope) # 회사 전체(company_id 스코프)
|
||
mine: StatScope = Field(default_factory=StatScope) # 내가 만든 견적(user_id 추가 스코프)
|