From d7c4e86f30518767f257bf524bb7f2f94df8305a Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 24 Jul 2026 14:18:52 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=EA=B3=B5=EA=B8=89=EC=82=AC=20?= =?UTF-8?q?=ED=8F=AC=ED=84=B8:=20=ED=98=91=EC=83=81=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=B6=80=EA=B0=80=EC=A0=95=EB=B3=B4(select=C2=B7=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EB=8F=99=EC=9D=98=ED=8F=BC)=C2=B7=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=9D=BC=EB=B2=A8=20=ED=86=B5=EC=9D=BC=C2=B7?= =?UTF-8?q?=EC=98=88=EC=95=84=EB=8B=88=EC=98=A4=20Enter=3D=EC=98=88=C2=B7?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B2=B0=EA=B3=BC=EC=97=B4=C2=B7=EC=9E=AC?= =?UTF-8?q?=ED=98=91=EC=83=81=20=EC=9A=94=EC=B2=AD/=EC=B2=A0=ED=9A=8C=20AP?= =?UTF-8?q?I=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/common/database/model/models.py | 23 ++ backend/common/enums.py | 35 +++ backend/crud/session_crud.py | 89 ++++++- backend/router/v1/negotiation/protocol.py | 16 ++ backend/router/v1/negotiation/session.py | 50 +++- backend/services/negotiation_service.py | 238 ++++++++++++++++-- backend/tests/test_negotiation.py | 123 +++++++++ backend/tests/test_renegotiation.py | 215 ++++++++++++++++ backend/tests/test_session_result.py | 51 ++++ frontend/src/apis/auth/auth.type.ts | 3 +- frontend/src/apis/negotiation/index.ts | 8 +- .../src/apis/negotiation/negotiation.api.ts | 19 ++ .../apis/negotiation/negotiation.mutations.ts | 23 +- .../src/apis/negotiation/negotiation.type.ts | 36 +++ .../features/chat/components/ChatMessage.tsx | 12 +- .../features/chat/components/ExtraInfoBar.tsx | 114 +++++++++ .../chat/components/MobileStepBar.tsx | 2 +- .../chat/components/RemainingTime.tsx | 17 +- .../features/chat/components/UserButton.tsx | 23 ++ .../chat/components/menu/NegoStep.tsx | 9 +- .../components/templates/ExtraInfoForm.tsx | 109 -------- .../chat/components/templates/OtherReason.tsx | 9 + .../chat/components/templates/RejectCM.tsx | 1 + .../chat/components/templates/RejectRSP.tsx | 2 + frontend/src/features/chat/lib/negoSteps.ts | 8 + .../src/features/chat/lib/userButtonConfig.ts | 7 +- frontend/src/features/chat/types.ts | 1 + .../list/components/ExtraInfoPopup.tsx | 11 + .../features/list/components/GuidePopup.tsx | 92 +++++++ .../src/features/list/components/KpiCards.tsx | 93 ++++--- .../components/RenegotiationMemoPopup.tsx | 54 ++++ .../list/components/RenegotiationPopup.tsx | 122 +++++++++ .../features/list/components/StatusTabs.tsx | 6 +- .../list/components/WorkspaceCards.tsx | 62 ++++- .../list/components/WorkspaceTable.tsx | 74 ++++-- .../list/containers/ListWorkspace.tsx | 90 ++++++- frontend/src/features/list/hooks/useList.ts | 4 +- .../features/list/hooks/useSessionCounts.ts | 62 +++-- frontend/src/features/list/lib/adapter.ts | 4 + frontend/src/features/list/lib/status.ts | 18 +- .../src/features/list/stores/useListStore.ts | 16 +- frontend/src/features/list/types.ts | 4 + frontend/src/index.css | 11 + frontend/src/layouts/MainHeaderBar.tsx | 6 +- frontend/src/layouts/PortalHeader.tsx | 4 +- frontend/src/pages/ChatPage.tsx | 9 +- 46 files changed, 1715 insertions(+), 270 deletions(-) create mode 100644 backend/tests/test_renegotiation.py create mode 100644 backend/tests/test_session_result.py create mode 100644 frontend/src/features/chat/components/ExtraInfoBar.tsx delete mode 100644 frontend/src/features/chat/components/templates/ExtraInfoForm.tsx create mode 100644 frontend/src/features/chat/lib/negoSteps.ts create mode 100644 frontend/src/features/list/components/GuidePopup.tsx create mode 100644 frontend/src/features/list/components/RenegotiationMemoPopup.tsx create mode 100644 frontend/src/features/list/components/RenegotiationPopup.tsx diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index ccf03f9..4813c6f 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -173,6 +173,7 @@ class quotations(MAIN_BASE): preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷) + close_reason = Column(SmallInteger, nullable=True) # 마감 사유(CloseReason). 재협상 요청 자격 판정에 읽는다 equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부 equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) @@ -180,6 +181,28 @@ class quotations(MAIN_BASE): deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 +class notifications(MAIN_BASE): + # company.notifications (담당자 인박스). 포털은 재협상 요청 알림을 만들기 위해서만 쓴다(조회는 negodata). + # company 스키마 전용 DBType 이 없어 USER 커넥션을 재사용한다(물리 DB 동일). + @staticmethod + def DBType(): + return DBType.USER.value + + __tablename__ = "notifications" + __table_args__ = {"schema": "company"} + + notification_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) + user_id = Column(UUID(as_uuid=True), nullable=False) # 수신자(company.users.user_id) = 견적 작성자 + type = Column(SmallInteger, nullable=False) # NotificationType + ref_qt_id = Column(UUID(as_uuid=True), nullable=True) + ref_session_id = Column(UUID(as_uuid=True), nullable=True) + data = Column(JSONB, nullable=True) # 렌더 스냅샷(공급사명·사유·희망가 등) + read_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) + deleted = Column(Boolean, nullable=False, server_default=text("false")) + + class quotation_settings(MAIN_BASE): # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값). @staticmethod diff --git a/backend/common/enums.py b/backend/common/enums.py index cd241d2..c591e05 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -130,6 +130,41 @@ class QuotationStatus(Enum): CLOSED = 3 # 견적마감 +class CloseReason(Enum): + """견적 마감 사유. quotation.quotations.close_reason + 낙찰(AWARDED) 외 OPEN_* 는 낙찰자 미정으로 마감된 '결렬' 건 — 공급사 재협상 요청 대상.""" + + AWARDED = 1 # 낙찰 + OPEN_PRICE = 5 # 개찰: 낙찰 기준 미달 + OPEN_EQUAL = 6 # 개찰: 동가 + OPEN_NOSHOW = 7 # 개찰: 전원 미응찰 + OPEN_REJECT = 8 # 개찰: 협상거부 존재 + + +# 재협상 요청 가능한 마감 사유(낙찰 건은 제외). +RENEGOTIABLE_CLOSE_REASONS = ( + CloseReason.OPEN_PRICE.value, + CloseReason.OPEN_EQUAL.value, + CloseReason.OPEN_NOSHOW.value, + CloseReason.OPEN_REJECT.value, +) + + +class RenegotiationStatus(Enum): + """sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15).""" + + PENDING = 1 # 접수, 담당자 심사 대기 + APPROVED = 2 # 승인 — 다음 라운드 생성됨 + REJECTED = 3 # 반려 + CANCELED = 4 # 공급사 철회 + + +class NotificationType(Enum): + """company.notifications.type — negodata 담당자 인박스. 포털에서 만드는 건 재협상 요청뿐.""" + + RENEGO_REQUESTED = 5 + + class ChatSender(Enum): """채팅 발신자 코드. negotiation.chats.sender """ diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 9059ca0..4db5ec9 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -1,12 +1,13 @@ from abc import ABC, abstractmethod from typing import Tuple -from sqlalchemy import case, func, nulls_last, select, update +from sqlalchemy import 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 items, quotations, sessions -from common.enums import ErrorType, SessionStatus +from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus from common.logger import LOG @@ -14,11 +15,11 @@ from common.logger import LOG # 마감일(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]: + 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) -> Tuple[ErrorType, int]: + async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, keyword=None, result=None) -> Tuple[ErrorType, int]: pass @abstractmethod @@ -45,20 +46,59 @@ class ISessionCRUD(ABC): async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType: pass + @abstractmethod + 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 + top = rows[0][0] if rows and rows[0] 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: + 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): + 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: conds.append(sessions.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) -> Tuple[ErrorType, list]: + 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) + conds = self.__filters(supplier_id, status, qt_type, keyword, result) # 정렬 규칙: # - order 를 명시(asc/desc)하면 그룹 구분 없이 전체를 마감 기준 한 줄로 정렬(전체 정렬). @@ -91,6 +131,11 @@ class SessionCRUD(ISessionCRUD): items.model_name, items.manufacturer, sessions.custom, + quotations.status, # 재협상 요청 자격 판정용(마감 여부) + quotations.close_reason, # 개찰(결렬) 사유 + quotations.round, + quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰 + sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조 ) .join(items, items.item_id == sessions.item_id) .join(quotations, quotations.qt_id == sessions.quotation_id) @@ -107,9 +152,9 @@ class SessionCRUD(ISessionCRUD): 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]: + 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) + conds = self.__filters(supplier_id, status, qt_type, keyword, result) query = ( select(func.count()) .select_from(sessions) @@ -179,6 +224,32 @@ class SessionCRUD(ISessionCRUD): 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 + top = rows[0][0] if rows and rows[0] 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: diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index e1dba6e..df44f35 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import Field from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -15,6 +17,10 @@ class ListItem(WebPacketProtocol): model_name: str = Field("", description="모델명") maker_name: str = Field("", description="제조사") custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict") + renegotiable: bool = Field(False, description="재협상 요청 가능 여부 — 낙찰 없이 마감(개찰)된 마지막 차수이고 대기 중 요청이 없을 때만 True") + renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0") + renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열") + result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)") class Res_SessionList(Res_WebPacketProtocol): @@ -42,3 +48,13 @@ class Req_ExtraInfo(WebPacketProtocol): class Res_ExtraInfo(Res_WebPacketProtocol): session_id: str = Field("", description="부가정보 저장된 세션 uuid") + + +class Req_Renegotiation(WebPacketProtocol): + reason: str = Field("", max_length=255, description="재협상 요청 사유(프리셋 라벨 또는 직접 입력)") + desired_price: Optional[int] = Field(None, description="희망 공급가(원). 담당자 판단 근거로만 쓰인다") + + +class Res_Renegotiation(Res_WebPacketProtocol): + session_id: str = Field("", description="요청이 기록된 세션 uuid") + status: int = Field(0, description="요청 상태(RenegotiationStatus): 1=심사중 2=승인 3=반려 4=철회") diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 323d85d..82ec668 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -6,7 +6,16 @@ from fastapi.security import HTTPAuthorizationCredentials from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security from services.negotiation_service import NegotiationService -from .protocol import Req_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList +from .protocol import ( + Req_ExtraInfo, + Req_Reject, + Req_Renegotiation, + Res_ExtraInfo, + Res_Participate, + Res_Reject, + Res_Renegotiation, + Res_SessionList, +) router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}}) @@ -26,9 +35,11 @@ async def list_sessions( 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)"), + keyword: Optional[str] = Query(None, description="검색어 — 견적번호·상품명·상품코드 부분일치(대소문자 무시)"), + result: Optional[int] = Query(None, description="결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬(개찰). 미지정 시 전체"), ): return RemoveNoneResponse( - await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size) + await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size, keyword, result) ) @@ -77,3 +88,38 @@ async def save_extra_info( service: NegotiationService = Depends(), ): return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req)) + + +@router.post( + path="/session/{session_id}/renegotiation", + response_model=Res_Renegotiation, + summary="재협상 요청", + description="낙찰 없이 마감된(개찰) 건에 대해 공급사가 재협상을 요청한다. 담당자 승인 시 다음 라운드가 생성된다. 본인 공급사의 마지막 라운드 세션만 허용.", +) +async def request_renegotiation( + req: Req_Renegotiation, + session_id: str = Path(..., description="협상 세션 uuid"), + user_info: UserInfo = Depends(IsValidAccessToken), + credentials: HTTPAuthorizationCredentials = Depends(security), + service: NegotiationService = Depends(), +): + return RemoveNoneResponse( + await service.request_renegotiation(user_info, credentials.credentials, session_id, req) + ) + + +@router.delete( + path="/session/{session_id}/renegotiation", + response_model=Res_Renegotiation, + summary="재협상 요청 철회", + description="심사 대기(PENDING) 중인 본인 요청을 철회한다.", +) +async def cancel_renegotiation( + session_id: str = Path(..., description="협상 세션 uuid"), + user_info: UserInfo = Depends(IsValidAccessToken), + credentials: HTTPAuthorizationCredentials = Depends(security), + service: NegotiationService = Depends(), +): + return RemoveNoneResponse( + await service.cancel_renegotiation(user_info, credentials.credentials, session_id) + ) diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index f86bc3f..2ae6e74 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -4,12 +4,31 @@ from datetime import datetime, timezone from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import chats, sessions -from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus +from common.database.model.models import chats, notifications, sessions +from common.enums import ( + CloseReason, + DBWRType, + ErrorType, + NotificationType, + QuotationStatus, + RENEGOTIABLE_CLOSE_REASONS, + RenegotiationStatus, + SessionStatus, +) +from common.logger import LOG from common.models.gmodel import UserInfo from crud.chat_crud import ChatCRUD, IChatCRUD from crud.session_crud import ISessionCRUD, SessionCRUD -from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList +from router.v1.negotiation.protocol import ( + ListItem, + Req_ExtraInfo, + Req_Renegotiation, + Res_ExtraInfo, + Res_Participate, + Res_Reject, + Res_Renegotiation, + Res_SessionList, +) from services.auth_service import AuthService @@ -32,7 +51,7 @@ class NegotiationService: self.session_crud = session_crud self.chat_crud = chat_crud - async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList: + async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int, keyword: str = None, result: int = None) -> Res_SessionList: res = Res_SessionList() # 1) 인증 (활성 + 저장된 access 토큰 대조) @@ -48,7 +67,7 @@ class NegotiationService: err_type, rows = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, - lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size), + lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size, keyword, result), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -58,27 +77,22 @@ class NegotiationService: err_type, total = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, - lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type), + lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type, keyword, result), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - res.items = [ - ListItem( - session_id=str(r[0]), - session_status=r[1], - qt_type=r[2], - qt_number=r[3], - qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "", - item_code=r[5] or "", - item_name=r[6] or "", - model_name=r[7] or "", - maker_name=r[8] or "", - custom=r[9] or {}, + # 같은 견적번호(체인)의 최대 차수 — 이미 다음 라운드가 있으면 재협상 요청 대상이 아니다. + max_rounds: dict = {} + for number in {r[3] for r in rows if r[3]}: + _e, mx = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), DBWRType.DB_READ.value, + lambda s, n=number: self.session_crud.chain_max_round(s, n), ) - for r in rows - ] + max_rounds[number] = mx or 0 + + res.items = [self._to_list_item(r, max_rounds) for r in rows] res.total = total res.page = page res.page_size = page_size @@ -134,6 +148,190 @@ class NegotiationService: res.session_id = str(session_id) return res + @staticmethod + def _to_list_item(r, max_rounds: dict) -> ListItem: + """세션 행 → 목록 아이템. 재협상 요청 가능 여부는 서버가 판정해 내려준다(프론트가 규칙을 몰라도 되게).""" + custom = r[9] or {} + renego = custom.get("renegotiation") or {} + status = renego.get("status") or 0 + + is_last_round = (r[12] or 0) >= max_rounds.get(r[3], 0) + renegotiable = ( + r[10] == QuotationStatus.CLOSED.value + and r[11] in RENEGOTIABLE_CLOSE_REASONS + and is_last_round + and status + not in ( + RenegotiationStatus.PENDING.value, + RenegotiationStatus.APPROVED.value, + RenegotiationStatus.REJECTED.value, + ) + ) + return ListItem( + session_id=str(r[0]), + session_status=r[1], + qt_type=r[2], + qt_number=r[3], + qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "", + item_code=r[5] or "", + item_name=r[6] or "", + model_name=r[7] or "", + maker_name=r[8] or "", + custom=custom, + renegotiable=renegotiable, + renegotiation_status=status, + renegotiation_memo=renego.get("memo") or "", + result=NegotiationService._to_result(r[10], r[11], r[13], r[14]), + ) + + @staticmethod + def _to_result(qt_status, close_reason, winner_id, my_id) -> int: + """공급사 관점 협상 결과(SessionResult). 견적 마감 전이면 0(미정). + 낙찰 건은 낙찰자가 나면 1(낙찰)·아니면 2(미낙찰), 개찰(OPEN_*) 마감은 3(결렬=재협상 대상).""" + if qt_status != QuotationStatus.CLOSED.value: + return 0 + if close_reason == CloseReason.AWARDED.value: + return 1 if winner_id is not None and str(winner_id) == str(my_id) else 2 + if close_reason in RENEGOTIABLE_CLOSE_REASONS: + return 3 + return 0 + + async def request_renegotiation( + self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_Renegotiation + ) -> Res_Renegotiation: + """결렬(개찰) 마감 건에 대해 공급사가 재협상을 요청한다(IMK #15). + 전용 테이블 없이 sessions.custom.renegotiation 에 기록하고, 견적 작성자에게 알림을 남긴다.""" + res = Res_Renegotiation() + + err_type, info, sess, quote = await self._load_renegotiable(user_info, access_token, session_id_str) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 심사 대기·승인·반려 건은 재요청을 막는다(전용 테이블이 없어 유니크 대신 여기서 검증). + # 반려는 담당자가 이미 판단한 결과라 같은 건으로 다시 올릴 수 없다. 철회(CANCELED)만 재요청 허용. + current = (sess.custom or {}).get("renegotiation") or {} + if current.get("status") in ( + RenegotiationStatus.PENDING.value, + RenegotiationStatus.APPROVED.value, + RenegotiationStatus.REJECTED.value, + ): + res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) + return res + + payload = { + "status": RenegotiationStatus.PENDING.value, + "reason": (req.reason or "").strip(), + "desired_price": req.desired_price, + "requested_at": datetime.now(timezone.utc).isoformat(), + } + err_type = await DB_SESSION_MNG.execute_lambda_run( + [sessions.DBType()], + [lambda s: self.session_crud.merge_session_custom( + s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": payload} + )], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + await self._notify_renegotiation(quote, sess, info, payload) + res.session_id = str(sess.session_id) + res.status = RenegotiationStatus.PENDING.value + return res + + async def cancel_renegotiation(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Renegotiation: + """공급사가 자기 요청을 철회한다. 심사 대기(PENDING) 중에만 가능.""" + res = Res_Renegotiation() + + err_type, info, sess, _quote = await self._load_renegotiable(user_info, access_token, session_id_str) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + current = (sess.custom or {}).get("renegotiation") or {} + if current.get("status") != RenegotiationStatus.PENDING.value: + res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) + return res + + patch = {**current, "status": RenegotiationStatus.CANCELED.value} + err_type = await DB_SESSION_MNG.execute_lambda_run( + [sessions.DBType()], + [lambda s: self.session_crud.merge_session_custom( + s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": patch} + )], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + res.session_id = str(sess.session_id) + res.status = RenegotiationStatus.CANCELED.value + return res + + async def _load_renegotiable(self, user_info: UserInfo, access_token: str, session_id_str: str): + """재협상 요청 자격 검증 — 인증 → 본인 세션 → 결렬(개찰) 마감 → 마지막 라운드. + 성공 시 (SUCCESS, info, sess, quote).""" + err_type, info = await self.auth.authenticate(user_info, access_token) + if err_type != ErrorType.SUCCESS: + return err_type, None, None, None + try: + session_id = uuid.UUID(session_id_str) + except (ValueError, TypeError): + return ErrorType.NEGO_NOT_FOUND, None, None, None + + err_type, sess = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), DBWRType.DB_READ.value, + lambda s: self.session_crud.get_session_by_id(s, session_id), + ) + if err_type != ErrorType.SUCCESS or sess is None: + return ErrorType.NEGO_NOT_FOUND, None, None, None + if str(sess.supplier_id) != info.supplier_id: + return ErrorType.NEGO_FORBIDDEN, None, None, None + + err_type, quote = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), DBWRType.DB_READ.value, + lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id), + ) + if err_type != ErrorType.SUCCESS or quote is None: + return ErrorType.NEGO_NOT_FOUND, None, None, None + + # 낙찰됐거나 아직 진행 중인 건은 요청 대상이 아니다. + if quote.status != QuotationStatus.CLOSED.value or quote.close_reason not in RENEGOTIABLE_CLOSE_REASONS: + return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None + + # 이미 다음 라운드가 만들어졌으면 요청할 이유가 없다. + _e, max_round = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), DBWRType.DB_READ.value, + lambda s: self.session_crud.chain_max_round(s, quote.number), + ) + if max_round and quote.round < max_round: + return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None + + return ErrorType.SUCCESS, info, sess, quote + + async def _notify_renegotiation(self, quote, sess, info, payload: dict) -> None: + """견적 작성자 인박스에 재협상 요청 알림을 남긴다. 부가 효과라 실패해도 본 흐름을 막지 않는다.""" + notif = notifications( + user_id=quote.user_id, + type=NotificationType.RENEGO_REQUESTED.value, + ref_qt_id=quote.qt_id, + ref_session_id=sess.session_id, + data={ + "supplier_name": info.supplier_name, + "qt_number": quote.number, + "qt_round": quote.round, + "reason": payload.get("reason"), + "desired_price": payload.get("desired_price"), + }, + ) + err = await DB_SESSION_MNG.execute_lambda_run( + [notifications.DBType()], + [lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)], + ) + if err != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[renego] 알림 기록 실패 qt={quote.qt_id} session={sess.session_id}") + async def _is_after_summary(self, session_id) -> bool: """마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지.""" err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda( diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index fedde1e..770f069 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -177,6 +177,56 @@ async def test_list_requires_auth(client): assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403) +# ---- 검색(keyword) ---------------------------------------------------------- +async def test_search_by_qt_number_and_item_code(client, nego_seed): + """검증: 견적번호/상품코드가 같은 값(PYTESTNEGO-B)으로 검색. + 기대결과: B 1건만, total 도 1(카운트도 같은 필터 적용).""" + token = await _login_token(client) + body = (await _list(client, token, keyword=f"{MARK}B")).json() + assert body["total"] == 1 + assert [i["item_code"] for i in body["items"]] == [f"{MARK}B"] + + +async def test_search_by_item_name(client, nego_seed): + """검증: 상품명 일부('상품 A')로 검색. + 기대결과: A 1건만.""" + token = await _login_token(client) + body = (await _list(client, token, keyword="상품 A")).json() + assert {i["item_code"] for i in body["items"]} == {f"{MARK}A"} + + +async def test_search_prefix_matches_all_own(client, nego_seed): + """검증: 공통 prefix(PYTESTNEGO)로 검색. + 기대결과: 본인 공급사 3건 전부(타 공급사 X 는 제외 유지).""" + token = await _login_token(client) + body = (await _list(client, token, keyword=MARK.rstrip("-"))).json() + assert body["total"] == 3 + + +async def test_search_case_insensitive(client, nego_seed): + """검증: 소문자로 검색(pytestnego-c). + 기대결과: ILIKE 라 대소문자 무시하고 C 매칭.""" + token = await _login_token(client) + body = (await _list(client, token, keyword=f"{MARK}c".lower())).json() + assert {i["item_code"] for i in body["items"]} == {f"{MARK}C"} + + +async def test_search_no_match_returns_empty(client, nego_seed): + """검증: 어디에도 없는 검색어. + 기대결과: 0건, total 0.""" + token = await _login_token(client) + body = (await _list(client, token, keyword="존재하지않는검색어zzz")).json() + assert body["total"] == 0 and body["items"] == [] + + +async def test_search_wildcard_is_escaped(client, nego_seed): + """검증: ILIKE 와일드카드('%')를 그대로 검색 — 패턴으로 새면 전건 매칭될 위험. + 기대결과: escape 되어 리터럴 '%' 로 취급 → 매칭 0건.""" + token = await _login_token(client) + body = (await _list(client, token, keyword="%")).json() + assert body["total"] == 0 + + # ---- 참여 ------------------------------------------------------------------- async def test_participate_success(client, nego_seed, db_engine): token = await _login_token(client) @@ -294,3 +344,76 @@ async def test_reject_requires_auth(client, nego_seed): sid = nego_seed["sids"]["B"] r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"}) assert r.status_code in (401, 403) + + +# ---- 결과 필터(result) ------------------------------------------------------ +# 마감(CLOSED) + 마감사유/낙찰자로 낙찰(1)·미낙찰(2)·결렬(3)을 만들고 result= 로 거른다. +# nego_seed 의 공급사/로그인을 재사용하고, MARK prefix 라 픽스처 teardown 이 함께 정리한다. +async def _seed_result_row(engine, *, supplier_id, code, close_reason, winner_id): + import uuid as _uuid + item_id, qt_id, session_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) " + "VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 'M', '제조사')"), + {"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}"}, + ) + await conn.execute( + text("INSERT INTO quotation.quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, " + " preferred_sp_id, round, start_time, end_time) VALUES " + "(:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 2, 3, :cr, " + " :win, 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"), + {"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "cr": close_reason, "win": winner_id}, + ) + await conn.execute( + text("INSERT INTO negotiation.sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:sid, :qid, :iid, :sup, :num, 1, 2, 100000, 3, 95000, now() - make_interval(hours => 1))"), + {"sid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id, "num": f"{MARK}{code}"}, + ) + + +@pytest_asyncio.fixture +async def result_rows(nego_seed, db_engine): + """nego_seed 공급사에 낙찰/미낙찰/결렬 각 1건을 추가한다(개찰 5=OPEN_PRICE, 1=AWARDED).""" + sup = nego_seed["supplier_id"] + await _seed_result_row(db_engine, supplier_id=sup, code="RWON", close_reason=1, winner_id=sup) # 낙찰(나) + await _seed_result_row(db_engine, supplier_id=sup, code="RLOST", close_reason=1, winner_id=uuid.uuid4()) # 미낙찰(남) + await _seed_result_row(db_engine, supplier_id=sup, code="ROPEN", close_reason=5, winner_id=None) # 결렬(개찰) + return nego_seed + + +async def test_result_filter_won(client, result_rows): + """검증: result=1(낙찰)로 필터. 기대결과: 낙찰 건만, total=1.""" + token = await _login_token(client) + body = (await _list(client, token, result=1)).json() + assert body["total"] == 1 + assert body["items"][0]["item_code"] == f"{MARK}RWON" + assert body["items"][0]["result"] == 1 + + +async def test_result_filter_lost(client, result_rows): + """검증: result=2(미낙찰)로 필터. 기대결과: 미낙찰 건만.""" + token = await _login_token(client) + body = (await _list(client, token, result=2)).json() + assert {i["item_code"] for i in body["items"]} == {f"{MARK}RLOST"} + assert body["items"][0]["result"] == 2 + + +async def test_result_filter_open(client, result_rows): + """검증: result=3(결렬)로 필터. 기대결과: 개찰 결렬 건만 + 재협상 대상(renegotiable=True).""" + token = await _login_token(client) + body = (await _list(client, token, result=3)).json() + assert {i["item_code"] for i in body["items"]} == {f"{MARK}ROPEN"} + assert body["items"][0]["result"] == 3 + assert body["items"][0]["renegotiable"] is True + + +async def test_result_filter_composes_with_paging(client, result_rows): + """검증: 결과 필터가 total(페이징)에 반영. 기대결과: result=1 이면 total=1(전체 목록과 별개).""" + token = await _login_token(client) + all_total = (await _list(client, token)).json()["total"] + won_total = (await _list(client, token, result=1)).json()["total"] + assert won_total == 1 and all_total > won_total diff --git a/backend/tests/test_renegotiation.py b/backend/tests/test_renegotiation.py new file mode 100644 index 0000000..c1127df --- /dev/null +++ b/backend/tests/test_renegotiation.py @@ -0,0 +1,215 @@ +"""공급사 재협상 요청/철회(IMK #15) 포털 e2e — 요청 접수 + 철회. + +담당자 심사(승인/반려)는 negodata 백엔드 몫이고, 여기(포털)는 공급사가 +sessions.custom.renegotiation 에 요청을 남기고(PENDING) 스스로 철회(CANCELED)하는 절반을 본다: + · 개찰(OPEN_*) 마감 + 본인 마지막 라운드 세션 → 요청 기록(PENDING) + 담당자 알림 + · 낙찰(AWARDED) 건 → 요청 거부 + · 남의 공급사 세션 → 거부(FORBIDDEN) + · 이미 대기 중인데 재요청 → 거부(중복 방지) + · 대기 중 철회 → CANCELED, 이후 재요청 허용 + +dev negosium_db 를 그대로 쓰므로(APP_ENV=local) 전용 테스트 행만 시드하고 끝나면 지운다. +""" +import uuid + +import bcrypt +import pytest_asyncio +from sqlalchemy import text + +from common.enums import CloseReason, QuotationStatus, RenegotiationStatus, SessionStatus + +TEST_LOGIN_ID = "pytest_renego_user" +TEST_PW = "pytest1234" +TEST_SUPPLIER_NAME = "파이테스트재협상공급사" +MARK = "PYTESTRENEGO-" # 시드 식별용 prefix (item code / qt number) + + +@pytest_asyncio.fixture +async def renego_seed(db_engine): + """공급사 + 로그인유저 + 재협상 후보 세션들을 시드하고 (supplier_id, sids, uids) 반환. + + (code, quotation.status, close_reason, 소속 공급사) — 요청 자격은 견적 마감사유·소유로 갈린다. + """ + supplier_id = uuid.uuid4() + other_supplier_id = uuid.uuid4() + pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + specs = [ + ("OPEN", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, supplier_id), # 개찰 → 요청 가능 + ("AWARD", QuotationStatus.CLOSED.value, CloseReason.AWARDED.value, supplier_id), # 낙찰 → 불가 + ("OTHER", QuotationStatus.CLOSED.value, CloseReason.OPEN_PRICE.value, other_supplier_id), # 남의 공급사 + ] + sids, uids = {}, {} + + async def _cleanup(conn): + await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'")) + await conn.execute(text(f"DELETE FROM company.notifications WHERE ref_qt_id IN " + f"(SELECT qt_id FROM quotation.quotations WHERE number LIKE '{MARK}%')")) + await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'")) + await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'")) + await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID}) + await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME}) + + async with db_engine.begin() as conn: + await _cleanup(conn) + await conn.execute( + text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) " + "VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"), + {"sid": supplier_id, "name": TEST_SUPPLIER_NAME}, + ) + await conn.execute( + text("INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) " + "VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"), + {"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash}, + ) + for code, quote_st, close_reason, sup in specs: + item_id, qt_id, session_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + sids[code], uids[code] = session_id, user_id + await conn.execute( + text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) " + "VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"), + {"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"}, + ) + await conn.execute( + text("INSERT INTO quotation.quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, " + " round, start_time, end_time) VALUES " + "(:qid, :uid, gen_random_uuid(), gen_random_uuid(), :name, :num, 2, :st, :cr, " + " 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"), + {"qid": qt_id, "uid": user_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "st": quote_st, "cr": close_reason}, + ) + await conn.execute( + text("INSERT INTO negotiation.sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:sesid, :qid, :iid, :sup, :qtn, 1, 2, 100000, :sst, 95000, now() - make_interval(hours => 1))"), + {"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "sst": SessionStatus.DONE.value}, + ) + + yield {"supplier_id": supplier_id, "sids": sids, "uids": uids} + + async with db_engine.begin() as conn: + await _cleanup(conn) + + +async def _login_token(client): + r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW}) + return r.json()["access_token"] + + +async def _request(client, token, session_id, *, reason="가격 재검토", desired_price=90000): + return await client.post( + f"/v1/negotiation/session/{session_id}/renegotiation", + headers={"Authorization": f"Bearer {token}"}, + json={"reason": reason, "desired_price": desired_price}, + ) + + +async def _cancel(client, token, session_id): + return await client.delete( + f"/v1/negotiation/session/{session_id}/renegotiation", + headers={"Authorization": f"Bearer {token}"}, + ) + + +async def _renego(db_engine, session_id): + async with db_engine.begin() as conn: + row = (await conn.execute( + text("SELECT custom FROM negotiation.sessions WHERE session_id = :sid"), + {"sid": session_id}, + )).scalar() + return (row or {}).get("renegotiation") or {} + + +async def _notif_count(db_engine, qt_number): + async with db_engine.begin() as conn: + return (await conn.execute( + text("SELECT count(*) FROM company.notifications WHERE ref_qt_id IN " + "(SELECT qt_id FROM quotation.quotations WHERE number = :num)"), + {"num": qt_number}, + )).scalar() + + +# ---- 요청 ------------------------------------------------------------------- +async def test_request_records_pending(client, renego_seed, db_engine): + """검증: 개찰(OPEN_PRICE) 마감 + 본인 마지막 라운드 세션에 재협상 요청. + 기대결과: success + PENDING 기록(사유·희망가 저장) + 담당자 알림 1건.""" + token = await _login_token(client) + sid = renego_seed["sids"]["OPEN"] + + body = (await _request(client, token, sid, reason="원자재 인상 반영", desired_price=88000)).json() + + assert body["result"]["success"] is True + assert body["status"] == RenegotiationStatus.PENDING.value + saved = await _renego(db_engine, sid) + assert saved["status"] == RenegotiationStatus.PENDING.value + assert saved["reason"] == "원자재 인상 반영" + assert saved["desired_price"] == 88000 + assert await _notif_count(db_engine, f"{MARK}OPEN") == 1 + + +async def test_request_twice_blocked(client, renego_seed, db_engine): + """검증: 이미 대기(PENDING) 요청이 있는 세션에 다시 요청. + 기대결과: 2번째는 거부(중복 방지) + 상태는 여전히 PENDING 1건.""" + token = await _login_token(client) + sid = renego_seed["sids"]["OPEN"] + + first = (await _request(client, token, sid)).json() + second = (await _request(client, token, sid)).json() + + assert first["result"]["success"] is True + assert second["result"]["success"] is False + assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value + + +async def test_request_blocked_on_awarded(client, renego_seed, db_engine): + """검증: 낙찰(AWARDED)로 마감된 건에 재협상 요청. + 기대결과: 거부(낙찰 건은 재협상 불가) + custom.renegotiation 미기록.""" + token = await _login_token(client) + sid = renego_seed["sids"]["AWARD"] + + body = (await _request(client, token, sid)).json() + + assert body["result"]["success"] is False + assert await _renego(db_engine, sid) == {} + + +async def test_request_forbidden_other_supplier(client, renego_seed, db_engine): + """검증: 다른 공급사 소유 세션에 재협상 요청. + 기대결과: 거부 + custom.renegotiation 미기록(소유 가드).""" + token = await _login_token(client) + sid = renego_seed["sids"]["OTHER"] + + body = (await _request(client, token, sid)).json() + + assert body["result"]["success"] is False + assert await _renego(db_engine, sid) == {} + + +# ---- 철회 ------------------------------------------------------------------- +async def test_cancel_sets_canceled_and_allows_rerequest(client, renego_seed, db_engine): + """검증: 대기 중 요청을 철회한 뒤 다시 요청. + 기대결과: 철회 시 CANCELED → 재요청 시 다시 PENDING(철회 건은 재요청 허용).""" + token = await _login_token(client) + sid = renego_seed["sids"]["OPEN"] + + await _request(client, token, sid) + cancelled = (await _cancel(client, token, sid)).json() + assert cancelled["result"]["success"] is True + assert cancelled["status"] == RenegotiationStatus.CANCELED.value + assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.CANCELED.value + + again = (await _request(client, token, sid)).json() + assert again["result"]["success"] is True + assert (await _renego(db_engine, sid))["status"] == RenegotiationStatus.PENDING.value + + +async def test_cancel_requires_pending(client, renego_seed, db_engine): + """검증: 대기 요청이 없는 세션에 철회 시도. + 기대결과: 거부(철회할 대기 요청 없음).""" + token = await _login_token(client) + sid = renego_seed["sids"]["OPEN"] + + body = (await _cancel(client, token, sid)).json() + + assert body["result"]["success"] is False diff --git a/backend/tests/test_session_result.py b/backend/tests/test_session_result.py new file mode 100644 index 0000000..486cc4a --- /dev/null +++ b/backend/tests/test_session_result.py @@ -0,0 +1,51 @@ +"""공급사 관점 협상 결과 파생(SessionResult) 단위 테스트. + +목록의 result 코드는 견적 마감상태·마감사유·낙찰자로 파생한다(DDL 무변경). 공급사가 이 배지로 +'내가 낙찰인지 / 결렬이라 재협상 요청 대상인지'를 구분한다. 결렬(3)만 renegotiable 과 짝을 이룬다. +""" +import uuid + +from common.enums import CloseReason, QuotationStatus +from services.negotiation_service import NegotiationService + +_R = NegotiationService._to_result +ME = uuid.uuid4() +OTHER = uuid.uuid4() +CLOSED = QuotationStatus.CLOSED.value + + +def test_result_undecided_before_close(): + """검증: 견적이 아직 마감 전(진행중)이면 결과 미정. + 기대결과: 0(미정).""" + assert _R(QuotationStatus.IN_PROGRESS.value, None, None, ME) == 0 + + +def test_result_won_when_winner_is_me(): + """검증: 낙찰(AWARDED) 마감 + 낙찰자가 나. + 기대결과: 1(낙찰).""" + assert _R(CLOSED, CloseReason.AWARDED.value, ME, ME) == 1 + + +def test_result_lost_when_winner_is_other(): + """검증: 낙찰 마감이지만 낙찰자가 남. + 기대결과: 2(미낙찰).""" + assert _R(CLOSED, CloseReason.AWARDED.value, OTHER, ME) == 2 + + +def test_result_lost_when_awarded_without_winner_id(): + """검증: 낙찰인데 낙찰자 id 가 비어 나와 대조 불가. + 기대결과: 2(미낙찰) — 낙찰이라 단정 못 하면 낙찰로 오인시키지 않는다.""" + assert _R(CLOSED, CloseReason.AWARDED.value, None, ME) == 2 + + +def test_result_open_is_renegotiable(): + """검증: 개찰(OPEN_*) 4종으로 마감(낙찰자 미정=결렬). + 기대결과: 전부 3(결렬) — 재협상 요청 대상.""" + for cr in (CloseReason.OPEN_PRICE, CloseReason.OPEN_EQUAL, CloseReason.OPEN_NOSHOW, CloseReason.OPEN_REJECT): + assert _R(CLOSED, cr.value, None, ME) == 3, cr + + +def test_result_none_when_closed_without_reason(): + """검증: 마감됐지만 close_reason 이 아직 없음(경계). + 기대결과: 0(미정) — 낙찰/결렬 어느 쪽도 아님.""" + assert _R(CLOSED, None, None, ME) == 0 diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts index b562e66..b77cf17 100644 --- a/frontend/src/apis/auth/auth.type.ts +++ b/frontend/src/apis/auth/auth.type.ts @@ -73,7 +73,8 @@ export interface SessionBrandingResponse { export interface SessionField { key: string label: string - type: 'text' | 'number' | 'boolean' + type: 'text' | 'number' | 'boolean' | 'select' + options?: string[] // type='select' 일 때 고를 보기 목록 } export interface MeResponse { diff --git a/frontend/src/apis/negotiation/index.ts b/frontend/src/apis/negotiation/index.ts index 49deade..931c3ba 100644 --- a/frontend/src/apis/negotiation/index.ts +++ b/frontend/src/apis/negotiation/index.ts @@ -2,5 +2,11 @@ export { negotiationApi } from './negotiation.api' export { negotiationKeys } from './negotiation.keys' export { useSessionListQuery } from './negotiation.queries' -export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations' +export { + useCancelRenegotiationMutation, + useParticipateMutation, + useRejectMutation, + useRequestRenegotiationMutation, + useSaveExtraInfoMutation, +} from './negotiation.mutations' export * from './negotiation.type' diff --git a/frontend/src/apis/negotiation/negotiation.api.ts b/frontend/src/apis/negotiation/negotiation.api.ts index 2e430c6..9acd825 100644 --- a/frontend/src/apis/negotiation/negotiation.api.ts +++ b/frontend/src/apis/negotiation/negotiation.api.ts @@ -6,6 +6,8 @@ import type { ParticipateResponse, RejectRequest, RejectResponse, + RenegotiationRequest, + RenegotiationResponse, SessionListParams, SessionListResponse, } from './negotiation.type' @@ -34,6 +36,23 @@ export const negotiationApi = { return res.data }, + /** POST /v1/negotiation/session/{id}/renegotiation — 결렬 건 재협상 요청 */ + requestRenegotiation: async (sessionId: string, body: RenegotiationRequest): Promise => { + const res = await http.post( + `/v1/negotiation/session/${sessionId}/renegotiation`, + body, + ) + return res.data + }, + + /** DELETE /v1/negotiation/session/{id}/renegotiation — 심사 대기 중인 요청 철회 */ + cancelRenegotiation: async (sessionId: string): Promise => { + const res = await http.delete( + `/v1/negotiation/session/${sessionId}/renegotiation`, + ) + return res.data + }, + /** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */ saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise => { const res = await http.post( diff --git a/frontend/src/apis/negotiation/negotiation.mutations.ts b/frontend/src/apis/negotiation/negotiation.mutations.ts index 10030a4..ce9f1c6 100644 --- a/frontend/src/apis/negotiation/negotiation.mutations.ts +++ b/frontend/src/apis/negotiation/negotiation.mutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { negotiationApi } from './negotiation.api' import { negotiationKeys } from './negotiation.keys' -import type { ExtraInfoRequest, RejectRequest } from './negotiation.type' +import type { ExtraInfoRequest, RejectRequest, RenegotiationRequest } from './negotiation.type' /** * 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다. @@ -44,3 +44,24 @@ export function useSaveExtraInfoMutation() { }, }) } + +export function useRequestRenegotiationMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ sessionId, request }: { sessionId: string; request: RenegotiationRequest }) => + negotiationApi.requestRenegotiation(sessionId, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) + }, + }) +} + +export function useCancelRenegotiationMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ sessionId }: { sessionId: string }) => negotiationApi.cancelRenegotiation(sessionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) + }, + }) +} diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index ccd0fd5..dbbf984 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -43,6 +43,8 @@ export interface SessionListParams { order?: 'asc' | 'desc' // 생략 시 기본 그룹 정렬('할 일' 우선+임박순, 종료는 하단). 지정 시 그룹 무시하고 전체 마감순(asc=임박/desc=여유) page?: number page_size?: number + keyword?: string // 검색어 — 견적번호·상품명·상품코드 부분일치 + result?: number // 결과 필터(SessionResult): 1=낙찰 2=미낙찰 3=결렬 } export interface SessionListItem { @@ -56,6 +58,40 @@ export interface SessionListItem { model_name: string maker_name: string custom: Record // 협상완료 부가정보(sessions.custom). 미입력이면 {} + renegotiable: boolean // 재협상 요청 가능 여부(서버 판정 — 개찰 마감 + 마지막 차수 + 대기 요청 없음) + renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0 + renegotiation_memo: string // 담당자 심사 메모(반려 사유) + result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) +} + +/** 공급사 관점 협상 결과 (sessions 파생) */ +export const SessionResult = { NONE: 0, WON: 1, LOST: 2, OPEN: 3 } as const + +export const SESSION_RESULT_LABEL: Record = { + 1: '낙찰', + 2: '미낙찰', + 3: '결렬', +} + +// 재협상 요청(IMK #15) +export interface RenegotiationRequest { + reason: string + desired_price?: number | null +} + +export interface RenegotiationResponse { + result: ApiResult + session_id: string + status: number +} + +export const RenegoStatus = { NONE: 0, PENDING: 1, APPROVED: 2, REJECTED: 3, CANCELED: 4 } as const + +export const RENEGO_STATUS_LABEL: Record = { + 1: '재협상 심사 중', + 2: '재협상 승인됨', + 3: '재협상 반려됨', + 4: '요청 철회됨', } export interface SessionListResponse { diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 3141b53..6767c55 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -6,7 +6,6 @@ import type { ChatMessage as ChatMessageType } from '@/features/chat/types' import { renderEmphasis } from '@/features/chat/lib/emphasis' import { Indicator } from '@/features/chat/components/templates/Indicator' import { Summary } from '@/features/chat/components/templates/Summary' -import { ExtraInfoForm } from '@/features/chat/components/templates/ExtraInfoForm' import { BidSummary } from '@/features/chat/components/templates/BidSummary' import { RejectRSP } from '@/features/chat/components/templates/RejectRSP' import { RejectCM } from '@/features/chat/components/templates/RejectCM' @@ -22,7 +21,7 @@ export function ChatMessage() { 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
@@ -48,7 +47,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject } scroller.scrollTo({ top: scroller.scrollHeight, behavior }) }) return () => cancelAnimationFrame(id) - }, [chats, isLoading]) + }, [chats, isLoading, scrollRef]) if (!chats || chats.length === 0) { return ( @@ -124,12 +123,7 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT {showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && ( )} - {message.bot_chat_type === 'summaryRSP' && message.summary && ( - <> - - - - )} + {message.bot_chat_type === 'summaryRSP' && message.summary && } {message.bot_chat_type === 'summaryCM' && message.summary && ( s.sessionId) + const sendMessage = useChatStore((s) => s.sendMessage) + const { data: user } = useMeQuery() + const fields: SessionField[] = user?.sessionFields ?? [] + const save = useSaveExtraInfoMutation() + const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필) + + // 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요). + const [overrides, setOverrides] = useState>({}) + const values: Record = {} + for (const f of fields) values[f.key] = overrides[f.key] ?? existing?.[f.key] ?? (f.type === 'boolean' ? false : '') + + const proceed = () => sendMessage(proceedText) + + // 필드 미정의 회사 → 부가정보 없이 동의만. + if (fields.length === 0) { + return ( +
+ +
+ ) + } + + const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value })) + + // 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지. + const handleSaveAndProceed = () => { + if (save.isPending) return + const custom: Record = {} + for (const f of fields) { + const v = values[f.key] + if (f.type === 'boolean') custom[f.key] = !!v + else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v + } + save.mutate( + { sessionId, request: { custom } }, + { + onSuccess: () => proceed(), + onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')), + }, + ) + } + + return ( +
+

