o2o-negosium-original/negodata/backend/crud/statistics_crud.py
Mina Choi c32ee300e6 [feat] negodata: 통계 페이지(성과 분석) 추가 — 파생 집계 API + recharts 차트
- 백엔드 GET /v1/statistics/summary: 회사/내견적 스코프, 최근 6개월 파생 집계(저장 X, DDL 0)
- 지표: 총절감/절감률/낙찰률/앵커도달률/마감수/재견적라운드 + 월별추이·마감결과분해·참여율·유형별(협상/견적)·카테고리·카드빈도
- 프론트 recharts+shadcn Chart, 사이드바 통계 nav, /statistics

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:33 +09:00

215 lines
9.6 KiB
Python

from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, func, and_, case
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, items, chats, users
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus
from common.logger import LOG
# 통계 유니버스 = 현재 마감사유 5코드로 마감된 견적. 레거시 REGEN_*(2·3·4) 은 제외해
# KPI(낙찰률·마감수)와 유형별/결과분해의 분모를 일치시킨다(프로덕션엔 레거시 없어 전체 마감과 동일).
CURRENT_CLOSE_REASONS = [
CloseReason.AWARDED.value,
CloseReason.OPEN_PRICE.value,
CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value,
CloseReason.OPEN_REJECT.value,
]
# 통계 집계 CRUD. 대시보드와 동일하게 회사 스코프(작성자 user_id→users.company_id)로 건다.
# owner(user_id) 가 주어지면 '내가 만든 견적'으로 더 좁힌다. quotations 엔 company_id 컬럼이 없어 서브쿼리로.
def _company_scope(company_id, owner) -> list:
conds = [
quotations.deleted == False, # noqa: E712
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
return conds
class IStatisticsCRUD(ABC):
@abstractmethod
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
pass
@abstractmethod
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
try:
stmt = (
select(
quotations.updated_at,
quotations.type,
items.category,
sessions.target_price,
sessions.bid_price,
sessions.anchoring_price,
)
.select_from(quotations)
.join(
sessions,
and_(
sessions.quotation_id == quotations.qt_id,
sessions.supplier_id == quotations.preferred_sp_id,
sessions.bid_price.isnot(None),
sessions.deleted == False, # noqa: E712
),
)
.join(items, items.item_id == sessions.item_id, isouter=True)
.where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason == CloseReason.AWARDED.value,
quotations.updated_at >= since,
)
)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 마감 결과 분해: close_reason 별 건수. 낙찰률·마감건수도 여기서 파생.
try:
stmt = (
select(quotations.close_reason, func.count())
.where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
quotations.updated_at >= since,
)
)
.group_by(quotations.close_reason)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 유형별(협상/경매) 마감 건수 + 낙찰 건수 → 유형별 낙찰률.
try:
awarded = func.sum(case((quotations.close_reason == CloseReason.AWARDED.value, 1), else_=0))
stmt = (
select(quotations.type, func.count(), awarded)
.where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
quotations.updated_at >= since,
)
)
.group_by(quotations.type)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 협력사 참여: 회사 견적(창 내 생성)의 세션을 status 별 집계(응찰/미응찰/거부).
try:
conds = [
sessions.deleted == False, # noqa: E712
quotations.deleted == False, # noqa: E712
quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
stmt = (
select(sessions.status, func.count())
.select_from(sessions)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds))
.group_by(sessions.status)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
# 평균 재견적 라운드. TODO: 체인키 없어 avg(round) 단순버전 — 체인당 최대 라운드 정의는 root_qt_id 도입 후.
try:
stmt = select(func.avg(quotations.round)).where(
and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
quotations.updated_at >= since,
)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
if err != ErrorType.SUCCESS:
return err, 0.0
# 단일컬럼 집계는 execute 가 스칼라 리스트를 반환한다(대시보드 _count 와 동일). rows[0] 이 곧 avg 값.
val = rows[0] if rows else None
return ErrorType.SUCCESS, float(val) if val is not None else 0.0
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0.0
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
try:
conds = [
chats.deleted == False, # noqa: E712
chats.card_used_yn.is_(True),
quotations.deleted == False, # noqa: E712
quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
stmt = (
select(chats.card_type, func.count())
.select_from(chats)
.join(sessions, sessions.session_id == chats.session_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds))
.group_by(chats.card_type)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []