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, []