아래 정보를 입력하고 협상을 마무리해 주세요.

+ {/* overflow-y-auto 는 overflow-x 도 auto 로 만들어(스펙상) 인풋 focus ring 을 좌우로 잘라낸다. + p-1 로 링 여백을 주고 -m-1 로 원래 정렬(버튼과 좌우폭)을 유지한다. */} +
+ {fields.map((f) => ( +
+ + {f.type === 'boolean' ? ( + + ) : f.type === 'select' ? ( + + ) : ( + set(f.key, e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSaveAndProceed()} + className="h-10 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600" + placeholder={f.label} + /> + )} +
+ ))} +
+ +
+ ) +} + +const PRIMARY = + 'h-[46px] min-w-[120px] rounded-xl bg-brand-600 px-6 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50' diff --git a/frontend/src/features/chat/components/MobileStepBar.tsx b/frontend/src/features/chat/components/MobileStepBar.tsx index cbb37ea..a2f6eed 100644 --- a/frontend/src/features/chat/components/MobileStepBar.tsx +++ b/frontend/src/features/chat/components/MobileStepBar.tsx @@ -1,7 +1,7 @@ import { Check } from 'lucide-react' import { cn } from '@/lib' import { useChatStore } from '@/features/chat/stores/useChatStore' -import { STEPS } from '@/features/chat/components/menu/NegoStep' +import { STEPS } from '@/features/chat/lib/negoSteps' // 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용) export function MobileStepBar() { diff --git a/frontend/src/features/chat/components/RemainingTime.tsx b/frontend/src/features/chat/components/RemainingTime.tsx index b507076..4f4355f 100644 --- a/frontend/src/features/chat/components/RemainingTime.tsx +++ b/frontend/src/features/chat/components/RemainingTime.tsx @@ -20,12 +20,23 @@ export function RemainingTime() { return ( - - 협상 마감 {remaining} + + + {ended ? ( + // 종료 시엔 어느 폭에서도 긴 문장('종료되었습니다.')을 쓰지 않는다 — 헤더에서 잘리던 원인. + '마감 종료' + ) : ( + <> + 협상 마감 + {remaining} + + )} + ) } diff --git a/frontend/src/features/chat/components/UserButton.tsx b/frontend/src/features/chat/components/UserButton.tsx index 333eafa..eb3840c 100644 --- a/frontend/src/features/chat/components/UserButton.tsx +++ b/frontend/src/features/chat/components/UserButton.tsx @@ -1,7 +1,9 @@ +import { useEffect } from 'react' import { useNavigate } from 'react-router' import { cn } from '@/lib' import { useChatStore } from '@/features/chat/stores/useChatStore' import { Percent, Price } from '@/features/chat/components/userInputs' +import { ExtraInfoBar } from '@/features/chat/components/ExtraInfoBar' import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig' import type { UserButtonConfig } from '@/features/chat/types' @@ -17,6 +19,15 @@ const style = { export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) { if (type === '') return null + // 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구. + if (type === 'extra-info') { + return ( +
+ +
+ ) + } + return (
@@ -82,6 +93,18 @@ function ThreeBlack({ textList }: { textList: [string, string, string] }) { function BlackWhite({ textList }: { textList: [string, string] }) { const sendMessage = useChatStore((s) => s.sendMessage) + // Enter = 첫 버튼(예). 입력창이 없는 선택 단계라 전역 Enter 를 잡아도 안전하다. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault() + sendMessage(textList[0]) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [sendMessage, textList]) + return (
- ) : ( - set(f.key, e.target.value)} - disabled={saved} - className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10" - placeholder={f.label} - /> - )} -
- ))} -
- - -
- ) -} diff --git a/frontend/src/features/chat/components/templates/OtherReason.tsx b/frontend/src/features/chat/components/templates/OtherReason.tsx index eb8bf3c..c7a2d44 100644 --- a/frontend/src/features/chat/components/templates/OtherReason.tsx +++ b/frontend/src/features/chat/components/templates/OtherReason.tsx @@ -10,6 +10,7 @@ export function OtherReason({ onChange, isError, disabled, + onSubmit, }: { inputValue: string setInputValue: (value: string) => void @@ -18,6 +19,7 @@ export function OtherReason({ onChange: (value: string) => void isError: boolean disabled?: boolean + onSubmit?: () => void }) { const isChecked = selectedValue === '기타' const ref = useRef(null) @@ -73,6 +75,13 @@ export function OtherReason({ )} value={inputValue} onChange={(e) => setInputValue(e.target.value)} + onKeyDown={(e) => { + // Enter=제출, Shift+Enter=줄바꿈. 한글 조합 중 Enter 는 무시. + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && onSubmit) { + e.preventDefault() + onSubmit() + } + }} placeholder="제시한 가격을 수용할 수 없는 이유를 작성해주세요." disabled={isTextareaDisabled} rows={1} diff --git a/frontend/src/features/chat/components/templates/RejectCM.tsx b/frontend/src/features/chat/components/templates/RejectCM.tsx index ded0edf..a78da24 100644 --- a/frontend/src/features/chat/components/templates/RejectCM.tsx +++ b/frontend/src/features/chat/components/templates/RejectCM.tsx @@ -79,6 +79,7 @@ export function RejectCM() { )} value={price ? parseInt(price).toLocaleString() : ''} onChange={(e) => handlePriceChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()} placeholder="0" disabled={isDisabled} /> diff --git a/frontend/src/features/chat/components/templates/RejectRSP.tsx b/frontend/src/features/chat/components/templates/RejectRSP.tsx index 05b6118..36fe738 100644 --- a/frontend/src/features/chat/components/templates/RejectRSP.tsx +++ b/frontend/src/features/chat/components/templates/RejectRSP.tsx @@ -109,6 +109,7 @@ export function RejectRSP() { )} value={price ? parseInt(price).toLocaleString() : ''} onChange={(e) => handlePriceChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSubmit()} placeholder="0" disabled={isDisabled} /> @@ -147,6 +148,7 @@ export function RejectRSP() { onChange={handleRadioChange} isError={!!radioErrorMessage} disabled={isDisabled} + onSubmit={handleSubmit} /> diff --git a/frontend/src/features/chat/lib/negoSteps.ts b/frontend/src/features/chat/lib/negoSteps.ts new file mode 100644 index 0000000..bca03be --- /dev/null +++ b/frontend/src/features/chat/lib/negoSteps.ts @@ -0,0 +1,8 @@ +// 협상 절차 5단계 — NegoStep(사이드) · MobileStepBar(모바일 상단)가 공유. +export const STEPS = [ + { name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' }, + { name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' }, + { name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' }, + { name: '가격협상', desc: '공급 단가를 제안하고 조율합니다.' }, + { name: '협상종료', desc: '최종 합의 후 결과를 확인합니다.' }, +] diff --git a/frontend/src/features/chat/lib/userButtonConfig.ts b/frontend/src/features/chat/lib/userButtonConfig.ts index 879760e..bc6f4e5 100644 --- a/frontend/src/features/chat/lib/userButtonConfig.ts +++ b/frontend/src/features/chat/lib/userButtonConfig.ts @@ -16,7 +16,12 @@ export function deriveUserButtonConfig( const options = last.next_input_type if (last.chat_end) return { type: 'one-black', text: GO_TO_LIST_TEXT } - if (mode === 'confirm') return { type: 'one-black', text: options?.[0] || '' } + // 타결 요약이 뜬 뒤의 '동의' 단계 = 부가정보 입력 + 동의를 한 자리(액션바)에서 받는다. + // (요약 이후 confirm 단계에만 적용 — 재견적의 투찰확정/정보수정 같은 선택 단계는 그대로 둔다.) + const dealt = messages.some((m) => m.bot_chat_type === 'summaryRSP' || m.bot_chat_type === 'summaryCM') + if (mode === 'confirm') { + return dealt ? { type: 'extra-info', text: options?.[0] || '' } : { type: 'one-black', text: options?.[0] || '' } + } if (mode === 'yes_no') return { type: 'black-white', textList: options || [] } if (mode === 'percent') return { type: 'percent' } if (mode === 'price') return { type: 'price', priceErrorMessage: priceErrorMessage || undefined } diff --git a/frontend/src/features/chat/types.ts b/frontend/src/features/chat/types.ts index 691ae2f..0fa623b 100644 --- a/frontend/src/features/chat/types.ts +++ b/frontend/src/features/chat/types.ts @@ -53,6 +53,7 @@ export type UserButtonType = | 'percent' | 'three-black' | 'price' + | 'extra-info' | 'loading' | '' diff --git a/frontend/src/features/list/components/ExtraInfoPopup.tsx b/frontend/src/features/list/components/ExtraInfoPopup.tsx index 1613531..02199e5 100644 --- a/frontend/src/features/list/components/ExtraInfoPopup.tsx +++ b/frontend/src/features/list/components/ExtraInfoPopup.tsx @@ -78,6 +78,17 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp }`} /> + ) : f.type === 'select' ? ( + ) : ( void }) { + return ( + +
+
+
+

이용안내

+

협상 목록의 상태·결과·재협상을 안내합니다.

+
+ +
+ +
+
+ + + + + +
+ +
+ + + +
+ +
+

+ 결렬 건의 재협상 요청 버튼으로 사유·희망가를 담아 요청하면, + 구매 담당자 검토 후 승인 시 다음 차수 협상이 새로 열립니다. + 요청 상태(심사 중·승인·반려)는 목록에서 확인할 수 있습니다. +

+
+
+ + +
+
+ ) +} + +function Section({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { + return ( +
+

{title}

+

{desc}

+
{children}
+
+ ) +} + +const TONE: Record = { + wait: 'bg-brand-light text-brand-700', + prog: 'bg-[#FFF3E5] text-[#F5A623]', + done: 'bg-[#EAFDF3] text-success', + none: 'bg-neutral-20 text-neutral-60', + reject: 'bg-[#FFEBEB] text-[#FF4D4F]', + win: 'bg-[#EAFDF3] text-success', + lost: 'bg-neutral-20 text-neutral-60', + open: 'bg-[#FFF3E5] text-[#F5A623]', +} + +function Row({ badge, tone, text }: { badge: string; tone: string; text: string }) { + return ( +
+ + {badge} + +

{text}

+
+ ) +} diff --git a/frontend/src/features/list/components/KpiCards.tsx b/frontend/src/features/list/components/KpiCards.tsx index ba458f4..891342a 100644 --- a/frontend/src/features/list/components/KpiCards.tsx +++ b/frontend/src/features/list/components/KpiCards.tsx @@ -1,54 +1,85 @@ -import { AlertTriangle, CheckCircle2, FileText, RefreshCw } from 'lucide-react' +import type { ReactNode } from 'react' import { cn } from '@/lib' import { useSessionCounts, type KpiCount } from '@/features/list/hooks/useSessionCounts' -// key → 아이콘/색 스타일 (콕핏 카드) -const STYLE: Record = { - created: { icon: FileText, tile: 'bg-brand-light', iconColor: 'text-brand-600' }, - progress: { icon: RefreshCw, tile: 'bg-sky-50', iconColor: 'text-sky-500', spin: true }, - done: { icon: CheckCircle2, tile: 'bg-emerald-50', iconColor: 'text-success' }, - rejected: { icon: AlertTriangle, tile: 'bg-red-50', iconColor: 'text-[#FF4D4F]' }, -} - +// 목업(claude artifact) 그대로: 진행(내가 할 일=상태축) / 결과(마감 후=결과축) 두 묶음. +// 모든 카드 동일 크기·높이 — 강조는 색·CTA 로만. 낙찰=초록, 결렬=amber(재협상 가능), 미낙찰=회색. interface KpiCardsProps { selectedStatus: string | null - onSelect: (status: string) => void + selectedResult: number | null + onSelectStatus: (status: string) => void + onSelectResult: (code: number) => void } -export function KpiCards({ selectedStatus, onSelect }: KpiCardsProps) { - const counts = useSessionCounts() +// key → 배지 점 색 / 숫자 색 / 카드 배경·테두리 / CTA. +const STYLE: Record = { + created: { num: 'text-brand-600' }, + progress: { num: 'text-neutral-90' }, + won: { dot: 'bg-success', num: 'text-success', card: 'border-success/40 bg-gradient-to-b from-emerald-50 to-white', cta: 'text-success', ctaText: '★ 계약 대상' }, + open: { dot: 'bg-warning', num: 'text-warning', card: 'border-warning/40', cta: 'text-warning', ctaText: '▲ 재협상 요청 가능' }, + lost: { dot: 'bg-neutral-50', num: 'text-neutral-70' }, +} + +export function KpiCards({ selectedStatus, selectedResult, onSelectStatus, onSelectResult }: KpiCardsProps) { + const { progress, result } = useSessionCounts() + + const isActive = (c: KpiCount) => + c.kind === 'status' ? selectedStatus === c.statusLabel : selectedResult === c.resultCode + const onClick = (c: KpiCount) => + c.kind === 'status' ? onSelectStatus(c.statusLabel!) : onSelectResult(c.resultCode!) + return ( -
- {counts.map((c) => ( - onSelect(c.status)} /> - ))} +
+ + {progress.map((c) => ( + onClick(c)} /> + ))} + + + {result.map((c) => ( + onClick(c)} /> + ))} + +
+ ) +} + +function Cluster({ label, dot, cols, children }: { label: string; dot: string; cols: string; children: ReactNode }) { + return ( +
+
+ + {label} +
+
{children}
) } function Card({ data, active, onClick }: { data: KpiCount; active: boolean; onClick: () => void }) { - const s = STYLE[data.key] - const Icon = s.icon + const s = STYLE[data.key] ?? STYLE.created + const ctaText = s.ctaText ?? ' ' // 빈 카드도 CTA 줄 높이를 확보(nbsp) → 5개 카드 높이 완전 동일 return ( ) } diff --git a/frontend/src/features/list/components/RenegotiationMemoPopup.tsx b/frontend/src/features/list/components/RenegotiationMemoPopup.tsx new file mode 100644 index 0000000..53a9b61 --- /dev/null +++ b/frontend/src/features/list/components/RenegotiationMemoPopup.tsx @@ -0,0 +1,54 @@ +import { X } from 'lucide-react' +import { Modal } from '@/components' +import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type' +import type { ListItem } from '../types' + +export interface RenegotiationMemoPopupProps { + target: ListItem + onClose: () => void +} + +// 재협상 심사 결과의 담당자 메모 열람 팝업. 목록에는 상태 배지만 두고 메모 전문은 여기서 보여준다. +export function RenegotiationMemoPopup({ target, onClose }: RenegotiationMemoPopupProps) { + return ( + +
+
+
+

+ {RENEGO_STATUS_LABEL[target.renegotiationStatus] ?? '재협상 심사 결과'} +

+

+ {target.qt_number} · {target.item_name} +

+
+ +
+ +
+

담당자 메모

+

+ {target.renegotiationMemo} +

+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/features/list/components/RenegotiationPopup.tsx b/frontend/src/features/list/components/RenegotiationPopup.tsx new file mode 100644 index 0000000..4ad5f90 --- /dev/null +++ b/frontend/src/features/list/components/RenegotiationPopup.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { Modal } from '@/components' +import type { ListItem } from '../types' + +export interface RenegotiationPopupProps { + target: ListItem + onClose: () => void + onSubmit: (reason: string, desiredPrice: number | null) => void +} + +// 재협상 요청 팝업. 결렬(낙찰자 미정) 건에만 열리며, 담당자 승인 시 다음 차수가 생성된다. +// 사유는 자주 쓰는 것을 프리셋으로 두고, 직접 입력도 허용한다. +const PRESETS = ['가격 조건 재검토', '재고·납기 확보', '단가 정정', '수량 조건 변경'] as const + +export function RenegotiationPopup({ target, onClose, onSubmit }: RenegotiationPopupProps) { + const [preset, setPreset] = useState(PRESETS[0]) + const [custom, setCustom] = useState('') + const [price, setPrice] = useState('') + + const isDirect = preset === '직접 입력' + const reason = isDirect ? custom.trim() : preset + const canSubmit = reason.length > 0 + + const handleSubmit = () => { + if (!canSubmit) return + const parsed = Number(price.replace(/[^0-9]/g, '')) + onSubmit(reason, parsed > 0 ? parsed : null) + onClose() + } + + return ( + +
+
+
+

재협상 요청

+

+ {target.qt_number} · {target.item_name} +

+
+ +
+ +
+

+ 이 건은 낙찰자 없이 마감되었습니다. 요청하면 구매 담당자가 검토 후 승인 시 + 다시 협상할 기회가 열립니다. 승인·반려 결과는 목록에서 확인할 수 있습니다. +

+ +
+ +
+ {[...PRESETS, '직접 입력'].map((p) => ( + + ))} +
+ {isDirect && ( + setCustom(e.target.value)} + maxLength={255} + placeholder="요청 사유를 입력해 주세요" + className="mt-1.5 h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600" + /> + )} +
+ +
+ + setPrice(e.target.value)} + placeholder="예: 14,500,000" + className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600" + /> +

