From 5519c6e73b568e288585102fbda740a64c91ac28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Wed, 8 Jul 2026 14:12:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(negotiation):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B8=B0=EB=B3=B8=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20+=20order=20=EC=A7=80=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 진입 시 "지금 할 일"이 먼저 보이도록 기본 정렬을 개선하고, 사용자가 마감일로 직접 훑어볼 수 있는 전체 정렬 모드를 추가한다. - 기본(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) --- backend/crud/session_crud.py | 27 ++++++++++++++++--- backend/router/v1/negotiation/session.py | 4 +-- backend/tests/test_negotiation.py | 22 +++++++++++---- .../src/apis/negotiation/negotiation.type.ts | 2 +- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index c602101..0e0c22e 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -1,12 +1,12 @@ from abc import ABC, abstractmethod 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 common.database.db_session_manager import DB_SESSION_MNG 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 @@ -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]: try: 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 = ( select( sessions.session_id, @@ -71,7 +90,7 @@ class SessionCRUD(ISessionCRUD): .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_col) + .order_by(*order_cols) .offset(offset) .limit(limit) ) diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 8918d02..86a6de3 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={40 path="/sessions", response_model=Res_SessionList, summary="협상 세션 목록", - description="로그인한 공급사의 협상 세션 목록. 필터(status/qt_type, 정수 코드)·마감일 정렬·페이지네이션 지원.", + description="로그인한 공급사의 협상 세션 목록. 필터(status/qt_type, 정수 코드)·페이지네이션 지원. 기본 정렬(order 미지정)은 '할 일(협상생성·협상중) 우선 + 마감 임박순', 종료(완료·미참여·거부)는 하단·최근순. order 를 주면 그룹 없이 전체 마감순으로 정렬.", ) async def list_sessions( user_info: UserInfo = Depends(IsValidAccessToken), @@ -23,7 +23,7 @@ async def list_sessions( service: NegotiationService = Depends(), status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"), 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_size: int = Query(20, ge=1, le=100, description="페이지당 건수 (1~100)"), ): diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index 4c5663c..fedde1e 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -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"} -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) - asc = (await _list(client, token, order="asc")).json()["items"] - desc = (await _list(client, token, order="desc")).json()["items"] - assert asc[0]["item_code"] == f"{MARK}B" # +1h 가 가장 임박 - assert desc[0]["item_code"] == f"{MARK}C" # +3h 가 가장 멈 + default = [i["item_code"] for i in (await _list(client, token)).json()["items"]] + # 액션 그룹 임박순(B +1h → A +2h) 뒤에 종료(C). C 는 마감이 가장 멀어도(+3h) 최하단 고정. + assert default == [f"{MARK}B", f"{MARK}A", f"{MARK}C"] + + +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): diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index 70ba859..5c9ca31 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -36,7 +36,7 @@ export const QT_TYPE_LABEL: Record = { export interface SessionListParams { status?: number // SessionStatus 코드 필터 qt_type?: number // QtType 코드 필터 - order?: 'asc' | 'desc' // 마감(qt_end_time) 정렬, asc=임박순 + order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유) page?: number page_size?: number }