From ac6366a8983bccbe21de0e7c99616c9ac3eb1e85 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 11 Aug 2026 10:34:07 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negosium=C2=B7negodata:=20=ED=98=91?= =?UTF-8?q?=EC=83=81=20=EC=A7=84=ED=96=89=20=EC=A4=91=20=EA=B1=B0=EB=B6=80?= =?UTF-8?q?=20+=20=EA=B2=B0=EB=A0=AC=20=EA=B1=B4=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=EA=B0=80=EB=A1=9C=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EB=82=99=EC=B0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고, 거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다. negosium - 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출, 주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap - 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다 - /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합 - 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업) negodata - 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부 - 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가) - 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다 - 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…' 자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다. --- backend/crud/session_crud.py | 16 +- backend/router/v1/negotiation/protocol.py | 2 + backend/router/v1/negotiation/session.py | 8 +- backend/services/negotiation_service.py | 24 +- backend/tests/test_negotiation.py | 34 +++ .../src/apis/negotiation/negotiation.type.ts | 5 +- .../features/chat/components/ChatSection.tsx | 21 +- .../features/chat/components/UserButton.tsx | 25 ++- .../chat/components/popup/RejectPopup.tsx | 210 ++++++++++++++++++ .../src/features/chat/lib/userButtonConfig.ts | 11 + .../features/list/components/GuidePopup.tsx | 2 +- .../list/components/WorkspaceCards.tsx | 2 +- .../list/components/WorkspaceTable.tsx | 2 +- .../list/containers/ListWorkspace.tsx | 4 +- frontend/src/features/list/lib/status.ts | 2 +- negodata/backend/crud/quotation_crud.py | 7 +- negodata/backend/crud/statistics_crud.py | 7 +- .../backend/services/quotation/closing.py | 30 ++- .../SessionsStatusTab.tsx | 38 ++-- .../front/src/features/quotations/types.ts | 14 +- negodata/front/src/pages/dev-design.tsx | 2 +- 21 files changed, 403 insertions(+), 63 deletions(-) create mode 100644 frontend/src/features/chat/components/popup/RejectPopup.tsx diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 687602f..1413723 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Tuple +from typing import Optional, Tuple from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update from sqlalchemy.dialects.postgresql import JSONB @@ -54,7 +54,9 @@ class ISessionCRUD(ABC): pass @abstractmethod - async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType: + async def update_session_reject( + self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None, + ) -> ErrorType: pass @abstractmethod @@ -217,12 +219,18 @@ class SessionCRUD(ISessionCRUD): 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: + async def update_session_reject( + self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None, + ) -> ErrorType: try: + values = {"status": status, "reject_reason": reject_reason} + # 공급 희망 가격은 채팅 중 거부에서만 들어온다 — 목록 거부는 가격 없이 사유만 남긴다. + if reject_price is not None: + values["reject_price"] = reject_price query = ( update(sessions) .where(sessions.session_id == session_id) - .values(status=status, reject_reason=reject_reason) + .values(**values) ) return await DB_SESSION_MNG.add(cdb, query) except Exception as ex: diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index a986208..8e000ef 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -37,6 +37,8 @@ class Res_Participate(Res_WebPacketProtocol): class Req_Reject(WebPacketProtocol): reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)") + reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 채팅 중 거부에서만 들어온다 — 목록 거부엔 없음") + opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합") class Res_Reject(Res_WebPacketProtocol): diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 82ec668..b4b9f36 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -62,7 +62,7 @@ async def participate( path="/sessions/{session_id}/reject", response_model=Res_Reject, summary="협상 거부", - description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.", + description="세션 참여를 거부하거나 진행 중인 협상을 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유·공급 희망 가격·의견을 저장.", ) async def reject( session_id: str = Path(description="대상 협상 세션 uuid"), @@ -71,7 +71,11 @@ async def reject( credentials: HTTPAuthorizationCredentials = Depends(security), service: NegotiationService = Depends(), ): - return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason)) + return RemoveNoneResponse( + await service.reject( + user_info, credentials.credentials, session_id, req.reject_reason, req.reject_price, req.opinion, + ) + ) @router.post( diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index f7b8a7b..29cfb33 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime, timezone +from typing import Optional from fastapi import Depends @@ -435,7 +436,10 @@ class NegotiationService: res.session_id = str(sess.session_id) return res - async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject: + async def reject( + self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str, + reject_price: Optional[int] = None, opinion: Optional[str] = None, + ) -> Res_Reject: res = Res_Reject() # 거부 사유 필수 @@ -455,11 +459,19 @@ class NegotiationService: res.result.SetResult(err_type) return res - # 거부 처리 — 세션을 협상거부로 전이하고 사유 저장 - err_type = await DB_SESSION_MNG.execute_lambda_run( - [sessions.DBType()], - [lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)], - ) + # 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장. + # 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리. + funcs = [ + lambda s: self.session_crud.update_session_reject( + s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price, + ) + ] + note = (opinion or "").strip()[:255] + if note: + funcs.append( + lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note}) + ) + err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index 99e2963..11385bf 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -385,6 +385,40 @@ async def test_reject_success(client, nego_seed, db_engine): assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장 +async def test_reject_with_price_and_opinion(client, nego_seed, db_engine): + # 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다. + token = await _login_token(client) + sid = nego_seed["sids"]["B"] + r = await client.post( + f"/v1/negotiation/sessions/{sid}/reject", + headers={"Authorization": f"Bearer {token}"}, + json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"}, + ) + assert r.json()["result"]["success"] is True + async with db_engine.begin() as conn: + row = (await conn.execute( + text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"), + {"sid": sid}, + )).first() + assert row.status == 5 and row.reject_reason == "품절" + assert row.reject_price == 88000 + assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다" + + +async def test_reject_without_price_keeps_null(client, nego_seed, db_engine): + # 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다. + token = await _login_token(client) + sid = nego_seed["sids"]["B"] + r = await _reject(client, token, sid, "단종") + assert r.json()["result"]["success"] is True + async with db_engine.begin() as conn: + row = (await conn.execute( + text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"), + {"sid": sid}, + )).first() + assert row.reject_price is None and row.custom is None + + async def test_reject_empty_reason(client, nego_seed): token = await _login_token(client) r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음 diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index bbb245d..d744eab 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -119,10 +119,13 @@ export interface ParticipateResponse { } // --- 거부 (POST /v1/negotiation/sessions/{id}/reject) --------------------- +// 목록의 협상 거부와 채팅 중 협상 거부가 같은 엔드포인트를 쓴다. // reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. -// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정) +// reject_price/opinion: 채팅 중 거부에서만 채운다(목록 거부는 사유만). export interface RejectRequest { reject_reason: string + reject_price?: number + opinion?: string } export interface RejectResponse { diff --git a/frontend/src/features/chat/components/ChatSection.tsx b/frontend/src/features/chat/components/ChatSection.tsx index 71a3bb6..5ff9e57 100644 --- a/frontend/src/features/chat/components/ChatSection.tsx +++ b/frontend/src/features/chat/components/ChatSection.tsx @@ -1,9 +1,11 @@ +import { useState } from 'react' import { SessionStatus } from '@/apis' import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { ChatMessage } from '@/features/chat/components/ChatMessage' import { UserButton } from '@/features/chat/components/UserButton' -import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig' +import { RejectPopup } from '@/features/chat/components/popup/RejectPopup' +import { canRejectNegotiation, GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig' import { MobileStepBar } from '@/features/chat/components/MobileStepBar' import type { UserButtonConfig } from '@/features/chat/types' @@ -12,10 +14,20 @@ import type { UserButtonConfig } from '@/features/chat/types' const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED] const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT } +// 거부 가능한 세션 상태 — 백엔드 /reject 가 허용하는 범위(완료/미참여/거부는 불가)와 같다. +const REJECTABLE_STATUSES: number[] = [SessionStatus.CREATED, SessionStatus.IN_PROGRESS] + export function ChatSection() { - const { userButtonConfig } = useChatStore() + const { messages, userButtonConfig } = useChatStore() const sessionStatus = useChatInitStore((s) => s.session_status) - const config = READ_ONLY_STATUSES.includes(sessionStatus) ? READ_ONLY_CONFIG : userButtonConfig + const [isRejectOpen, setIsRejectOpen] = useState(false) + + const isReadOnly = READ_ONLY_STATUSES.includes(sessionStatus) + const config = isReadOnly ? READ_ONLY_CONFIG : userButtonConfig + // 단종·품절처럼 대화로 풀 수 없는 사유는 봇의 결렬 선언을 기다릴 수 없다 — 진행 중에도 빠져나갈 길을 둔다. + const canReject = + !isReadOnly && REJECTABLE_STATUSES.includes(sessionStatus) && canRejectNegotiation(messages, userButtonConfig) + return (
@@ -23,8 +35,9 @@ export function ChatSection() { {/* 하단 액션 덱 */} {/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
- + setIsRejectOpen(true) : undefined} />
+ {isRejectOpen && setIsRejectOpen(false)} />}
) } diff --git a/frontend/src/features/chat/components/UserButton.tsx b/frontend/src/features/chat/components/UserButton.tsx index 28f01ad..44fe508 100644 --- a/frontend/src/features/chat/components/UserButton.tsx +++ b/frontend/src/features/chat/components/UserButton.tsx @@ -17,7 +17,8 @@ const style = { 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]', } -export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) { +// onReject: 협상 거부 진입점. 넘어오면 버튼 덱 끝에 붙는다(입력 단계는 인풋이 넓어 좁은 화면에서 아랫줄로 wrap). +export function UserButton({ type, text, textList, priceErrorMessage, onReject }: UserButtonConfig & { onReject?: () => void }) { if (type === '') return null // 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구. @@ -41,8 +42,13 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt // 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용) const isInputStep = type === 'percent' || type === 'price' return ( -
-
+
+
{type === 'one-black' && } {type === 'one-gray' && } {type === 'black-white' && } @@ -53,6 +59,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt {type === 'price' && } {type === 'loading' && }
+ {/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로), + 좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */} + {onReject && ( + + )}
) } diff --git a/frontend/src/features/chat/components/popup/RejectPopup.tsx b/frontend/src/features/chat/components/popup/RejectPopup.tsx new file mode 100644 index 0000000..5709d91 --- /dev/null +++ b/frontend/src/features/chat/components/popup/RejectPopup.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router' +import { X } from 'lucide-react' +import { Modal } from '@/components' +import { getApiErrorMessage, useRejectMutation } from '@/apis' +import { cn, toast } from '@/lib' +import { numberToKorean } from '@/features/chat/lib/koreanNumber' +import { useChatStore } from '@/features/chat/stores/useChatStore' +import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' + +const MAX_PRICE = 999999999999999 + +// 사유 목록은 목록 화면의 거부 팝업과 동일하게 맞춘다. +const REASONS = ['단종', '품절', '기타'] as const + +// 협상 진행 중 거부 팝업 — 목록의 거부 팝업과 같은 어휘·같은 엔드포인트를 쓰고, +// 대화 중이라 이미 오간 가격이 있으므로 공급 희망 가격과 의견을 더 받는다. +export function RejectPopup({ onClose }: { onClose: () => void }) { + const navigate = useNavigate() + const reject = useRejectMutation() + const sessionId = useChatStore((s) => s.sessionId) + const itemVatYn = useChatInitStore((s) => s.item_vat_yn) + const isVAT = itemVatYn === 'VAT별도' + + const [selectedReason, setSelectedReason] = useState(null) + const [customReason, setCustomReason] = useState('') + const [price, setPrice] = useState('') + const [opinion, setOpinion] = useState('') + const [showError, setShowError] = useState(false) + + const isEtcOpen = selectedReason === '기타' + const isPending = reject.isPending + const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending + + const handleReasonClick = (reason: string) => { + setShowError(false) + if (selectedReason === reason) { + setSelectedReason(null) + setCustomReason('') + } else { + setSelectedReason(reason) + if (reason !== '기타') setCustomReason('') + } + } + + const handlePriceChange = (value: string) => { + const numeric = value.replace(/\D/g, '') + if (numeric && parseInt(numeric) > MAX_PRICE) return + setPrice(numeric) + } + + const handleSubmit = () => { + if (isEtcOpen && !customReason.trim()) { + setShowError(true) + return + } + if (isSubmitDisabled || !selectedReason) return + + reject.mutate( + { + sessionId, + request: { + reject_reason: isEtcOpen ? customReason.trim() : selectedReason, + // 선택 입력 — 빈 값이면 보내지 않아 컬럼을 건드리지 않는다. + ...(price ? { reject_price: parseInt(price) } : {}), + ...(opinion.trim() ? { opinion: opinion.trim() } : {}), + }, + }, + { + onSuccess: () => { + onClose() + toast.warning('협상 거부가 완료되었습니다.') + navigate('/list') + }, + onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')), + }, + ) + } + + const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : '' + + return ( + +
+ {/* 헤더 */} +
+

거부 사유를 입력해주세요

+ +
+ + {/* 본문 */} +
+
+ {REASONS.map((reason) => ( + + ))} +
+ + {isEtcOpen && ( +
+