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 && (
+
+ )}
+
+ {/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
+
+
+ 공급 희망 가격 (선택)
+
+
+ handlePriceChange(e.target.value)}
+ placeholder="0"
+ disabled={isPending}
+ />
+ 원{isVAT && '(VAT 별도)'}
+ {koreanPrice && {koreanPrice}}
+
+
+
+ {/* 의견 (선택) — 결렬 폼과 동일 */}
+
+
+
+ {/* 푸터 — 목록 거부 팝업과 동일 규격 */}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/chat/lib/userButtonConfig.ts b/frontend/src/features/chat/lib/userButtonConfig.ts
index 0be4ded..5768a50 100644
--- a/frontend/src/features/chat/lib/userButtonConfig.ts
+++ b/frontend/src/features/chat/lib/userButtonConfig.ts
@@ -2,6 +2,17 @@ import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
+// 협상 거부 진입점을 숨기는 액션바 상태.
+// reject=봇이 이미 결렬을 선언(그 폼이 최종가를 받는다) / extra-info=타결 후 동의 단계 / loading=응답 대기.
+const NO_REJECT_TYPES = ['', 'loading', 'reject', 'extra-info']
+
+// 채팅 안에서 협상을 거부할 수 있는 상태인지 — 대화가 끝나지 않고 협력사 입력을 기다리는 동안만.
+export function canRejectNegotiation(messages: ChatMessage[], config: UserButtonConfig): boolean {
+ if (!messages || messages.length === 0) return false
+ if (messages[messages.length - 1].chat_end) return false
+ return !NO_REJECT_TYPES.includes(config.type)
+}
+
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
export function deriveUserButtonConfig(
messages: ChatMessage[],
diff --git a/frontend/src/features/list/components/GuidePopup.tsx b/frontend/src/features/list/components/GuidePopup.tsx
index 4e322a6..d6002df 100644
--- a/frontend/src/features/list/components/GuidePopup.tsx
+++ b/frontend/src/features/list/components/GuidePopup.tsx
@@ -29,7 +29,7 @@ export function GuidePopup({ onClose }: { onClose: () => void }) {
-
+
diff --git a/frontend/src/features/list/components/WorkspaceCards.tsx b/frontend/src/features/list/components/WorkspaceCards.tsx
index 0fb1009..1e35f9c 100644
--- a/frontend/src/features/list/components/WorkspaceCards.tsx
+++ b/frontend/src/features/list/components/WorkspaceCards.tsx
@@ -139,7 +139,7 @@ function Card({
onClick={() => onReject(item)}
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
- 거절
+ 협상 거부
)}
{canEnter && (
diff --git a/frontend/src/features/list/components/WorkspaceTable.tsx b/frontend/src/features/list/components/WorkspaceTable.tsx
index 3b8d408..3ff86c8 100644
--- a/frontend/src/features/list/components/WorkspaceTable.tsx
+++ b/frontend/src/features/list/components/WorkspaceTable.tsx
@@ -151,7 +151,7 @@ function Row({
onClick={() => onReject(item)}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
>
- 거절
+ 협상 거부
)}
{canEnter ? (
diff --git a/frontend/src/features/list/containers/ListWorkspace.tsx b/frontend/src/features/list/containers/ListWorkspace.tsx
index 9d1e736..ee42122 100644
--- a/frontend/src/features/list/containers/ListWorkspace.tsx
+++ b/frontend/src/features/list/containers/ListWorkspace.tsx
@@ -100,8 +100,8 @@ export function ListWorkspace() {
reject.mutate(
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
{
- onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
- onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
+ onSuccess: () => toast.warning('협상 거부가 완료되었습니다.'),
+ onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
},
)
}
diff --git a/frontend/src/features/list/lib/status.ts b/frontend/src/features/list/lib/status.ts
index e6af319..9083f9d 100644
--- a/frontend/src/features/list/lib/status.ts
+++ b/frontend/src/features/list/lib/status.ts
@@ -11,7 +11,7 @@ export const STATUS_META: Record = {
협상중: { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
협상완료: { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
미참여: { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
- 협상거부: { display: '협상 거절', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
+ 협상거부: { display: '협상 거부', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
}
export function statusMeta(label: string): StatusMeta {
diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py
index ae68ab2..cc847da 100644
--- a/negodata/backend/crud/quotation_crud.py
+++ b/negodata/backend/crud/quotation_crud.py
@@ -703,13 +703,14 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, 0
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
- """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price). 삭제 제외.
+ """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price). 삭제 제외.
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).
- target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
+ target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).
+ reject_price 는 거부 협력사의 공급 희망 가격 — 자동 마감 판정엔 안 쓰고 수동 직접 낙찰 후보에서만 본다."""
try:
query = (
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
- sessions.target_price, sessions.anchoring_price)
+ sessions.target_price, sessions.anchoring_price, sessions.reject_price)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
)
diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py
index a97cd7c..3786f82 100644
--- a/negodata/backend/crud/statistics_crud.py
+++ b/negodata/backend/crud/statistics_crud.py
@@ -76,14 +76,17 @@ class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
+ # 계약가는 coalesce(투찰가, 거부 시 공급 희망가) — 결렬 건을 담당자가 직접 낙찰하면 bid_price 가 없어
+ # 절감 집계에서 통째로 빠지기 때문. 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
try:
+ award_price = func.coalesce(sessions.bid_price, sessions.reject_price).label("bid_price")
stmt = (
select(
quotations.updated_at,
quotations.type,
items.category,
sessions.target_price,
- sessions.bid_price,
+ award_price,
sessions.anchoring_price,
)
.select_from(quotations)
@@ -92,7 +95,7 @@ class StatisticsCRUD(IStatisticsCRUD):
and_(
sessions.quotation_id == quotations.qt_id,
sessions.supplier_id == quotations.preferred_sp_id,
- sessions.bid_price.isnot(None),
+ or_(sessions.bid_price.isnot(None), sessions.reject_price.isnot(None)),
sessions.deleted == False, # noqa: E712
),
)
diff --git a/negodata/backend/services/quotation/closing.py b/negodata/backend/services/quotation/closing.py
index 8f60e65..ace5bc0 100644
--- a/negodata/backend/services/quotation/closing.py
+++ b/negodata/backend/services/quotation/closing.py
@@ -10,6 +10,17 @@ from router.v1.quotation.protocol import Res_Quotation
from services.notification import create_notification
+def _award_price(row) -> Optional[int]:
+ """직접 낙찰 시 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격.
+ 둘 다 없으면(가격을 한 번도 안 낸 협력사) None → 낙찰 후보가 아니다.
+ 자동 마감 판정(close_and_decide)은 이 함수를 쓰지 않는다 — 자동 낙찰은 투찰가만 본다."""
+ if row.status == SessionStatus.DONE.value and row.bid_price is not None:
+ return int(row.bid_price)
+ if row.status == SessionStatus.REJECTED.value and row.reject_price is not None:
+ return int(row.reject_price)
+ return None
+
+
class ClosingMixin:
@staticmethod
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
@@ -159,7 +170,9 @@ class ClosingMixin:
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
- 투찰한 협상완료(DONE) 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
+ 가격을 써낸 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
+ 후보는 투찰한 협상완료(DONE) + 공급 희망 가격을 남긴 협상거부(REJECTED) — 협상이 결렬돼도
+ 마지막에 제출한 가격으로 계약을 진행하는 운영 방침을 시스템에서 그대로 처리하기 위함이다.
자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다."""
res = Res_Quotation()
@@ -187,23 +200,20 @@ class ClosingMixin:
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
return res
- # 낙찰 후보 = 투찰한 협상완료(DONE) 세션. close_and_decide 와 같은 조회(list_sessions_status,
- # 공급사 삭제돼도 포함되는 outerjoin)를 써서 자동낙찰과 후보 집합을 일치시킨다.
+ # 낙찰 후보 = 가격을 써낸 세션. close_and_decide 와 같은 조회(list_sessions_status,
+ # 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
)
rows = rows if err_type == ErrorType.SUCCESS else []
- winner = next(
- (r for r in rows
- if r.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid),
- None,
- )
+ winner = next((r for r in rows if r.supplier_id == sp_uuid and _award_price(r) is not None), None)
if winner is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
- res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다."
+ res.msg = "선택한 협력사는 이 견적의 낙찰 후보(가격을 제출한 협력사)가 아닙니다."
return res
+ winner_price = _award_price(winner)
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
@@ -219,7 +229,7 @@ class ClosingMixin:
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
- "winner_name": winner.name, "winner_price": winner.bid_price, "manual": True},
+ "winner_name": winner.name, "winner_price": winner_price, "manual": True},
ref_qt_id=qt_uuid,
)
return await self.get_quotation(qt_id, company_id)
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx
index 66cd5df..6ca9500 100644
--- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx
+++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx
@@ -9,7 +9,7 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@
import { SessionStatus } from '@/api/generated/model';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { StatusPill, sessionStatusTone } from './StatusPill';
-import { mapServerSessionView, sessionStatusLabel } from '../../types';
+import { awardPrice, mapServerSessionView, sessionStatusLabel } from '../../types';
import { useCompanySettings } from '@/features/settings/useCompanySettings';
type SessionView = ReturnType;
@@ -57,22 +57,24 @@ export function SessionsStatusTab({
const [awarding, setAwarding] = useState(false);
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
- // 낙찰 후보 = 투찰한 협상완료(DONE) 협력사. 개찰 견적에서 이 중 하나를 담당자가 직접 낙찰한다.
- const candidates = sessionViews.filter((s) => s.status === SessionStatus.DONE && s.bid_price != null);
+ // 낙찰 후보 = 가격을 써낸 협력사(투찰한 협상완료 + 공급 희망가를 남긴 협상거부).
+ // 협상이 결렬돼도 최종 제출가로 계약을 진행하므로 거부 건도 후보에 넣는다.
+ const candidates = sessionViews.filter((s) => awardPrice(s) != null);
const showAward = canAward && candidates.length > 0;
- // 최저 투찰가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
- const lowestBid = candidates.length ? Math.min(...candidates.map((s) => s.bid_price as number)) : null;
+ // 최저가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
+ const lowestBid = candidates.length ? Math.min(...candidates.map((s) => awardPrice(s) as number)) : null;
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
const colCount = showAward ? 13 : 12;
const handleAward = async () => {
if (!selectedWinner) return;
const name = selectedWinner.supplier_name;
- const price = selectedWinner.bid_price != null ? `₩${selectedWinner.bid_price.toLocaleString()}` : '-';
+ const winnerPrice = awardPrice(selectedWinner);
+ const price = winnerPrice != null ? `₩${winnerPrice.toLocaleString()}` : '-';
if (
!(await confirm({
title: '직접 낙찰',
- description: `[${name}] (투찰가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
+ description: `[${name}] (계약가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
confirmText: '낙찰 확정',
}))
)
@@ -179,9 +181,9 @@ export function SessionsStatusTab({
투찰가
투찰시각
마감시각
- 거절사유
- 거절가격
- 거절배송방식
+ 거부사유
+ 거부가격
+ 거부배송방식
@@ -193,8 +195,8 @@ export function SessionsStatusTab({
)}
{sessionViews.map((sess) => {
- const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null;
- const isRecommended = isCandidate && sess.bid_price === lowestBid;
+ const isCandidate = awardPrice(sess) != null;
+ const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
return (
)}
{sessionViews.map((sess) => {
- const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null;
- const isRecommended = isCandidate && sess.bid_price === lowestBid;
+ const isCandidate = awardPrice(sess) != null;
+ const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
const rows: { label: string; value: string }[] = [
{ label: '상품', value: sess.item_name || '-' },
@@ -368,10 +370,10 @@ export function SessionsStatusTab({
{ label: '투찰시각', value: sess.bid_at || '-' },
{ label: '마감시각', value: sess.end_time || '-' },
];
- // 거절 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게).
- if (sess.reject_reason) rows.push({ label: '거절사유', value: sess.reject_reason });
- if (sess.reject_price) rows.push({ label: '거절가격', value: `₩${sess.reject_price.toLocaleString()}` });
- if (sess.reject_delivery_type) rows.push({ label: '거절배송방식', value: sess.reject_delivery_type });
+ // 거부 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게).
+ if (sess.reject_reason) rows.push({ label: '거부사유', value: sess.reject_reason });
+ if (sess.reject_price) rows.push({ label: '거부가격', value: `₩${sess.reject_price.toLocaleString()}` });
+ if (sess.reject_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
return (
diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts
index 3a58cda..1457f62 100644
--- a/negodata/front/src/features/quotations/types.ts
+++ b/negodata/front/src/features/quotations/types.ts
@@ -283,15 +283,23 @@ export type QuotationResultView = {
savingsRate: number | null;
};
+// 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격.
+// 결렬 건도 최종 제출가로 계약을 진행하므로 낙찰 후보·결과 표기가 같은 기준을 본다.
+export function awardPrice(s: SessionView): number | null {
+ if (s.status === SessionStatus.DONE && s.bid_price != null) return s.bid_price;
+ if (s.status === SessionStatus.REJECTED && s.reject_price != null) return s.reject_price;
+ return null;
+}
+
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
const winnerId = q.preferred_sp_id ?? null;
const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null;
- // 투찰가 있는 세션들 중 최저 = 잠정 낙찰가(우선협상자 미확정 시).
- const bids = sessions.map((s) => s.bid_price).filter((v): v is number => v != null);
+ // 가격을 써낸 세션들 중 최저 = 잠정 낙찰가(우선협상자 미확정 시).
+ const bids = sessions.map(awardPrice).filter((v): v is number => v != null);
const lowestBid = bids.length ? Math.min(...bids) : null;
const highestBid = bids.length ? Math.max(...bids) : null;
- const winnerPrice = winnerSession?.bid_price ?? lowestBid;
+ const winnerPrice = (winnerSession ? awardPrice(winnerSession) : null) ?? lowestBid;
const provisional = !winnerSession;
const sessionTarget =
diff --git a/negodata/front/src/pages/dev-design.tsx b/negodata/front/src/pages/dev-design.tsx
index 48eaecc..37d0791 100644
--- a/negodata/front/src/pages/dev-design.tsx
+++ b/negodata/front/src/pages/dev-design.tsx
@@ -84,7 +84,7 @@ export default function DevDesignPage() {
진행중
완료
주의
- 거절
+ 거부