협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.
agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
(Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다
negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)
negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림
테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
354 lines
17 KiB
Python
354 lines
17 KiB
Python
from abc import ABC, abstractmethod
|
||
from typing import Tuple
|
||
|
||
from sqlalchemy import select, func, and_, or_, case, cast, BigInteger
|
||
from sqlalchemy.orm import aliased
|
||
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, CardType, ChatSender
|
||
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 markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def card_effect_chats(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 허용).
|
||
# 계약가 = 담당자가 확정한 값이 있으면 그 값, 없으면 협력사 투찰가.
|
||
# 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
|
||
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
|
||
try:
|
||
offline_price = cast(sessions.custom["offline_award"]["price"].astext, BigInteger)
|
||
award_price = func.coalesce(offline_price, sessions.bid_price).label("bid_price")
|
||
stmt = (
|
||
select(
|
||
quotations.updated_at,
|
||
quotations.type,
|
||
items.category,
|
||
sessions.target_price,
|
||
award_price,
|
||
sessions.anchoring_price,
|
||
offline_price.isnot(None).label("is_offline"), # 오프라인 협상 반영 건수 표기용
|
||
)
|
||
.select_from(quotations)
|
||
.join(
|
||
sessions,
|
||
and_(
|
||
sessions.quotation_id == quotations.qt_id,
|
||
sessions.supplier_id == quotations.preferred_sp_id,
|
||
or_(sessions.bid_price.isnot(None), offline_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 markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||
# 인상억제율(재협상 전용): 같은 견적번호(qt_number)의 직전 라운드 투찰가 대비 이번 라운드 투찰가가
|
||
# 얼마나 안 올랐나 = avg((직전투찰 − 이번투찰) / 직전투찰). 양수=인하(억제 성공), 음수=인상 허용.
|
||
# 직전·이번 둘 다 유효 투찰(bid_price)이 있는 재협상 쌍만 대상(직전이 개찰/거부면 비교 불가 → 제외).
|
||
# 새 컬럼 없이 sessions.qt_number+qt_round+bid_price 로만 파생.
|
||
try:
|
||
prev = aliased(sessions)
|
||
stmt = (
|
||
select(func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
|
||
.select_from(sessions)
|
||
.join(
|
||
prev,
|
||
and_(
|
||
prev.qt_number == sessions.qt_number,
|
||
prev.item_id == sessions.item_id,
|
||
prev.supplier_id == sessions.supplier_id,
|
||
prev.qt_round == sessions.qt_round - 1,
|
||
prev.bid_price.isnot(None),
|
||
prev.bid_price > 0,
|
||
prev.deleted == False, # noqa: E712
|
||
),
|
||
)
|
||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||
.where(
|
||
and_(
|
||
*_company_scope(company_id, owner),
|
||
sessions.bid_price.isnot(None),
|
||
sessions.qt_round >= 2,
|
||
sessions.deleted == False, # noqa: E712
|
||
quotations.updated_at >= since,
|
||
)
|
||
)
|
||
)
|
||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||
if err != ErrorType.SUCCESS:
|
||
return err, 0.0
|
||
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 markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||
# 월별 인상억제율: 이번 라운드 마감월(quotations.updated_at)별 avg((직전투찰 − 이번투찰)/직전투찰).
|
||
try:
|
||
prev = aliased(sessions)
|
||
month = func.to_char(quotations.updated_at, "YYYY-MM")
|
||
stmt = (
|
||
select(month.label("m"), func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
|
||
.select_from(sessions)
|
||
.join(
|
||
prev,
|
||
and_(
|
||
prev.qt_number == sessions.qt_number,
|
||
prev.item_id == sessions.item_id,
|
||
prev.supplier_id == sessions.supplier_id,
|
||
prev.qt_round == sessions.qt_round - 1,
|
||
prev.bid_price.isnot(None),
|
||
prev.bid_price > 0,
|
||
prev.deleted == False, # noqa: E712
|
||
),
|
||
)
|
||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||
.where(
|
||
and_(
|
||
*_company_scope(company_id, owner),
|
||
sessions.bid_price.isnot(None),
|
||
sessions.qt_round >= 2,
|
||
sessions.deleted == False, # noqa: E712
|
||
quotations.updated_at >= since,
|
||
)
|
||
)
|
||
.group_by(month)
|
||
.order_by(month)
|
||
)
|
||
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 card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||
# 카드 유형별 사용 빈도: card_used_yn=True 채팅 + 1% 인하 시스템 카드(meta.step='wild_card_1pct', card_id 미제공)를
|
||
# 와일드로 함께 집계. (1% 카드는 카탈로그 카드가 아니라 card_type 로그가 없어 step 으로 잡는다.)
|
||
try:
|
||
step_1pct = chats.meta["step"].astext == "wild_card_1pct"
|
||
ctype = case(
|
||
(chats.card_used_yn.is_(True), chats.card_type),
|
||
(step_1pct, CardType.WILD.value),
|
||
else_=None,
|
||
)
|
||
conds = [
|
||
chats.deleted == False, # noqa: E712
|
||
or_(chats.card_used_yn.is_(True), step_1pct),
|
||
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(ctype, 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(ctype)
|
||
)
|
||
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 card_effect_chats(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||
# 카드 사용 직후 제시가 하락 산출용 — 유저 제시가 chat + 카드 사용 chat(1% 인하 포함)을 세션·순번 순으로.
|
||
try:
|
||
step_1pct = chats.meta["step"].astext == "wild_card_1pct"
|
||
conds = [
|
||
chats.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)),
|
||
or_(
|
||
and_(chats.sender == ChatSender.USER.value, chats.target_price > 0),
|
||
chats.card_used_yn.is_(True),
|
||
step_1pct,
|
||
),
|
||
]
|
||
if owner is not None:
|
||
conds.append(quotations.user_id == owner)
|
||
stmt = (
|
||
select(chats.session_id, chats.seq, chats.sender, chats.target_price, chats.card_used_yn,
|
||
chats.card_type, step_1pct, sessions.bid_price, sessions.status)
|
||
.select_from(chats)
|
||
.join(sessions, sessions.session_id == chats.session_id)
|
||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||
.where(and_(*conds))
|
||
.order_by(chats.session_id, chats.seq)
|
||
)
|
||
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, []
|