담당자 검토 참고용입니다. 실제 가격은 재협상에서 다시 조율합니다.

+
+
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/list/components/StatusTabs.tsx b/frontend/src/features/list/components/StatusTabs.tsx index 1674961..de3694e 100644 --- a/frontend/src/features/list/components/StatusTabs.tsx +++ b/frontend/src/features/list/components/StatusTabs.tsx @@ -18,7 +18,7 @@ interface StatusTabsProps { export function StatusTabs({ selected, onSelect }: StatusTabsProps) { return ( -
+
{TABS.map((t) => { const active = selected === t.value return ( @@ -27,8 +27,8 @@ export function StatusTabs({ selected, onSelect }: StatusTabsProps) { type="button" onClick={() => onSelect(t.value)} className={cn( - 'rounded-lg px-3.5 py-1.5 text-[13px] font-bold transition-all active:scale-[0.98]', - active ? 'bg-white text-neutral-90 shadow-sm' : 'text-neutral-60 hover:text-neutral-80', + 'whitespace-nowrap rounded-[10px] px-3 py-[7px] text-[13px] font-bold transition-all active:scale-[0.98]', + active ? 'bg-brand-light text-brand-700' : 'text-neutral-60 hover:text-neutral-80', )} > {t.label} diff --git a/frontend/src/features/list/components/WorkspaceCards.tsx b/frontend/src/features/list/components/WorkspaceCards.tsx index 07e43de..e804552 100644 --- a/frontend/src/features/list/components/WorkspaceCards.tsx +++ b/frontend/src/features/list/components/WorkspaceCards.tsx @@ -1,7 +1,8 @@ -import { Loader2 } from 'lucide-react' +import { Loader2, MessageSquareText } from 'lucide-react' import { cn, formatKstDateTime } from '@/lib' +import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type' import type { ListItem } from '@/features/list/types' -import { statusMeta } from '@/features/list/lib/status' +import { statusMeta, RESULT_META } from '@/features/list/lib/status' interface WorkspaceCardsProps { items: ListItem[] @@ -10,10 +11,12 @@ interface WorkspaceCardsProps { onEnter: (item: ListItem) => void onReject: (item: ListItem) => void onExtraInfo: (item: ListItem) => void + onRenegotiate: (item: ListItem) => void + onMemo: (item: ListItem) => void } // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. -export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) { +export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceCardsProps) { if (isLoading) { return (
@@ -28,7 +31,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on return (
{items.map((item) => ( - + ))}
) @@ -40,12 +43,16 @@ function Card({ onEnter, onReject, onExtraInfo, + onRenegotiate, + onMemo, }: { item: ListItem busy: boolean onEnter: (item: ListItem) => void onReject: (item: ListItem) => void onExtraInfo: (item: ListItem) => void + onRenegotiate: (item: ListItem) => void + onMemo: (item: ListItem) => void }) { const meta = statusMeta(item.session_status) const canEnter = !['미참여', '협상거부'].includes(item.session_status) @@ -53,6 +60,8 @@ function Card({ const isDone = item.session_status === '협상완료' const enterLabel = isDone ? '결과 보기' : '협상 입장' const hasExtra = item.custom && Object.keys(item.custom).length > 0 + // 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다. + const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? '' return (
@@ -67,10 +76,17 @@ function Card({ {item.model_name && · {item.model_name}}

- - - {meta.display} - +
+ {RESULT_META[item.result] && ( + + {RESULT_META[item.result].label} + + )} + + + {meta.display} + +
@@ -78,8 +94,36 @@ function Card({ 마감 {formatKstDateTime(item.qt_end_time)}
- {(canEnter || canReject || isDone) && ( + {renegoLabel && + (item.renegotiationMemo ? ( + + ) : ( +
+

{renegoLabel}

+
+ ))} + + {(canEnter || canReject || isDone || item.renegotiable) && (
+ {item.renegotiable && ( + + )} {isDone && (