협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고, 거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다. negosium - 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출, 주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap - 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다 - /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합 - 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업) negodata - 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부 - 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가) - 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다 - 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…' 자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
279 lines
15 KiB
Python
279 lines
15 KiB
Python
from abc import ABC, abstractmethod
|
|
from typing import Optional, Tuple
|
|
|
|
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import chats, items, quotations, sessions
|
|
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
|
from common.logger import LOG
|
|
|
|
|
|
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
|
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
|
|
|
|
|
def _effective_status():
|
|
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
|
|
|
|
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
|
|
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
|
|
"""
|
|
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
|
|
return case(
|
|
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
|
|
else_=sessions.status,
|
|
)
|
|
|
|
|
|
class ISessionCRUD(ABC):
|
|
@abstractmethod
|
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_session_by_id(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, sessions]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_quotation_by_id(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, quotations]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_session_status(self, cdb: AsyncSession, session_id, status: int) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_session_reject(
|
|
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
|
) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
|
pass
|
|
|
|
|
|
class SessionCRUD(ISessionCRUD):
|
|
@staticmethod
|
|
def __filters(supplier_id, status, qt_type, keyword=None, result=None):
|
|
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
|
if status is not None:
|
|
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
|
|
conds.append(_effective_status() == status)
|
|
if qt_type is not None:
|
|
conds.append(sessions.qt_type == qt_type)
|
|
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
|
|
# ILIKE 와일드카드(%,_)는 escape 해 사용자 입력이 패턴으로 새지 않게 한다.
|
|
if keyword and keyword.strip():
|
|
kw = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
like = f"%{kw}%"
|
|
conds.append(or_(sessions.qt_number.ilike(like), items.name.ilike(like), items.code.ilike(like)))
|
|
# 결과(SessionResult) 필터 — _to_result 파생 규칙을 SQL WHERE 로 그대로 복제(집계·필터 일치용).
|
|
# 1=낙찰 2=미낙찰 3=결렬(개찰). 전부 견적 마감(CLOSED) 이 전제.
|
|
if result in (1, 2, 3):
|
|
conds.append(quotations.status == QuotationStatus.CLOSED.value)
|
|
if result == 1:
|
|
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
|
|
conds.append(quotations.preferred_sp_id == sessions.supplier_id)
|
|
elif result == 2:
|
|
conds.append(quotations.close_reason == CloseReason.AWARDED.value)
|
|
conds.append(or_(quotations.preferred_sp_id.is_(None), quotations.preferred_sp_id != sessions.supplier_id))
|
|
else:
|
|
conds.append(quotations.close_reason.in_(RENEGOTIABLE_CLOSE_REASONS))
|
|
return conds
|
|
|
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
|
try:
|
|
conds = self.__filters(supplier_id, status, qt_type, keyword, result)
|
|
|
|
# 정렬 규칙:
|
|
# - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬).
|
|
# - order 가 없으면(기본) UX 그룹 정렬:
|
|
# (1) '할 일'(협상생성·협상중)을 위로, 종료(완료·미참여·거부)는 아래로 그룹핑
|
|
# (2) 액션 그룹은 마감 임박순, (3) 종료 그룹은 최근 마감순(desc)
|
|
# - 어느 경우든 동일 마감은 session_id 로 tie-break → 페이지네이션 안정화.
|
|
# end_time 이 실제 NULL 인 견적은 nulls_last 로 맨 뒤로 민다.
|
|
if order in ("asc", "desc"):
|
|
flat = quotations.end_time.desc() if order == "desc" else quotations.end_time.asc()
|
|
order_cols = (nulls_last(flat), sessions.session_id.asc())
|
|
else:
|
|
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
|
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
|
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
|
status_rank = case((actionable, 0), else_=1)
|
|
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
|
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
|
order_cols = (status_rank.asc(), nulls_last(action_order), nulls_last(done_order), sessions.session_id.asc())
|
|
|
|
query = (
|
|
select(
|
|
sessions.session_id,
|
|
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
|
|
sessions.qt_type,
|
|
sessions.qt_number,
|
|
quotations.end_time, # qt_end_time = 견적 마감 시각
|
|
items.code,
|
|
items.name,
|
|
items.model_name,
|
|
items.manufacturer,
|
|
sessions.custom,
|
|
quotations.status, # 재협상 요청 자격 판정용(마감 여부)
|
|
quotations.close_reason, # 개찰(결렬) 사유
|
|
quotations.round,
|
|
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
|
|
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
|
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
|
|
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
|
|
)
|
|
.join(items, items.item_id == sessions.item_id)
|
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
|
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
|
|
.order_by(*order_cols)
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "list_by_supplier failed.")
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, []
|
|
return ErrorType.SUCCESS, rows
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, []
|
|
|
|
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]:
|
|
try:
|
|
conds = self.__filters(supplier_id, status, qt_type, keyword, result)
|
|
query = (
|
|
select(func.count())
|
|
.select_from(sessions)
|
|
.join(items, items.item_id == sessions.item_id)
|
|
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
|
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
|
|
)
|
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_by_supplier failed.")
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, 0
|
|
return ErrorType.SUCCESS, (rows[0] if rows else 0)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, 0
|
|
|
|
async def get_session_by_id(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, sessions]:
|
|
try:
|
|
query = select(sessions).where(sessions.session_id == session_id, sessions.deleted == False).limit(1) # noqa: E712
|
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_session_by_id({session_id}) failed.")
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, None
|
|
if len(row_list) != 1:
|
|
return ErrorType.DB_INVALID_KEY, None
|
|
return ErrorType.SUCCESS, row_list[0]
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, None
|
|
|
|
async def get_quotation_by_id(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, quotations]:
|
|
try:
|
|
query = select(quotations).where(quotations.qt_id == quotation_id, quotations.deleted == False).limit(1) # noqa: E712
|
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_quotation_by_id({quotation_id}) failed.")
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, None
|
|
if len(row_list) != 1:
|
|
return ErrorType.DB_INVALID_KEY, None
|
|
return ErrorType.SUCCESS, row_list[0]
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, None
|
|
|
|
async def update_session_status(self, cdb: AsyncSession, session_id, status: int) -> ErrorType:
|
|
try:
|
|
query = update(sessions).where(sessions.session_id == session_id).values(status=status)
|
|
return await DB_SESSION_MNG.add(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED
|
|
|
|
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
|
|
try:
|
|
query = update(quotations).where(quotations.qt_id == quotation_id).values(status=status)
|
|
return await DB_SESSION_MNG.add(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED
|
|
|
|
async def update_session_reject(
|
|
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
|
) -> ErrorType:
|
|
try:
|
|
values = {"status": status, "reject_reason": reject_reason}
|
|
# 공급 희망 가격은 채팅 중 거부에서만 들어온다 — 목록 거부는 가격 없이 사유만 남긴다.
|
|
if reject_price is not None:
|
|
values["reject_price"] = reject_price
|
|
query = (
|
|
update(sessions)
|
|
.where(sessions.session_id == session_id)
|
|
.values(**values)
|
|
)
|
|
return await DB_SESSION_MNG.add(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED
|
|
|
|
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
|
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
|
|
try:
|
|
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
|
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, 0
|
|
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님).
|
|
top = rows[0] if rows else None
|
|
return ErrorType.SUCCESS, int(top or 0)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, 0
|
|
|
|
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
|
# sessions.custom 부분 갱신(기존 키 보존). 부가정보와 재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
|
|
try:
|
|
query = (
|
|
update(sessions)
|
|
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
|
|
.values(custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)))
|
|
)
|
|
return await DB_SESSION_MNG.add(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED
|
|
|
|
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
|
|
# 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드).
|
|
try:
|
|
query = (
|
|
update(sessions)
|
|
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
|
|
.values(custom=custom)
|
|
)
|
|
return await DB_SESSION_MNG.add(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED
|