o2o-negosium-original/negodata/backend/services/statistics_service.py

230 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.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