o2o-negosium-original/negodata/backend/services/statistics_service.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

231 lines
12 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 fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations
from common.enums import DBWRType, ErrorType, QuotationType, SessionStatus, CardType, CloseReason, ChatSender
from common.utils.gtime import GTime
from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD
from router.v1.statistics.protocol import (
Res_StatisticsSummary,
StatScope,
StatKpi,
StatMonthPoint,
StatMarkupPoint,
StatOutcome,
StatParticipation,
StatTypeRow,
StatCategory,
StatCardUsage,
)
WINDOW_MONTHS = 6 # 최근 6개월(당월 포함) 창
class StatisticsService:
"""통계(성과 분석) 집계. 회사 전체(company)와 내 견적(mine) 두 스코프를 한 응답으로 내린다.
전부 파생(저장 안 함) — 조회 때 sessions/items/chats 조인 집계한다. 읽기 전용.
절감 원천은 '낙찰 세션'(preferred_sp_id) 이며, 여기서 총절감·추이·유형·카테고리·앵커도달률을 모두 파생한다.
"""
def __init__(self, stat_crud: IStatisticsCRUD = Depends(StatisticsCRUD)):
self.stat_crud = stat_crud
async def get_summary(self, company_id: str, user_id: str) -> Res_StatisticsSummary:
res = Res_StatisticsSummary()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
labels, window_start = self._window(GTime.UTC())
res.company = await self._scope(company_uuid, None, labels, window_start)
res.mine = await self._scope(company_uuid, user_uuid, labels, window_start)
return res
# ── 스코프 집계 ─────────────────────────────────────────────
async def _scope(self, company_uuid, owner_uuid, labels, since) -> StatScope:
scope = StatScope()
win_rows = await self._read(lambda s: self.stat_crud.winning_sessions(s, company_uuid, owner_uuid, since))
outcome_rows = await self._read(lambda s: self.stat_crud.outcome_counts(s, company_uuid, owner_uuid, since))
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since))
markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since))
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
card_effect_rows = await self._read(lambda s: self.stat_crud.card_effect_chats(s, company_uuid, owner_uuid, since))
scope.trend = self._trend(win_rows, labels)
scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows]
scope.categories = self._categories(win_rows)
scope.outcome = self._outcome(outcome_rows)
scope.participation = self._participation(part_rows)
scope.type_split = self._type_split(type_rows, win_rows)
scope.cards = self._cards(card_rows, self._card_drops(card_effect_rows))
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
return scope
# ── 파생 계산 ───────────────────────────────────────────────
def _kpi(self, win_rows, trend, outcome, regen, markup) -> StatKpi:
k = StatKpi()
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
total_target = sum(int(r.target_price) for r in win_rows)
k.total_savings = total_saving
k.savings_rate = (total_saving / total_target) if total_target else 0.0
k.anchor_reach_rate = self._anchor_reach(win_rows)
closed = outcome.awarded + outcome.open_price + outcome.open_equal + outcome.open_noshow + outcome.open_reject
k.closed_count = closed
k.award_rate = (outcome.awarded / closed) if closed else 0.0
k.regen_avg_round = round(regen, 2)
k.offline_award_count = sum(1 for r in win_rows if r.is_offline)
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
return k
def _anchor_reach(self, win_rows) -> float:
# (목표−투찰)/(목표−앵커), 앵커 있고 목표>앵커인 세션만 평균.
# 세션별로 [0,100%] 클램프 후 평균 — '도달률'이라 앵커 도달=100% 상한(앵커 뚫어도 100%로).
# (앵커 gap 이 작으면 원비율이 100% 훌쩍 넘어 평균이 왜곡되므로 캡한다.)
vals = []
for r in win_rows:
if r.anchoring_price is None:
continue
target, bid, anchor = int(r.target_price), int(r.bid_price), int(r.anchoring_price)
span = target - anchor
if span > 0:
reach = (target - bid) / span
vals.append(min(1.0, max(0.0, reach)))
return (sum(vals) / len(vals)) if vals else 0.0
def _trend(self, win_rows, labels) -> list:
bucket = {m: {"savings": 0, "target": 0} for m in labels}
for r in win_rows:
m = f"{r.updated_at.year:04d}-{r.updated_at.month:02d}"
if m in bucket:
bucket[m]["savings"] += int(r.target_price) - int(r.bid_price)
bucket[m]["target"] += int(r.target_price)
out = []
for m in labels:
b = bucket[m]
rate = (b["savings"] / b["target"]) if b["target"] else 0.0
out.append(StatMonthPoint(month=m, savings=b["savings"], rate=rate))
return out
def _categories(self, win_rows) -> list:
# TODO: items.category 자유텍스트 그룹 — 표기 흔들리면 지저분. 카테고리 정규화(코드/테이블) 후 개선.
agg: dict = {}
for r in win_rows:
key = r.category or "미분류"
a = agg.setdefault(key, {"savings": 0, "count": 0})
a["savings"] += int(r.target_price) - int(r.bid_price)
a["count"] += 1
rows = [StatCategory(category=k, savings=v["savings"], count=v["count"]) for k, v in agg.items()]
rows.sort(key=lambda x: x.savings, reverse=True)
return rows
def _outcome(self, outcome_rows) -> StatOutcome:
by = {int(cr): int(n) for cr, n in outcome_rows if cr is not None}
return StatOutcome(
awarded=by.get(CloseReason.AWARDED.value, 0),
open_price=by.get(CloseReason.OPEN_PRICE.value, 0),
open_equal=by.get(CloseReason.OPEN_EQUAL.value, 0),
open_noshow=by.get(CloseReason.OPEN_NOSHOW.value, 0),
open_reject=by.get(CloseReason.OPEN_REJECT.value, 0),
)
def _participation(self, part_rows) -> StatParticipation:
by = {int(st): int(n) for st, n in part_rows if st is not None}
return StatParticipation(
bid=by.get(SessionStatus.DONE.value, 0),
no_participate=by.get(SessionStatus.NOT_PARTICIPATED.value, 0),
rejected=by.get(SessionStatus.REJECTED.value, 0),
)
def _type_split(self, type_rows, win_rows) -> list:
# 4개 코드(협상 1·3 / 견적 2·4=1:N)를 2그룹으로 묶는다. 낙찰률·건수=type_counts, 평균절감=낙찰세션.
grp = {"nego": {"count": 0, "awarded": 0}, "auction": {"count": 0, "awarded": 0}}
for t, cnt, awarded in type_rows:
g = "auction" if QuotationType.is_auction(int(t)) else "nego"
grp[g]["count"] += int(cnt or 0)
grp[g]["awarded"] += int(awarded or 0)
sav = {"nego": [], "auction": []}
for r in win_rows:
g = "auction" if QuotationType.is_auction(int(r.type)) else "nego"
sav[g].append(int(r.target_price) - int(r.bid_price))
out = []
for g, label in (("nego", "협상 (1:1)"), ("auction", "견적 (1:N)")):
cnt = grp[g]["count"]
rate = (grp[g]["awarded"] / cnt) if cnt else 0.0
avg = int(sum(sav[g]) / len(sav[g])) if sav[g] else 0
out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt))
return out
def _cards(self, card_rows, drops: dict) -> list:
by = {int(ct): int(n) for ct, n in card_rows if ct is not None}
return [
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=int(round(drops.get(CardType.NEGO.value, 0)))),
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=int(round(drops.get(CardType.WILD.value, 0)))),
]
def _card_drops(self, rows) -> dict:
# 카드 사용 직후 제시가 하락(유형별 평균).
# - 일반: 카드 직전 유저 제시가 − 직후 유저 제시가.
# - 1% 인하(수락은 가격 재입력이 아님): 카드 직전 유저 제시가 − 최종 낙찰가(타결 세션).
# rows: (session_id, seq, sender, target_price, card_used_yn, card_type, is_1pct, bid_price, status)
by_sess: dict = {}
for r in rows:
by_sess.setdefault(r[0], []).append(r)
sums = {CardType.NEGO.value: 0, CardType.WILD.value: 0}
cnts = {CardType.NEGO.value: 0, CardType.WILD.value: 0}
for chs in by_sess.values():
for i, ch in enumerate(chs):
_sid, _seq, _sender, _tp, used, ctype, is_1pct, bid_price, status = ch
if not (used or is_1pct):
continue
ct = int(ctype) if ctype else (CardType.WILD.value if is_1pct else None)
if ct not in sums:
continue
prev = next((c[3] for c in reversed(chs[:i]) if c[2] == ChatSender.USER.value and c[3] and c[3] > 0), None)
if is_1pct:
if prev is not None and bid_price and status == SessionStatus.DONE.value and prev >= bid_price:
sums[ct] += (prev - bid_price)
cnts[ct] += 1
else:
after = next((c[3] for c in chs[i + 1:] if c[2] == ChatSender.USER.value and c[3] and c[3] > 0), None)
if prev is not None and after is not None:
sums[ct] += (prev - after)
cnts[ct] += 1
return {ct: (sums[ct] / cnts[ct]) if cnts[ct] else 0 for ct in sums}
# ── 창(최근 6개월) ─────────────────────────────────────────
def _window(self, now):
yy, mm = now.year, now.month
mm -= (WINDOW_MONTHS - 1)
while mm <= 0:
mm += 12
yy -= 1
window_start = now.replace(year=yy, month=mm, day=1, hour=0, minute=0, second=0, microsecond=0)
labels, ly, lm = [], yy, mm
for _ in range(WINDOW_MONTHS):
labels.append(f"{ly:04d}-{lm:02d}")
lm += 1
if lm > 12:
lm = 1
ly += 1
return labels, window_start
# ── DB 실행 헬퍼 ───────────────────────────────────────────
async def _read(self, fn) -> list:
err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
return rows if err == ErrorType.SUCCESS else []
async def _read_scalar(self, fn) -> float:
err, val = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
return val if err == ErrorType.SUCCESS else 0.0