o2o-negosium-original/backend/crud/session_crud.py
민헌 5519c6e73b feat(negotiation): 세션 목록 기본 그룹 정렬 + order 지정 시 전체 정렬
목록 진입 시 "지금 할 일"이 먼저 보이도록 기본 정렬을 개선하고,
사용자가 마감일로 직접 훑어볼 수 있는 전체 정렬 모드를 추가한다.

- 기본(order 미지정): 협상생성·협상중을 상단 그룹으로 마감 임박순,
  종료(완료·미참여·거부)는 하단 그룹으로 최근 마감순. 동일 마감은
  session_id 로 tie-break 하여 페이지네이션 안정화.
- order=asc/desc: 그룹 구분 없이 전체를 마감(qt_end_time) 기준으로 정렬.
- router: order 기본값을 "asc" → None(Optional) 로 변경해 "그룹 기본"과
  "명시적 전체 정렬"을 구분.
- frontend: SessionListParams.order 주석을 새 계약에 맞게 갱신(타입 무변경).
- tests: 기본=그룹 / 파라미터=전체 정렬 검증으로 분리(21 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:12:56 +09:00

176 lines
8.4 KiB
Python

from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import case, func, nulls_last, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items, quotations, sessions
from common.enums import ErrorType, SessionStatus
from common.logger import LOG
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
class ISessionCRUD(ABC):
@abstractmethod
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> 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) -> ErrorType:
pass
class SessionCRUD(ISessionCRUD):
@staticmethod
def __filters(supplier_id, status, qt_type):
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
if status is not None:
conds.append(sessions.status == status)
if qt_type is not None:
conds.append(sessions.qt_type == qt_type)
return conds
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
try:
conds = self.__filters(supplier_id, status, qt_type)
# 정렬 규칙:
# - 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 = sessions.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,
sessions.status,
sessions.qt_type,
sessions.qt_number,
quotations.end_time, # qt_end_time = 견적 마감 시각
items.code,
items.name,
items.model_name,
items.manufacturer,
)
.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) -> Tuple[ErrorType, int]:
try:
conds = self.__filters(supplier_id, status, qt_type)
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) -> ErrorType:
try:
query = (
update(sessions)
.where(sessions.session_id == session_id)
.values(status=status, reject_reason=reject_reason)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED