o2o-negosium-original/negodata/backend/router/v1/statistics/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

81 lines
2.9 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.

from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class StatisticsProtocol(WebPacketProtocol):
pass
class StatKpi(WebPacketProtocol):
total_savings: int = 0 # 총 절감액(목표가 대비, 낙찰세션 targetbid 합)
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 추가 스코프)