[feat] negosium·negodata: 협상 진행 중 거부 + 결렬 건 최종 제출가로 직접 낙찰

협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고,
거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다.

negosium
- 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출,
  주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap
- 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다
- /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합
- 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업)

negodata
- 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부
- 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가)
- 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다
- 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…'

자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
This commit is contained in:
Mina Choi 2026-08-11 10:34:07 +09:00
parent f1e924931f
commit ac6366a898
21 changed files with 403 additions and 63 deletions

View File

@ -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:

View File

@ -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):

View File

@ -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(

View File

@ -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

View File

@ -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"], " ") # 공백만 → 사유 없음

View File

@ -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 {

View File

@ -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 (
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
<MobileStepBar />
@ -23,8 +35,9 @@ export function ChatSection() {
{/* 하단 액션 덱 */}
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
<UserButton {...config} />
<UserButton {...config} onReject={canReject ? () => setIsRejectOpen(true) : undefined} />
</div>
{isRejectOpen && <RejectPopup onClose={() => setIsRejectOpen(false)} />}
</div>
)
}

View File

@ -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 (
<div className={cn('flex w-full justify-center px-6 py-4 max-[1180px]:px-4', !isInputStep && 'overflow-x-auto')}>
<div className="flex justify-center">
<div
className={cn(
'relative flex w-full flex-wrap items-center justify-center gap-3 px-6 py-4 max-[1180px]:px-4',
!isInputStep && 'overflow-x-auto',
)}
>
<div className="flex items-center justify-center gap-3">
{type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />}
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
@ -53,6 +59,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <LoadingDots />}
</div>
{/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로),
좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */}
{onReject && (
<button
className={cn(
style.white,
'min-[1180px]:absolute min-[1180px]:right-8 min-[1180px]:top-1/2 min-[1180px]:-translate-y-1/2',
)}
onClick={onReject}
>
협상 거부
</button>
)}
</div>
)
}

View File

@ -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<string | null>(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 (
<Modal onClose={onClose}>
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
{/* 본문 */}
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
disabled={isPending}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
selectedReason === reason
? 'border-brand-600 bg-brand-light text-brand-600'
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
)}
>
{reason}
</button>
))}
</div>
{isEtcOpen && (
<div className="animate-fade-in">
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
rows={3}
disabled={isPending}
className={cn(
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
'placeholder:text-neutral-50 focus:ring-1',
showError
? 'border-destructive focus:border-destructive focus:ring-destructive'
: 'border-border focus:border-brand-600 focus:ring-brand-600',
)}
/>
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
</div>
)}
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
공급 희망 가격 <span className="font-medium text-neutral-60">(선택)</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
className={cn(
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
value={price ? parseInt(price).toLocaleString() : ''}
onChange={(e) => handlePriceChange(e.target.value)}
placeholder="0"
disabled={isPending}
/>
<span className="whitespace-nowrap text-sm text-neutral-70">원{isVAT && '(VAT 별도)'}</span>
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
</div>
</div>
{/* 의견 (선택) — 결렬 폼과 동일 */}
<div className="flex flex-col gap-1.5">
<div className="text-sm font-bold text-neutral-90">
의견 <span className="font-medium text-neutral-60">(선택)</span>
</div>
<textarea
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={isPending}
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
className={cn(
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
isPending
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
)}
/>
</div>
</div>
{/* 푸터 — 목록 거부 팝업과 동일 규격 */}
<div className="flex shrink-0 gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
>
취소
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
>
거부 처리
</button>
</div>
</div>
</Modal>
)
}

View File

@ -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[],

View File

@ -29,7 +29,7 @@ export function GuidePopup({ onClose }: { onClose: () => void }) {
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
<Row badge="협상 거절" tone="reject" text="내가 참여를 거절한 건입니다." />
<Row badge="협상 거부" tone="reject" text="내가 거부한 건입니다." />
</Section>
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">

View File

@ -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]"
>
거절
협상 거부
</button>
)}
{canEnter && (

View File

@ -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]"
>
거절
협상 거부
</button>
)}
{canEnter ? (

View File

@ -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, '거부 처리에 실패했습니다.')),
},
)
}

View File

@ -11,7 +11,7 @@ export const STATUS_META: Record<string, StatusMeta> = {
협상중: { 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 {

View File

@ -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
)

View File

@ -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
),
)

View File

@ -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)

View File

@ -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<typeof mapServerSessionView>;
@ -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({
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
<TableHead className="p-3 font-semibold">마감시각</TableHead>
<TableHead className="p-3 font-semibold font-sans">거절사유</TableHead>
<TableHead className="p-3 font-semibold text-right">거절가격</TableHead>
<TableHead className="p-3 font-semibold font-sans">거절배송방식</TableHead>
<TableHead className="p-3 font-semibold font-sans">거부사유</TableHead>
<TableHead className="p-3 font-semibold text-right">거부가격</TableHead>
<TableHead className="p-3 font-semibold font-sans">거부배송방식</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
@ -193,8 +195,8 @@ export function SessionsStatusTab({
</TableRow>
)}
{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 (
<TableRow
@ -355,8 +357,8 @@ export function SessionsStatusTab({
</Typography>
)}
{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 (
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
<div className="flex items-center gap-2">

View File

@ -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 =

View File

@ -84,7 +84,7 @@ export default function DevDesignPage() {
<StatusPill tone="blue">진행중</StatusPill>
<StatusPill tone="emerald">완료</StatusPill>
<StatusPill tone="amber">주의</StatusPill>
<StatusPill tone="rose">거절</StatusPill>
<StatusPill tone="rose">거부</StatusPill>
</div>
</Section>