230 lines
12 KiB
Python
230 lines
12 KiB
Python
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
|