[fix] negodata: 협상카드 성공률에 회사 스코프 누락 — 타사 견적 이력이 섞이던 문제

card_success_map 이 quotations 조인 없이 DB 전체 chats 를 카드별로 집계해,
다른 회사 견적에서 쓰인 이력이 내 카드 목록의 used_count/success_rate 로 잡혔다.
통계 화면 '협상카드 성공률 TOP 5' 와 견적 생성 시 카드 추천 순위가 모두 이 값을 쓴다.

- quotations 조인 + 작성자 회사(users.company_id) 스코프 추가
- 소프트 삭제분 제외(sessions.deleted / quotations.deleted 필터 누락분 보강)
This commit is contained in:
Mina Choi 2026-08-13 14:04:48 +09:00
parent 8112394aef
commit 69e4240039
2 changed files with 14 additions and 5 deletions

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users, chats, sessions from common.database.model.models import users, chats, sessions, quotations
from common.enums import ErrorType, SessionStatus from common.enums import ErrorType, SessionStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -38,7 +38,7 @@ class ICardCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]: async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
pass pass
@abstractmethod @abstractmethod
@ -83,9 +83,10 @@ class CardCRUD(ICardCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]: async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
# 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율. # 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율.
# {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료). # {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료).
# 집계 범위는 내 회사 견적에서 쓰인 이력만 — quotations 까지 조인해 작성자 회사로 좁힌다.
try: try:
stmt = ( stmt = (
select( select(
@ -94,7 +95,15 @@ class CardCRUD(ICardCRUD):
func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"), func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"),
) )
.join(sessions, sessions.session_id == chats.session_id) .join(sessions, sessions.session_id == chats.session_id)
.where(chats.card_used_yn == True, chats.card_id.isnot(None), chats.deleted == False) # noqa: E712 .join(quotations, quotations.qt_id == sessions.quotation_id)
.where(
chats.card_used_yn == True, # noqa: E712
chats.card_id.isnot(None),
chats.deleted == False, # noqa: E712
sessions.deleted == False, # noqa: E712
quotations.deleted == False, # noqa: E712
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
)
.group_by(chats.card_id) .group_by(chats.card_id)
) )
err, rows = await DB_SESSION_MNG.execute(cdb, stmt) err, rows = await DB_SESSION_MNG.execute(cdb, stmt)

View File

@ -142,7 +142,7 @@ class CardService:
# 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑. # 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑.
_e, success_map = await DB_SESSION_MNG.execute_lambda( _e, success_map = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(), DBWRType.DB_READ.value, nego_cards.DBType(), DBWRType.DB_READ.value,
lambda s: self.card_crud.card_success_map(s), lambda s: self.card_crud.card_success_map(s, company_uuid),
) )
success_map = success_map or {} success_map = success_map or {}
for c in page: for c in page: