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>
This commit is contained in:
민헌 2026-07-08 14:12:56 +09:00
parent c2760c804f
commit 5519c6e73b
4 changed files with 43 additions and 12 deletions

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from sqlalchemy import asc, desc, func, select, update from sqlalchemy import case, func, nulls_last, select, update
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 items, quotations, sessions from common.database.model.models import items, quotations, sessions
from common.enums import ErrorType from common.enums import ErrorType, SessionStatus
from common.logger import LOG from common.logger import LOG
@ -55,7 +55,26 @@ class SessionCRUD(ISessionCRUD):
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
try: try:
conds = self.__filters(supplier_id, status, qt_type) conds = self.__filters(supplier_id, status, qt_type)
order_col = desc(quotations.end_time) if order == "desc" else asc(quotations.end_time)
# 정렬 규칙:
# - 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 = ( query = (
select( select(
sessions.session_id, sessions.session_id,
@ -71,7 +90,7 @@ class SessionCRUD(ISessionCRUD):
.join(items, items.item_id == sessions.item_id) .join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id) .join(quotations, quotations.qt_id == sessions.quotation_id)
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712 .where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
.order_by(order_col) .order_by(*order_cols)
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
) )

View File

@ -15,7 +15,7 @@ router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={40
path="/sessions", path="/sessions",
response_model=Res_SessionList, response_model=Res_SessionList,
summary="협상 세션 목록", summary="협상 세션 목록",
description="로그인한 공급사의 협상 세션 목록. 필터(status/qt_type, 정수 코드)·마감일 정렬·페이지네이션 지원.", description="로그인한 공급사의 협상 세션 목록. 필터(status/qt_type, 정수 코드)·페이지네이션 지원. 기본 정렬(order 미지정)은 '할 일(협상생성·협상중) 우선 + 마감 임박순', 종료(완료·미참여·거부)는 하단·최근순. order 를 주면 그룹 없이 전체 마감순으로 정렬.",
) )
async def list_sessions( async def list_sessions(
user_info: UserInfo = Depends(IsValidAccessToken), user_info: UserInfo = Depends(IsValidAccessToken),
@ -23,7 +23,7 @@ async def list_sessions(
service: NegotiationService = Depends(), service: NegotiationService = Depends(),
status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"), status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"),
qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적)"), qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적)"),
order: str = Query("asc", description="마감일 정렬: asc(임박순)/desc"), order: Optional[str] = Query(None, description="마감일 전체 정렬: asc(임박순)/desc(여유순). 미지정 시 기본 그룹 정렬('할 일' 우선 → 종료는 하단·최근순). 지정하면 그룹 없이 전체를 마감 기준으로 정렬."),
page: int = Query(1, ge=1, description="페이지 (1부터)"), page: int = Query(1, ge=1, description="페이지 (1부터)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"), page_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"),
): ):

View File

@ -147,12 +147,24 @@ async def test_list_filter_qt_type(client, nego_seed):
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A", f"{MARK}C"} assert {i["item_code"] for i in body["items"]} == {f"{MARK}A", f"{MARK}C"}
async def test_list_order_by_quotation_end_time(client, nego_seed): async def test_list_default_sort_groups_actionable_first(client, nego_seed):
# 기본 정렬(order 미지정): '할 일'(협상생성 A·협상중 B) 우선 → 마감 임박순, 종료(협상완료 C)는 하단.
token = await _login_token(client) token = await _login_token(client)
asc = (await _list(client, token, order="asc")).json()["items"] default = [i["item_code"] for i in (await _list(client, token)).json()["items"]]
desc = (await _list(client, token, order="desc")).json()["items"] # 액션 그룹 임박순(B +1h → A +2h) 뒤에 종료(C). C 는 마감이 가장 멀어도(+3h) 최하단 고정.
assert asc[0]["item_code"] == f"{MARK}B" # +1h 가 가장 임박 assert default == [f"{MARK}B", f"{MARK}A", f"{MARK}C"]
assert desc[0]["item_code"] == f"{MARK}C" # +3h 가 가장 멈
async def test_list_order_param_switches_to_global_sort(client, nego_seed):
# order 를 명시하면 그룹을 무시하고 전체를 마감 기준 한 줄로 정렬한다.
token = await _login_token(client)
asc = [i["item_code"] for i in (await _list(client, token, order="asc")).json()["items"]]
desc = [i["item_code"] for i in (await _list(client, token, order="desc")).json()["items"]]
# asc: 전체 마감 임박순 (B +1h → A +2h → C +3h)
assert asc == [f"{MARK}B", f"{MARK}A", f"{MARK}C"]
# desc: 전체 마감 여유순 — 종료(C)라도 마감이 가장 멀면 최상단으로 올라온다(그룹 무시 증거).
assert desc == [f"{MARK}C", f"{MARK}A", f"{MARK}B"]
async def test_list_pagination(client, nego_seed): async def test_list_pagination(client, nego_seed):

View File

@ -36,7 +36,7 @@ export const QT_TYPE_LABEL: Record<QtType, string> = {
export interface SessionListParams { export interface SessionListParams {
status?: number // SessionStatus 코드 필터 status?: number // SessionStatus 코드 필터
qt_type?: number // QtType 코드 필터 qt_type?: number // QtType 코드 필터
order?: 'asc' | 'desc' // 마감(qt_end_time) 정렬, asc=임박순 order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유)
page?: number page?: number
page_size?: number page_size?: number
} }