[feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링

협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.

agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
  (Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
  없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다

negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
  재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
  없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)

negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
  담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
  계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
  KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
  시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
  라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
  협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림

테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
This commit is contained in:
Mina Choi 2026-08-11 16:10:16 +09:00
parent d02ba20520
commit 6eb4dc26f8
70 changed files with 1695 additions and 739 deletions

View File

@ -320,7 +320,8 @@ class ChatService:
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id) await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
session.context["last_state"] = idx session.context["last_state"] = idx
session.context["last_action"] = decision.action_id session.context["last_action"] = decision.action_id
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False) await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
decision=decision, policy=policy)
res.card_id = card_id res.card_id = card_id
res.policy = policy.name res.policy = policy.name
@ -420,7 +421,7 @@ class ChatService:
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True)) policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action) await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
await self._log(repo, session, last_state, last_action, await self._log(repo, session, last_state, last_action,
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True) self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
res.updated_q = float(policy.qtable.q[last_state, last_action]) res.updated_q = float(policy.qtable.q[last_state, last_action])
@staticmethod @staticmethod
@ -499,13 +500,26 @@ class ChatService:
prior[a] = 0.3 * (n - rank) / n prior[a] = 0.3 * (n - rank) / n
return prior if prior.any() else None return prior if prior.any() else None
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done): async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
decision=None, policy=None):
data = { data = {
"session_id": session.session_id, "state_index": state_index, "action_id": action_id, "session_id": session.session_id, "state_index": state_index, "action_id": action_id,
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity, "card_id": card_id, "snapshot": snap.to_dict(),
"turn": snap.round_number, "reward": reward.total, "done": done, "turn": snap.round_number, "reward": reward.total, "done": done,
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None, "settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
} }
if decision is not None and policy is not None:
# 선택 근거(Q값·UCB·방문수)를 그 턴 값 그대로 박제한다 — 사후에 q_values 를 읽으면 이미 갱신된 뒤라
# "그때 왜 이 카드였나"를 복원할 수 없다. negodata 협상 학습 화면이 이 컬럼들을 읽는다.
# 종료 로그(카드 선택 없는 done 행)는 decision 이 없어 NULL — 화면 집계(avg/max)가 무시한다.
data.update({
"propensity": decision.propensity,
"available_actions": decision.available_actions,
"q_value_at_selection": decision.q_value,
"ucb_score_at_selection": decision.ucb_score,
"visit_count_at_selection": int(policy.qtable.visits[state_index, action_id]),
"total_visits_at_selection": int(policy.qtable.state_visits(state_index)),
})
try: try:
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)]) await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
except Exception as ex: except Exception as ex:

View File

@ -97,10 +97,10 @@ class NegotiationService:
# 7) experience_logs 기록 # 7) experience_logs 기록
if req.log: if req.log:
res.logged = await self._log(engine, session_id, idx, decision, snap, reward) res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
return res return res
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool: async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
repo = LearningRepository(engine.company_id) repo = LearningRepository(engine.company_id)
data = { data = {
"session_id": session_id, "state_index": idx, "action_id": decision.action_id, "session_id": session_id, "state_index": idx, "action_id": decision.action_id,
@ -108,6 +108,8 @@ class NegotiationService:
"turn": snap.round_number, "available_actions": decision.available_actions, "turn": snap.round_number, "available_actions": decision.available_actions,
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING, "reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score, "q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
"visit_count_at_selection": int(policy.qtable.visits[idx, decision.action_id]),
"total_visits_at_selection": int(policy.qtable.state_visits(idx)),
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None, "settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
} }
try: try:

View File

@ -143,6 +143,9 @@ class SessionCRUD(ISessionCRUD):
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조 sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다. # 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712 select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
# 거부 건이 제출한 사유·희망가 — 목록의 '거부 내역' 열람용(의견은 custom.opinion).
sessions.reject_reason,
sessions.reject_price,
) )
.join(items, items.item_id == sessions.item_id) .join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id) .join(quotations, quotations.qt_id == sessions.quotation_id)
@ -224,7 +227,7 @@ class SessionCRUD(ISessionCRUD):
) -> ErrorType: ) -> ErrorType:
try: try:
values = {"status": status, "reject_reason": reject_reason} values = {"status": status, "reject_reason": reject_reason}
# 공급 희망 가격은 채팅 중 거부에서만 들어온다 — 목록 거부는 가격 없이 사유만 남긴다. # 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
if reject_price is not None: if reject_price is not None:
values["reject_price"] = reject_price values["reject_price"] = reject_price
query = ( query = (

View File

@ -79,6 +79,8 @@ class Res_ChatInit(Res_WebPacketProtocol):
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)") item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)") item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용") custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값") labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")

View File

@ -22,6 +22,8 @@ class ListItem(WebPacketProtocol):
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열") renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)") result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용") has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
class Res_SessionList(Res_WebPacketProtocol): class Res_SessionList(Res_WebPacketProtocol):
@ -37,7 +39,7 @@ class Res_Participate(Res_WebPacketProtocol):
class Req_Reject(WebPacketProtocol): class Req_Reject(WebPacketProtocol):
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)") reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 채팅 중 거부에서만 들어온다 — 목록 거부엔 없음") reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합") opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")

View File

@ -71,6 +71,14 @@ class ChatService:
opinion = None opinion = None
if ", 의견-" in s: if ", 의견-" in s:
s, opinion = s.split(", 의견-", 1) s, opinion = s.split(", 의견-", 1)
# 폼이 아닌 자유 입력("협상 포기합니다" 등)은 원문이 곧 사유다. 폼 마커가 없으면 가격도 읽지 않는다
# — 문장에 섞인 숫자를 희망가로 오인해 저장하는 것을 막는다.
if "합의불가사유-" not in s and "공급희망가격-" not in s:
return {
"offer_price": None,
"reason": s.strip()[:255] or None,
"opinion": (opinion.strip() or None) if opinion is not None else None,
}
reason = None reason = None
if ", 합의불가사유-" in s: if ", 합의불가사유-" in s:
price_part, reason = s.split(", 합의불가사유-", 1) price_part, reason = s.split(", 합의불가사유-", 1)
@ -251,6 +259,9 @@ class ChatService:
res.item_vat_yn = item.vat_yn res.item_vat_yn = item.vat_yn
res.item_delivery_fee_yn = item.delivery_fee_yn res.item_delivery_fee_yn = item.delivery_fee_yn
res.custom = sess.custom or {} res.custom = sess.custom or {}
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
res.reject_reason = sess.reject_reason or ""
res.reject_price = sess.reject_price
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백. # 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
_e, settings = await DB_SESSION_MNG.execute_lambda( _e, settings = await DB_SESSION_MNG.execute_lambda(

View File

@ -184,6 +184,8 @@ class NegotiationService:
renegotiation_memo=renego.get("memo") or "", renegotiation_memo=renego.get("memo") or "",
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]), result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
has_chat=bool(r[15]), has_chat=bool(r[15]),
reject_reason=r[16] or "",
reject_price=r[17],
) )
@staticmethod @staticmethod

View File

@ -220,6 +220,23 @@ async def test_chat_init_returns_meta(client, chat_seed):
assert body["quotation_end_time"] # 타이머용 마감 시각 assert body["quotation_end_time"] # 타이머용 마감 시각
async def test_chat_init_returns_reject_detail(client, chat_seed, db_engine):
"""검증: 협상 거부로 끝난 세션에 재진입('결과 보기')했을 때의 init 응답.
기대결과: 대화에 남지 않는 제출 내역(reject_reason·reject_price) 실려 열람 카드를 그릴 있다."""
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "단종", "reject_price": 91000, "opinion": "후속 모델로 제안 가능합니다"},
)
body = (await _init(client, token, sid)).json()
assert body["session_status"] == 5
assert body["reject_reason"] == "단종"
assert body["reject_price"] == 91000
assert body["custom"]["opinion"] == "후속 모델로 제안 가능합니다"
async def test_chat_init_forbidden_other_supplier(client, chat_seed): async def test_chat_init_forbidden_other_supplier(client, chat_seed):
token = await _login_token(client) token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["X"])).json() body = (await _init(client, token, chat_seed["sids"]["X"])).json()

View File

@ -419,6 +419,31 @@ async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
assert row.reject_price is None and row.custom is None assert row.reject_price is None and row.custom is None
async def test_list_returns_reject_detail(client, nego_seed):
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
token = await _login_token(client)
sid = nego_seed["sids"]["B"]
await client.post(
f"/v1/negotiation/sessions/{sid}/reject",
headers={"Authorization": f"Bearer {token}"},
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
)
items = (await _list(client, token)).json()["items"]
row = next(i for i in items if i["session_id"] == str(sid))
assert row["reject_reason"] == "품절"
assert row["reject_price"] == 77000
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
async def test_list_reject_detail_empty_for_active(client, nego_seed):
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
token = await _login_token(client)
items = (await _list(client, token)).json()["items"]
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
assert row["reject_reason"] == ""
assert row.get("reject_price") is None
async def test_reject_empty_reason(client, nego_seed): async def test_reject_empty_reason(client, nego_seed):
token = await _login_token(client) token = await _login_token(client)
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음 r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음

View File

@ -49,6 +49,8 @@ export interface ChatInitResponse {
item_delivery_fee_yn?: boolean item_delivery_fee_yn?: boolean
custom?: Record<string, unknown> custom?: Record<string, unknown>
labels?: Record<string, string> labels?: Record<string, string>
reject_reason?: string
reject_price?: number | null
} }
export interface ChatMessagesResponse { export interface ChatMessagesResponse {
@ -108,5 +110,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
quotation_memo: r.quotation_memo ?? '', quotation_memo: r.quotation_memo ?? '',
quotation_end_time: r.quotation_end_time ?? '', quotation_end_time: r.quotation_end_time ?? '',
labels: r.labels ?? {}, labels: r.labels ?? {},
reject_reason: r.reject_reason ?? '',
reject_price: r.reject_price ?? null,
} }
} }

View File

@ -63,6 +63,8 @@ export interface SessionListItem {
renegotiation_memo: string // 담당자 심사 메모(반려 사유) renegotiation_memo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용 has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
} }
/** 공급사 관점 협상 결과 (sessions 파생) */ /** 공급사 관점 협상 결과 (sessions 파생) */
@ -119,9 +121,9 @@ export interface ParticipateResponse {
} }
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) --------------------- // --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 엔드포인트를 쓴다. // 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다.
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. // reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수.
// reject_price/opinion: 채팅 중 거부에서만 채운다(목록 거부는 사유만). // reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
export interface RejectRequest { export interface RejectRequest {
reject_reason: string reject_reason: string
reject_price?: number reject_price?: number

View File

@ -0,0 +1,81 @@
import { X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { numberToKorean } from '@/lib'
export interface RejectDetailPopupProps {
onClose: () => void
/** 어느 건인지 식별용 부제 (견적번호 · 상품명) */
subtitle?: string
/** sessions.reject_reason — 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
reason: string
/** sessions.reject_price — 미입력이면 null */
price: number | null
/** sessions.custom.opinion */
opinion: string
/** 'VAT포함'/'VAT별도' — 품목 VAT 를 아는 화면에서만 넘긴다 */
vatLabel?: string
}
// 협상 거부 사유 열람 팝업 — 목록의 '협상완료 부가정보(보기)'와 같은 규격(읽기전용 필드 나열).
// 거부는 제출 내역이 대화에 남지 않아 세션 컬럼이 유일한 기록이다.
export function RejectDetailPopup({ onClose, subtitle, reason, price, opinion, vatLabel }: RejectDetailPopupProps) {
const priceText = price
? `${price.toLocaleString()}원 (${numberToKorean(price)}원)${vatLabel ? ` ${vatLabel}` : ''}`
: '미입력'
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90"> ()</h3>
<p className="mt-0.5 text-xs text-neutral-60">
{subtitle}
{subtitle ? ' · ' : ''} .
</p>
</div>
<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="space-y-4 p-5">
<Field label="거부 사유" value={reason || '-'} />
<Field label="공급 희망 가격" value={priceText} />
<Field label="의견" value={opinion} rows={2} placeholder="남긴 의견 없음" />
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
>
</button>
</div>
</div>
</Modal>
)
}
// 부가정보 팝업의 읽기전용 필드와 같은 모양(라벨 + disabled 입력).
function Field({ label, value, rows, placeholder }: { label: string; value: string; rows?: number; placeholder?: string }) {
const style =
'w-full rounded-xl border border-border bg-neutral-10 px-3 text-sm text-neutral-60 cursor-not-allowed outline-none'
return (
<div className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{label}</label>
{rows ? (
<textarea value={value} rows={rows} disabled placeholder={placeholder} className={`${style} resize-none py-2`} />
) : (
<input type="text" value={value} disabled placeholder={placeholder} className={`${style} h-11`} />
)}
</div>
)
}

View File

@ -0,0 +1,200 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { cn, numberToKorean } from '@/lib'
const MAX_PRICE = 999999999999999
// 거부 사유는 협상을 시작하지 않겠다는 사유(단종/품절/기타)다. 협상해보고 합의가 안 된
// 결렬 폼(단가인상·수량 포함 5종)과는 성격이 달라 목록을 맞추지 않는다.
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectSubmitPayload {
/** 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
reject_reason: string
reject_price?: number
opinion?: string
}
export interface RejectPopupProps {
onClose: () => void
onSubmit: (payload: RejectSubmitPayload) => void
isPending?: boolean
/** 가격 옆 'VAT 별도' 표기 — 품목 VAT 를 아는 화면(채팅)에서만 켠다 */
isVatExcluded?: boolean
}
// 협상 거부 팝업 — 목록과 채팅 양쪽이 같은 폼·같은 엔드포인트(/reject)를 쓴다.
// 사유는 필수, 공급 희망 가격과 의견은 선택.
export function RejectPopup({ onClose, onSubmit, isPending = false, isVatExcluded = false }: RejectPopupProps) {
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 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
onSubmit({
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
// 빈 값이면 아예 보내지 않아 컬럼을 건드리지 않는다.
...(price ? { reject_price: parseInt(price) } : {}),
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
})
}
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">{isVatExcluded && '(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

@ -7,3 +7,7 @@ export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo' export type { LogoProps, LogoVariant } from '@/components/Logo'
export { ErrorPage } from '@/components/ErrorPage' export { ErrorPage } from '@/components/ErrorPage'
export type { ErrorPageProps } from '@/components/ErrorPage' export type { ErrorPageProps } from '@/components/ErrorPage'
export { RejectPopup } from '@/components/RejectPopup'
export type { RejectPopupProps, RejectSubmitPayload } from '@/components/RejectPopup'
export { RejectDetailPopup } from '@/components/RejectDetailPopup'
export type { RejectDetailPopupProps } from '@/components/RejectDetailPopup'

View File

@ -7,6 +7,8 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis'
import { Indicator } from '@/features/chat/components/templates/Indicator' import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary' import { Summary } from '@/features/chat/components/templates/Summary'
import { BidSummary } from '@/features/chat/components/templates/BidSummary' import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectSummary } from '@/features/chat/components/templates/RejectSummary'
import { RejectedNotice } from '@/features/chat/components/templates/RejectedNotice'
const AI_LABEL = '아이마켓코리아 (구매담당자)' const AI_LABEL = '아이마켓코리아 (구매담당자)'
@ -57,6 +59,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
{chats.map((message, index) => ( {chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} /> <MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
))} ))}
<RejectedNotice />
{isLoading && <TypingBubble />} {isLoading && <TypingBubble />}
</div> </div>
) )
@ -87,11 +90,16 @@ const MessageItem = memo(function MessageItem({
}) { }) {
const isBot = message.sender === 'bot' const isBot = message.sender === 'bot'
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음) // 결렬 폼 답변은 제출 문자열(공급희망가격-…, 합의불가사유-…)이라 말풍선 대신 요약 카드로 낸다.
// 폼은 세션 종료와 함께 사라지므로 이 카드가 없으면 결렬 사유가 대화에 안 남는다.
if (!isBot && currentIndex > 0) { if (!isBot && currentIndex > 0) {
const prev = messages[currentIndex - 1] const prev = messages[currentIndex - 1]
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') { if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
return null return (
<div className="mb-5 animate-fade-in">
<RejectSummary script={message.script || ''} />
</div>
)
} }
} }

View File

@ -1,23 +1,20 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib' import { cn } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber' import { numberToKorean } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls' import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
import { OtherReason } from '@/features/chat/components/templates/OtherReason' import { OtherReason } from '@/features/chat/components/templates/OtherReason'
import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm' import {
findRestoreScript,
restoreRejectRSP,
extractPart,
REJECT_REASONS,
} from '@/features/chat/lib/rejectForm'
const MAX_PRICE = 999999999999999 const MAX_PRICE = 999999999999999
// 합의 불가 사유 프리셋 (rejectRSP 와 동일 목록)
const REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
]
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견. // 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더). // rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
export function RejectForm() { export function RejectForm() {
@ -129,7 +126,7 @@ export function RejectForm() {
<div className="flex w-full gap-3"> <div className="flex w-full gap-3">
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90"> </div> <div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90"> </div>
<div className="flex w-full flex-col mt-2 gap-2"> <div className="flex w-full flex-col mt-2 gap-2">
{REASONS.map((r) => ( {REJECT_REASONS.map((r) => (
<SelectRadio <SelectRadio
key={r.value} key={r.value}
text={r.text} text={r.text}

View File

@ -1,71 +1,25 @@
import { useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { X } from 'lucide-react' import { RejectPopup as RejectPopupForm } from '@/components'
import { Modal } from '@/components'
import { getApiErrorMessage, useRejectMutation } from '@/apis' import { getApiErrorMessage, useRejectMutation } from '@/apis'
import { cn, toast } from '@/lib' import { toast } from '@/lib'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
const MAX_PRICE = 999999999999999 // 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
// 사유 목록은 목록 화면의 거부 팝업과 동일하게 맞춘다.
const REASONS = ['단종', '품절', '기타'] as const
// 협상 진행 중 거부 팝업 — 목록의 거부 팝업과 같은 어휘·같은 엔드포인트를 쓰고,
// 대화 중이라 이미 오간 가격이 있으므로 공급 희망 가격과 의견을 더 받는다.
export function RejectPopup({ onClose }: { onClose: () => void }) { export function RejectPopup({ onClose }: { onClose: () => void }) {
const navigate = useNavigate() const navigate = useNavigate()
const reject = useRejectMutation() const reject = useRejectMutation()
const sessionId = useChatStore((s) => s.sessionId) const sessionId = useChatStore((s) => s.sessionId)
const itemVatYn = useChatInitStore((s) => s.item_vat_yn) 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
return (
<RejectPopupForm
onClose={onClose}
isPending={reject.isPending}
isVatExcluded={itemVatYn === 'VAT별도'}
onSubmit={(request) =>
reject.mutate( reject.mutate(
{ { sessionId, request },
sessionId,
request: {
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
// 선택 입력 — 빈 값이면 보내지 않아 컬럼을 건드리지 않는다.
...(price ? { reject_price: parseInt(price) } : {}),
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
},
},
{ {
onSuccess: () => { onSuccess: () => {
onClose() onClose()
@ -76,135 +30,6 @@ export function RejectPopup({ onClose }: { onClose: () => void }) {
}, },
) )
} }
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

@ -1,5 +1,5 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber' import { numberToKorean } from '@/lib'
import { useMeQuery } from '@/apis' import { useMeQuery } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type' import type { SessionField } from '@/apis/auth/auth.type'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'

View File

@ -0,0 +1,52 @@
import { AlertTriangle } from 'lucide-react'
import { numberToKorean } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { parseRejectSubmission } from '@/features/chat/lib/rejectForm'
// 결렬 제출 요약 — 타결의 BidSummary 와 대칭.
export function RejectSummary({ script }: { script: string }) {
// VAT 표기는 회사 설정 기준(item_vat_yn). 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const { price, reasonLabel, reasonDetail, opinion, extras } = parseRejectSubmission(script)
return (
<div className="w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="size-5 text-[#E5484D]" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
<div className="divide-y divide-border/60 rounded-xl border border-border">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}</span>
<span className="text-neutral-70"> ({numberToKorean(price)})</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span>
</div>
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right">
<span className="text-sm font-semibold text-neutral-90">{reasonLabel || '-'}</span>
{reasonDetail && <span className="mt-1 block text-xs text-neutral-70">{reasonDetail}</span>}
</span>
</div>
{extras.map((e) => (
<Row key={e.label} label={e.label} value={e.value} />
))}
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
</div>
)
}

View File

@ -0,0 +1,56 @@
import { AlertTriangle } from 'lucide-react'
import { SessionStatus } from '@/apis'
import { numberToKorean } from '@/lib'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 거부로 끝난 세션의 제출 내역 — 결렬의 RejectSummary 와 같은 카드 규격.
// 거부는 팝업으로 내 대화에 남지 않아 대화 끝에 따로 붙인다.
export function RejectedNotice() {
const sessionStatus = useChatInitStore((s) => s.session_status)
const reason = useChatInitStore((s) => s.reject_reason)
const price = useChatInitStore((s) => s.reject_price)
const custom = useChatInitStore((s) => s.custom)
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
if (sessionStatus !== SessionStatus.REJECTED || !reason) return null
const opinion = String(custom?.opinion ?? '')
return (
<div className="mb-6 w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="size-5 text-[#E5484D]" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
<div className="divide-y divide-border/60 rounded-xl border border-border">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">
{price ? (
<>
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}</span>
<span className="text-neutral-70"> ({numberToKorean(price)})</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</>
) : (
<span className="font-semibold text-neutral-90"></span>
)}
</span>
</div>
<Row label="거부 사유" value={reason} />
{/* 의견은 타결·결렬·거부 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
</div>
)
}

View File

@ -1,5 +1,5 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber' import { numberToKorean } from '@/lib'
import { formatLeadTime } from '@/features/chat/lib/format' import { formatLeadTime } from '@/features/chat/lib/format'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import type { ChatSummary } from '@/features/chat/types' import type { ChatSummary } from '@/features/chat/types'

View File

@ -1,5 +1,13 @@
import type { ChatMessage } from '@/features/chat/types' import type { ChatMessage } from '@/features/chat/types'
// value = sessions.reject_reason 에 저장되는 값
export const REJECT_REASONS = [
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
] as const
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환 // reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null { export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
const reversed = [...messages].reverse() const reversed = [...messages].reverse()
@ -22,3 +30,28 @@ export function restoreRejectRSP(script: string | null) {
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') } if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
return { price, selectedReason: reasonRaw, reason: '' } return { price, selectedReason: reasonRaw, reason: '' }
} }
// 제출 script → 표시값
export function parseRejectSubmission(script: string | null) {
const { price, selectedReason, reason } = restoreRejectRSP(script)
const preset = REJECT_REASONS.find((r) => r.value === selectedReason)
return {
price: price ? parseInt(price) : 0,
reasonLabel: selectedReason,
reasonDetail: selectedReason === '기타' ? reason : (preset?.text ?? ''),
opinion: extractPart(script, '의견-'),
extras: extraParts(script),
}
}
// 통합 폼 이전 제출분의 '배송형태-' 등. 사유 뒤는 자유서술이 섞여 앞 구간만 훑는다.
function extraParts(script: string | null): { label: string; value: string }[] {
const head = (script ?? '').split(', 의견-')[0].split(', 합의불가사유-')[0]
return head
.split(', ')
.filter((p) => p && !p.startsWith('공급희망가격-'))
.map((p) => {
const i = p.indexOf('-')
return i > 0 ? { label: p.slice(0, i), value: p.slice(i + 1) } : { label: '항목', value: p }
})
}

View File

@ -32,6 +32,8 @@ const initialState: ChatInitData = {
quotation_end_time: '', quotation_end_time: '',
custom: {}, custom: {},
labels: {}, labels: {},
reject_reason: '',
reject_price: null,
} }
export const useChatInitStore = create<ChatInitStore>((set) => ({ export const useChatInitStore = create<ChatInitStore>((set) => ({

View File

@ -86,4 +86,6 @@ export type ChatInitData = {
quotation_end_time: string quotation_end_time: string
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용) custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용 labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
reject_reason: string // 협상 거부로 끝난 세션의 제출 사유(의견은 custom.opinion)
reject_price: number | null // 거부와 함께 낸 공급 희망 가격(원)
} }

View File

@ -1,124 +0,0 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { cn } from '@/lib'
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectPopupProps {
onClose: () => void
/** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
onSubmit: (reason: string) => void
}
// 거부 사유 입력 팝업 (단종/품절/기타).
export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
{/* 헤더 */}
<div className="flex 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="space-y-4 p-5">
<div className="grid grid-cols-3 gap-2">
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
className={cn(
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
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}
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>
{/* 푸터 */}
<div className="flex gap-2 border-t border-border p-4">
<button
type="button"
onClick={onClose}
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]"
>
</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

@ -13,10 +13,11 @@ interface WorkspaceCardsProps {
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
} }
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceCardsProps) { export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceCardsProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@ -31,7 +32,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
return ( return (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{items.map((item) => ( {items.map((item) => (
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} /> <Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
))} ))}
</div> </div>
) )
@ -45,6 +46,7 @@ function Card({
onExtraInfo, onExtraInfo,
onRenegotiate, onRenegotiate,
onMemo, onMemo,
onRejectDetail,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
@ -53,11 +55,14 @@ function Card({
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}) { }) {
const meta = statusMeta(item.session_status) const meta = statusMeta(item.session_status)
const ended = isEndedStatus(item.session_status) const ended = isEndedStatus(item.session_status)
const canEnter = canEnterChat(item.session_status, item.hasChat) const canEnter = canEnterChat(item.session_status, item.hasChat)
const canReject = ['협상생성', '협상중'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status)
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
const isRejected = item.session_status === '협상거부'
const isDone = item.session_status === '협상완료' const isDone = item.session_status === '협상완료'
const enterLabel = ended ? '결과 보기' : '협상 입장' const enterLabel = ended ? '결과 보기' : '협상 입장'
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다. // 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
@ -113,8 +118,17 @@ function Card({
</div> </div>
))} ))}
{(canEnter || canReject || isDone || item.renegotiable) && ( {(canEnter || canReject || isDone || isRejected || item.renegotiable) && (
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
{isRejected && (
<button
type="button"
onClick={() => onRejectDetail(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
</button>
)}
{item.renegotiable && ( {item.renegotiable && (
<button <button
type="button" type="button"

View File

@ -14,13 +14,14 @@ interface WorkspaceTableProps {
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
} }
// th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확). // th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap' const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80' const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceTableProps) { export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceTableProps) {
return ( return (
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse"> <table className="w-full min-w-[900px] border-collapse">
@ -46,7 +47,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
</StateRow> </StateRow>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} /> <Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
)) ))
)} )}
</tbody> </tbody>
@ -63,6 +64,7 @@ function Row({
onExtraInfo, onExtraInfo,
onRenegotiate, onRenegotiate,
onMemo, onMemo,
onRejectDetail,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
@ -71,11 +73,14 @@ function Row({
onExtraInfo: (item: ListItem) => void onExtraInfo: (item: ListItem) => void
onRenegotiate: (item: ListItem) => void onRenegotiate: (item: ListItem) => void
onMemo: (item: ListItem) => void onMemo: (item: ListItem) => void
onRejectDetail: (item: ListItem) => void
}) { }) {
const meta = statusMeta(item.session_status) const meta = statusMeta(item.session_status)
const ended = isEndedStatus(item.session_status) const ended = isEndedStatus(item.session_status)
const canEnter = canEnterChat(item.session_status, item.hasChat) const canEnter = canEnterChat(item.session_status, item.hasChat)
const canReject = ['협상생성', '협상중'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status)
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
const isRejected = item.session_status === '협상거부'
const isDone = item.session_status === '협상완료' const isDone = item.session_status === '협상완료'
const enterLabel = ended ? '결과 보기' : '협상 입장' const enterLabel = ended ? '결과 보기' : '협상 입장'
@ -145,6 +150,15 @@ function Row({
</button> </button>
)} )}
{isRejected && (
<button
type="button"
onClick={() => onRejectDetail(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
</button>
)}
{canReject && ( {canReject && (
<button <button
type="button" type="button"

View File

@ -10,6 +10,7 @@ import {
useRequestRenegotiationMutation, useRequestRenegotiationMutation,
useSaveExtraInfoMutation, useSaveExtraInfoMutation,
} from '@/apis' } from '@/apis'
import { RejectDetailPopup, RejectPopup, type RejectSubmitPayload } from '@/components'
import { cn, toast } from '@/lib' import { cn, toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList' import { useList } from '@/features/list/hooks/useList'
import { useListStore } from '@/features/list/stores/useListStore' import { useListStore } from '@/features/list/stores/useListStore'
@ -19,7 +20,6 @@ import { SortControl } from '@/features/list/components/SortControl'
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable' import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards' import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
import { Pagination } from '@/features/list/components/Pagination' import { Pagination } from '@/features/list/components/Pagination'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup' import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup' import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup' import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
@ -68,6 +68,7 @@ export function ListWorkspace() {
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null) const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null) const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null) const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
const [rejectDetailTarget, setRejectDetailTarget] = useState<ListItem | null>(null)
const [guideOpen, setGuideOpen] = useState(false) const [guideOpen, setGuideOpen] = useState(false)
const handleEnter = (item: ListItem) => { const handleEnter = (item: ListItem) => {
@ -95,12 +96,15 @@ export function ListWorkspace() {
setRejectTarget(item) setRejectTarget(item)
} }
const handleRejectSubmit = (reason: string) => { const handleRejectSubmit = (request: RejectSubmitPayload) => {
if (!rejectTarget) return if (!rejectTarget) return
reject.mutate( reject.mutate(
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } }, { sessionId: rejectTarget.session_id, request },
{ {
onSuccess: () => toast.warning('협상 거부가 완료되었습니다.'), onSuccess: () => {
setRejectTarget(null)
toast.warning('협상 거부가 완료되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')), onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
}, },
) )
@ -190,6 +194,7 @@ export function ListWorkspace() {
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget} onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget} onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">
@ -202,6 +207,7 @@ export function ListWorkspace() {
onExtraInfo={setExtraTarget} onExtraInfo={setExtraTarget}
onRenegotiate={setRenegoTarget} onRenegotiate={setRenegoTarget}
onMemo={setMemoTarget} onMemo={setMemoTarget}
onRejectDetail={setRejectDetailTarget}
/> />
</div> </div>
@ -213,7 +219,11 @@ export function ListWorkspace() {
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />} {guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
{rejectTarget && ( {rejectTarget && (
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} /> <RejectPopup
onClose={() => setRejectTarget(null)}
onSubmit={handleRejectSubmit}
isPending={reject.isPending}
/>
)} )}
{extraTarget && ( {extraTarget && (
@ -228,6 +238,16 @@ export function ListWorkspace() {
/> />
)} )}
{rejectDetailTarget && (
<RejectDetailPopup
onClose={() => setRejectDetailTarget(null)}
subtitle={`${rejectDetailTarget.qt_number} · ${rejectDetailTarget.item_name}`}
reason={rejectDetailTarget.rejectReason}
price={rejectDetailTarget.rejectPrice}
opinion={String(rejectDetailTarget.custom?.opinion ?? '')}
/>
)}
{memoTarget && ( {memoTarget && (
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} /> <RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
)} )}

View File

@ -43,5 +43,7 @@ export function toListItem(api: SessionListItem): ListItem {
renegotiationMemo: api.renegotiation_memo ?? '', renegotiationMemo: api.renegotiation_memo ?? '',
result: api.result ?? 0, result: api.result ?? 0,
hasChat: api.has_chat ?? false, hasChat: api.has_chat ?? false,
rejectReason: api.reject_reason ?? '',
rejectPrice: api.reject_price ?? null,
} }
} }

View File

@ -14,4 +14,6 @@ export type ListItem = {
renegotiationMemo: string // 담당자 심사 메모(반려 사유) renegotiationMemo: string // 담당자 심사 메모(반려 사유)
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건 hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
} }

View File

@ -4,3 +4,4 @@ export type { ClassValue } from '@/lib/cn'
export { interactive } from '@/lib/interactive' export { interactive } from '@/lib/interactive'
export { toast } from '@/lib/toast' export { toast } from '@/lib/toast'
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime' export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
export { numberToKorean } from '@/lib/koreanNumber'

View File

@ -15,6 +15,10 @@ _EXPERIENCE_LOGS = table(
column("company_id"), column("session_id"), column("card_id"), column("reward"), column("company_id"), column("session_id"), column("card_id"), column("reward"),
column("settled_price"), column("is_invalidated"), column("created_at"), column("settled_price"), column("is_invalidated"), column("created_at"),
column("turn"), column("turn"),
# 선택 시점 박제값 — 카드가 왜 뽑혔는지의 근거. UCB = Q + 탐색보너스 라
# 둘의 차이가 "학습으로 뽑혔나 / 덜 써봐서 뽑혔나"를 가른다.
column("q_value_at_selection"), column("ucb_score_at_selection"),
column("visit_count_at_selection"),
schema="learning", schema="learning",
) )
_ANCHORING_CURRENT = table( _ANCHORING_CURRENT = table(
@ -78,11 +82,16 @@ class LearningCRUD(ILearningCRUD):
return ErrorType.SUCCESS, tuple(rows[0]) return ErrorType.SUCCESS, tuple(rows[0])
async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용). """카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용,
평균 Q값, 평균 UCB, 누적 방문수).
협상 결과(타결·가격) 카드 장의 성과로 나눌 없어 다루지 않는다 협상에 여러 장이 협상 결과(타결·가격) 카드 장의 성과로 나눌 없어 다루지 않는다 협상에 여러 장이
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다. 나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
평균 라운드 = 카드가 협상의 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조). 평균 라운드 = 카드가 협상의 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
Q값/방문수는 선택 시점 박제값(experience_logs)이라 q_values 테이블을 따로 읽는다
"그때 무슨 근거로 뽑혔나" 사후 현재값보다 사용 현황에 맞는 기준이다.
방문수는 마지막 선택 시점 누적값(단조 증가라 max 최신).
""" """
query = ( query = (
select( select(
@ -91,6 +100,9 @@ class LearningCRUD(ILearningCRUD):
func.count(), func.count(),
func.avg(_EXPERIENCE_LOGS.c.turn), func.avg(_EXPERIENCE_LOGS.c.turn),
func.max(_EXPERIENCE_LOGS.c.created_at), func.max(_EXPERIENCE_LOGS.c.created_at),
func.avg(_EXPERIENCE_LOGS.c.q_value_at_selection),
func.avg(_EXPERIENCE_LOGS.c.ucb_score_at_selection),
func.max(_EXPERIENCE_LOGS.c.visit_count_at_selection),
) )
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None))) .where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
.group_by(_EXPERIENCE_LOGS.c.card_id) .group_by(_EXPERIENCE_LOGS.c.card_id)

View File

@ -2,7 +2,8 @@ from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
from typing import Optional, Tuple from typing import Optional, Tuple
from sqlalchemy import select, func, and_, or_, update from sqlalchemy import select, func, and_, or_, update, cast, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
@ -79,6 +80,10 @@ class IQuotationCRUD(ABC):
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
pass pass
@abstractmethod
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
pass
@abstractmethod @abstractmethod
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
"""견적과 연결된 하위 데이터(세션·대화)를 함께 소프트 삭제.""" """견적과 연결된 하위 데이터(세션·대화)를 함께 소프트 삭제."""
@ -571,6 +576,22 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def merge_session_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType:
# sessions.custom 부분 갱신(기존 키 보존). 부가정보·의견·재협상 요청이 같은 컬럼을 쓰므로 덮어쓰면 안 된다.
try:
query = (
update(sessions)
.where(sessions.session_id == session_id)
.values(
custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB)),
updated_at=GTime.UTC(),
)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
"""견적 + 연결된 하위 데이터(협상 세션·대화)를 한 트랜잭션으로 소프트 삭제한다. """견적 + 연결된 하위 데이터(협상 세션·대화)를 한 트랜잭션으로 소프트 삭제한다.
대화(chats)세션(sessions)견적(quotations) 순서로 deleted=True. 스텝이라도 실패하면 execute_lambda_run 롤백한다.""" 대화(chats)세션(sessions)견적(quotations) 순서로 deleted=True. 스텝이라도 실패하면 execute_lambda_run 롤백한다."""
@ -703,14 +724,16 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price). 삭제 제외. """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price, reject_price, session_id). 삭제 제외.
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None). 공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None).
target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값). target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).
reject_price 거부 협력사의 공급 희망 가격 자동 마감 판정엔 쓰고 수동 직접 낙찰 후보에서만 본다.""" reject_price 거부 협력사의 공급 희망 가격 자동 마감 판정엔 쓰고 화면 표기에만 쓴다.
session_id 직접 낙찰이 계약가를 세션 custom 적을 쓴다."""
try: try:
query = ( query = (
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name, select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
sessions.target_price, sessions.anchoring_price, sessions.reject_price) sessions.target_price, sessions.anchoring_price, sessions.reject_price,
sessions.session_id)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
) )

View File

@ -1,7 +1,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from sqlalchemy import select, func, and_, or_, case from sqlalchemy import select, func, and_, or_, case, cast, BigInteger
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -76,10 +76,12 @@ class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천. # 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용). # 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
# 계약가는 coalesce(투찰가, 거부 시 공급 희망가) — 결렬 건을 담당자가 직접 낙찰하면 bid_price 가 없어 # 계약가 = 담당자가 확정한 값이 있으면 그 값, 없으면 협력사 투찰가.
# 절감 집계에서 통째로 빠지기 때문. 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다. # 결렬·미응찰 건을 오프라인으로 다시 협상하고 직접 낙찰하면 시스템 투찰가가 없거나 실제 계약가와
# 다르기 때문. 컬럼 이름은 bid_price 로 유지해 statistics_service 는 그대로 쓴다.
try: try:
award_price = func.coalesce(sessions.bid_price, sessions.reject_price).label("bid_price") offline_price = cast(sessions.custom["offline_award"]["price"].astext, BigInteger)
award_price = func.coalesce(offline_price, sessions.bid_price).label("bid_price")
stmt = ( stmt = (
select( select(
quotations.updated_at, quotations.updated_at,
@ -88,6 +90,7 @@ class StatisticsCRUD(IStatisticsCRUD):
sessions.target_price, sessions.target_price,
award_price, award_price,
sessions.anchoring_price, sessions.anchoring_price,
offline_price.isnot(None).label("is_offline"), # 오프라인 협상 반영 건수 표기용
) )
.select_from(quotations) .select_from(quotations)
.join( .join(
@ -95,7 +98,7 @@ class StatisticsCRUD(IStatisticsCRUD):
and_( and_(
sessions.quotation_id == quotations.qt_id, sessions.quotation_id == quotations.qt_id,
sessions.supplier_id == quotations.preferred_sp_id, sessions.supplier_id == quotations.preferred_sp_id,
or_(sessions.bid_price.isnot(None), sessions.reject_price.isnot(None)), or_(sessions.bid_price.isnot(None), offline_price.isnot(None)),
sessions.deleted == False, # noqa: E712 sessions.deleted == False, # noqa: E712
), ),
) )

View File

@ -29,6 +29,11 @@ class CardUsageRow(LearningProtocol):
share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중 share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용 avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
last_used_at: Optional[datetime] = None last_used_at: Optional[datetime] = None
# 선택 근거 — UCB = Q + 탐색보너스. 봇은 이 점수가 가장 높은 카드를 고른다.
avg_q: Optional[float] = None # 선택 시점 Q값 평균 — 학습이 매긴 이 카드의 값어치
avg_ucb: Optional[float] = None # 선택 시점 UCB 평균
explore_bonus: Optional[float] = None # avg_ucb - avg_q — 클수록 '아직 덜 검증돼서' 뽑힌 것
visits: int = 0 # 마지막 선택 시점 누적 방문수 — 적으면 Q값이 아직 추측
class AnchoringCell(LearningProtocol): class AnchoringCell(LearningProtocol):

View File

@ -46,7 +46,9 @@ class Req_RegenerateQuotation(QuotationProtocol):
class Req_AwardQuotation(QuotationProtocol): class Req_AwardQuotation(QuotationProtocol):
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택) winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(견적에 초청된 협력사 중 선택)
contract_price: int # 최종 계약가(원). 오프라인 협상 결과를 담당자가 확정해 넣는다
contract_note: Optional[str] = None # 계약가 근거 메모(오프라인 협상 요약)
class QuotationData(WebPacketProtocol): class QuotationData(WebPacketProtocol):
@ -110,6 +112,7 @@ class SessionData(WebPacketProtocol):
qt_type: QuotationType qt_type: QuotationType
target_price: int target_price: int
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000anchoring_value)//1000 anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000anchoring_value)//1000
done_ceiling_price: Optional[int] = None # 완료 상한가(원). 생성 시 박제 = 목표가×(1+완료상한율/1000)
status: SessionStatus status: SessionStatus
bid_price: Optional[int] = None bid_price: Optional[int] = None
bid_at: Optional[datetime] = None bid_at: Optional[datetime] = None

View File

@ -67,7 +67,10 @@ async def award_quotation(
): ):
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다. # 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
return RemoveNoneResponse( return RemoveNoneResponse(
await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id) await service.award_quotation(
str(qt_id), user_info.company_id, user_info.user_id, user_info.role,
req.winner_supplier_id, req.contract_price, req.contract_note,
)
) )

View File

@ -16,6 +16,7 @@ class StatKpi(WebPacketProtocol):
closed_count: int = 0 # 마감 견적 수(창) closed_count: int = 0 # 마감 견적 수(창)
regen_avg_round: float = 0.0 # 평균 재견적 라운드 regen_avg_round: float = 0.0 # 평균 재견적 라운드
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생) markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
offline_award_count: int = 0 # 낙찰 중 오프라인 협상 결과를 담당자가 반영한 건수(절감액엔 포함)
class StatMonthPoint(WebPacketProtocol): class StatMonthPoint(WebPacketProtocol):

View File

@ -59,8 +59,8 @@ class LearningService:
names = await self._read(lambda s: self.crud.card_names(s), default=[]) names = await self._read(lambda s: self.crud.card_names(s), default=[])
name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number} name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
total_uses = sum(int(uses or 0) for _n, _s, uses, _t, _l in rows) or 1 total_uses = sum(int(r[2] or 0) for r in rows) or 1
for card_id, used_sessions, uses, avg_turn, last_used in rows: for card_id, used_sessions, uses, avg_turn, last_used, avg_q, avg_ucb, visits in rows:
number = str(card_id) number = str(card_id)
name, is_wild, card_pk = name_map.get(number, (None, 0, None)) name, is_wild, card_pk = name_map.get(number, (None, 0, None))
res.cards.append(CardUsageRow( res.cards.append(CardUsageRow(
@ -73,6 +73,12 @@ class LearningService:
share=round(int(uses or 0) / total_uses, 3), share=round(int(uses or 0) / total_uses, 3),
avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0, avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
last_used_at=last_used, last_used_at=last_used,
avg_q=round(float(avg_q), 4) if avg_q is not None else None,
avg_ucb=round(float(avg_ucb), 4) if avg_ucb is not None else None,
# 탐색보너스는 두 값이 다 있을 때만 — 한쪽만 있으면 차이가 의미를 잃는다.
explore_bonus=(round(float(avg_ucb) - float(avg_q), 4)
if avg_q is not None and avg_ucb is not None else None),
visits=int(visits or 0),
)) ))
res.kpi = LearningKpi( res.kpi = LearningKpi(

View File

@ -6,21 +6,12 @@ from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions from common.database.model.models import quotations, sessions
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
from router.v1.quotation.protocol import Res_Quotation from router.v1.quotation.protocol import Res_Quotation
from services.notification import create_notification 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: class ClosingMixin:
@staticmethod @staticmethod
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]: def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
@ -168,17 +159,25 @@ class ClosingMixin:
await self.close_and_decide(qt_uuid) await self.close_and_decide(qt_uuid)
return await self.get_quotation(qt_id, company_id) return await self.get_quotation(qt_id, company_id)
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation: async def award_quotation(
self, qt_id: str, company_id, user_id, role, winner_supplier_id,
contract_price: int, contract_note: Optional[str] = None,
) -> Res_Quotation:
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다. """[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
가격을 써낸 세션 고른 협력사를 낙찰자로 박고 close_reason AWARDED 바꾼다(직접 낙찰). 협상이 결렬·미응찰로 끝나면 오프라인으로 다시 협상하고 결과를 여기서 반영한다 그래서
후보는 투찰한 협상완료(DONE) + 공급 희망 가격을 남긴 협상거부(REJECTED) 협상이 결렬돼도 후보는 견적에 초청된 협력사 전부이고, 계약가는 담당자가 확정해 넣는다(시스템 제출가와 다를 있다).
마지막에 제출한 가격으로 계약을 진행하는 운영 방침을 시스템에서 그대로 처리하기 위함이. 계약가는 sessions.custom.offline_award 근거 메모·작성자·시각과 함께 남겨 통계가 값을 계약가로 읽는.
자동 낙찰(close_and_decide) 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다. 자동 낙찰(close_and_decide) 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
권한: 본인이 생성한 견적만. 최고관리자(OWNER) 회사 남의 견적도 낙찰할 있다.""" 권한: 본인이 생성한 견적만. 최고관리자(OWNER) 회사 남의 견적도 낙찰할 있다."""
res = Res_Quotation() res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id) qt_uuid = uuid.UUID(qt_id)
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id)) sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
if not contract_price or contract_price <= 0:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "계약가를 입력해야 낙찰할 수 있습니다."
return res
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND. # 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
err_type, original = await self._fetch(qt_uuid, company_id) err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None: if err_type != ErrorType.SUCCESS or original is None:
@ -200,20 +199,21 @@ class ClosingMixin:
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다." res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
return res return res
# 낙찰 후보 = 가격을 써낸 세션. close_and_decide 와 같은 조회(list_sessions_status, # 낙찰 후보 = 이 견적에 초청된 협력사 전부. 오프라인 협상 결과를 반영하는 자리라 시스템에
# 공급사 삭제돼도 포함되는 outerjoin)를 쓴다. # 가격을 안 낸 협력사(전원 미응찰 견적 포함)도 고를 수 있다. close_and_decide 와 같은 조회
# (list_sessions_status, 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
err_type, rows = await DB_SESSION_MNG.execute_lambda( err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), sessions.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid), lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
) )
rows = rows if err_type == ErrorType.SUCCESS else [] rows = rows if err_type == ErrorType.SUCCESS else []
winner = next((r for r in rows if r.supplier_id == sp_uuid and _award_price(r) is not None), None) winner = next((r for r in rows if r.supplier_id == sp_uuid), None)
if winner is None: if winner is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(가격을 제출한 협력사)가 아닙니다." res.msg = "선택한 협력사는 이 견적에 초청된 협력사가 아닙니다."
return res return res
winner_price = _award_price(winner) winner_price = int(contract_price)
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어). # [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim( claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
@ -225,6 +225,22 @@ class ClosingMixin:
res.msg = "이미 낙찰 처리된 견적입니다." res.msg = "이미 낙찰 처리된 견적입니다."
return res return res
# 계약가를 낙찰 세션에 남긴다 — 통계가 이 값을 계약가로 읽고(투찰가보다 우선), 누가 언제 어떤
# 근거로 확정했는지 추적한다. custom 은 부가정보·의견과 같은 컬럼이라 병합(덮어쓰기 금지).
offline_award = {
"price": winner_price,
"note": (contract_note or "").strip()[:255],
"by": str(user_id) if user_id else "",
"at": GTime.UTC().isoformat(timespec="seconds"),
}
award_err = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.quotation_crud.merge_session_custom(s, winner.session_id, {"offline_award": offline_award})],
)
if award_err != ErrorType.SUCCESS:
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
LOG.w(f"[award] 계약가 기록 실패 qt_id={qt_id} session_id={winner.session_id} price={winner_price}")
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분. # 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
await create_notification( await create_notification(
original.user_id, NotificationType.SUCCESS, original.user_id, NotificationType.SUCCESS,

View File

@ -181,6 +181,7 @@ class QueriesMixin:
qt_type=r.qt_type, qt_type=r.qt_type,
target_price=r.target_price, target_price=r.target_price,
anchoring_price=r.anchoring_price, anchoring_price=r.anchoring_price,
done_ceiling_price=r.done_ceiling_price,
status=r.status, status=r.status,
bid_price=r.bid_price, bid_price=r.bid_price,
bid_at=r.bid_at, bid_at=r.bid_at,

View File

@ -80,6 +80,7 @@ class StatisticsService:
k.closed_count = closed k.closed_count = closed
k.award_rate = (outcome.awarded / closed) if closed else 0.0 k.award_rate = (outcome.awarded / closed) if closed else 0.0
k.regen_avg_round = round(regen, 2) k.regen_avg_round = round(regen, 2)
k.offline_award_count = sum(1 for r in win_rows if r.is_offline)
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생) k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0). # 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0 k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0

View File

@ -39,7 +39,7 @@ async def test_award_opened_sets_winner_and_notifies(clean):
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
assert res.result.success is True assert res.result.success is True
row = await _quotation(engine, qt) row = await _quotation(engine, qt)
@ -65,7 +65,7 @@ async def test_award_rejects_when_not_opened(clean):
qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value) qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
assert res.result.success is False assert res.result.success is False
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
@ -73,14 +73,14 @@ async def test_award_rejects_when_not_opened(clean):
async def test_award_rejects_unknown_supplier(clean): async def test_award_rejects_unknown_supplier(clean):
"""검증: 개찰 견적에, 투찰 후보가 아닌 협력사 id 를 지정. """검증: 개찰 견적에, 이 견적에 초청되지 않은(세션이 없는) 협력사 id 를 지정.
기대결과: 거부 + close_reason 개찰(OPEN_PRICE) 그대로 유지, 알림 없음.""" 기대결과: 거부 + close_reason 개찰(OPEN_PRICE) 그대로 유지, 알림 없음."""
engine = clean engine = clean
user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value) qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger) res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger, 100)
assert res.result.success is False assert res.result.success is False
row = await _quotation(engine, qt) row = await _quotation(engine, qt)
@ -97,8 +97,8 @@ async def test_award_is_idempotent(clean):
qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value) qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
assert first.result.success is True assert first.result.success is True
assert second.result.success is False assert second.result.success is False
@ -113,7 +113,7 @@ async def test_award_rejects_non_owner(clean):
qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value) qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a) res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a, 100)
assert res.result.success is False assert res.result.success is False
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
@ -131,7 +131,7 @@ async def test_award_allows_owner_role(clean):
qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value) qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a) res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a, 100)
assert res.result.success is True assert res.result.success is True
row = await _quotation(engine, qt) row = await _quotation(engine, qt)
@ -141,6 +141,64 @@ async def test_award_allows_owner_role(clean):
assert len(await _notifications(engine, admin)) == 0 assert len(await _notifications(engine, admin)) == 0
async def test_award_records_contract_price_on_session(clean):
"""검증: 협력사 제출가(100)와 다른 계약가(88)로 직접 낙찰 — 오프라인 재협상 결과 반영.
기대결과: 낙찰 세션 custom.offline_award 계약가·메모가 남고, 알림 winner_price 계약가."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-OFFLINE", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.REJECTED.value, bid_price=None, supplier_id=supplier_a)
res = await _service().award_quotation(
str(qt), None, user_id, UserRole.USER.value, supplier_a, 88, "오프라인 협상, 8/12 통화 합의",
)
assert res.result.success is True
async with engine.begin() as conn:
row = (await conn.execute(
text("SELECT custom FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
{"qt": qt, "sp": supplier_a},
)).first()
award = row.custom["offline_award"]
assert award["price"] == 88
assert award["note"] == "오프라인 협상, 8/12 통화 합의"
assert award["by"] == str(user_id)
notis = await _notifications(engine, user_id)
assert notis[0][1]["winner_price"] == 88
async def test_award_allows_supplier_without_price(clean):
"""검증: 시스템에 가격을 한 번도 안 낸(미참여) 협력사를 계약가와 함께 직접 낙찰.
기대결과: 낙찰 성공 전원 미응찰 견적도 오프라인 협상 결과를 반영할 있다."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-NOSHOW", close_reason=CloseReason.OPEN_NOSHOW.value)
await _add_session(engine, qt, status=SessionStatus.NOT_PARTICIPATED.value, bid_price=None, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 77_000)
assert res.result.success is True
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.AWARDED.value
assert str(row.preferred_sp_id) == str(supplier_a)
async def test_award_rejects_without_contract_price(clean):
"""검증: 계약가 없이(0) 직접 낙찰 시도.
기대결과: 거부(INVALID_REQUEST_DATA) 계약가는 절감 통계의 기준이라 필수다."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-NOPRICE", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 0)
assert res.result.success is False
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.OPEN_PRICE.value
# ===== 헬퍼 ===== # ===== 헬퍼 =====
async def _seed_opened(engine, *, user_id, number, close_reason, round_=1): async def _seed_opened(engine, *, user_id, number, close_reason, round_=1):
"""개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작.""" """개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작."""

View File

@ -7,6 +7,9 @@
import type { CardUsageRowName } from './cardUsageRowName'; import type { CardUsageRowName } from './cardUsageRowName';
import type { CardUsageRowCardId } from './cardUsageRowCardId'; import type { CardUsageRowCardId } from './cardUsageRowCardId';
import type { CardUsageRowLastUsedAt } from './cardUsageRowLastUsedAt'; import type { CardUsageRowLastUsedAt } from './cardUsageRowLastUsedAt';
import type { CardUsageRowAvgQ } from './cardUsageRowAvgQ';
import type { CardUsageRowAvgUcb } from './cardUsageRowAvgUcb';
import type { CardUsageRowExploreBonus } from './cardUsageRowExploreBonus';
export interface CardUsageRow { export interface CardUsageRow {
number: string; number: string;
@ -18,4 +21,8 @@ export interface CardUsageRow {
share?: number; share?: number;
avg_turn?: number; avg_turn?: number;
last_used_at?: CardUsageRowLastUsedAt; last_used_at?: CardUsageRowLastUsedAt;
avg_q?: CardUsageRowAvgQ;
avg_ucb?: CardUsageRowAvgUcb;
explore_bonus?: CardUsageRowExploreBonus;
visits?: number;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowAvgQ = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowAvgUcb = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowExploreBonus = number | null;

View File

@ -25,7 +25,10 @@ export * from './cardDataUserId';
export * from './cardStatus'; export * from './cardStatus';
export * from './cardType'; export * from './cardType';
export * from './cardUsageRow'; export * from './cardUsageRow';
export * from './cardUsageRowAvgQ';
export * from './cardUsageRowAvgUcb';
export * from './cardUsageRowCardId'; export * from './cardUsageRowCardId';
export * from './cardUsageRowExploreBonus';
export * from './cardUsageRowLastUsedAt'; export * from './cardUsageRowLastUsedAt';
export * from './cardUsageRowName'; export * from './cardUsageRowName';
export * from './cardUsageType'; export * from './cardUsageType';
@ -159,6 +162,7 @@ export * from './renegotiationDataNextQuotationId';
export * from './renegotiationDataTargetPrice'; export * from './renegotiationDataTargetPrice';
export * from './reqApproveRenegotiation'; export * from './reqApproveRenegotiation';
export * from './reqAwardQuotation'; export * from './reqAwardQuotation';
export * from './reqAwardQuotationContractNote';
export * from './reqBulkMapByNames'; export * from './reqBulkMapByNames';
export * from './reqCheckCodes'; export * from './reqCheckCodes';
export * from './reqCreateCard'; export * from './reqCreateCard';
@ -432,6 +436,7 @@ export * from './sessionDataBidAt';
export * from './sessionDataBidPrice'; export * from './sessionDataBidPrice';
export * from './sessionDataCustom'; export * from './sessionDataCustom';
export * from './sessionDataCustomAnyOf'; export * from './sessionDataCustomAnyOf';
export * from './sessionDataDoneCeilingPrice';
export * from './sessionDataEmailSentAt'; export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice'; export * from './sessionDataRejectPrice';

View File

@ -4,7 +4,10 @@
* Negodata Api Server * Negodata Api Server
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { ReqAwardQuotationContractNote } from './reqAwardQuotationContractNote';
export interface ReqAwardQuotation { export interface ReqAwardQuotation {
winner_supplier_id: string; winner_supplier_id: string;
contract_price: number;
contract_note?: ReqAwardQuotationContractNote;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqAwardQuotationContractNote = string | null;

View File

@ -6,6 +6,7 @@
*/ */
import type { QuotationType } from './quotationType'; import type { QuotationType } from './quotationType';
import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice'; import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice';
import type { SessionDataDoneCeilingPrice } from './sessionDataDoneCeilingPrice';
import type { SessionStatus } from './sessionStatus'; import type { SessionStatus } from './sessionStatus';
import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidPrice } from './sessionDataBidPrice';
import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataBidAt } from './sessionDataBidAt';
@ -25,6 +26,7 @@ export interface SessionData {
qt_type: QuotationType; qt_type: QuotationType;
target_price: number; target_price: number;
anchoring_price?: SessionDataAnchoringPrice; anchoring_price?: SessionDataAnchoringPrice;
done_ceiling_price?: SessionDataDoneCeilingPrice;
status: SessionStatus; status: SessionStatus;
bid_price?: SessionDataBidPrice; bid_price?: SessionDataBidPrice;
bid_at?: SessionDataBidAt; bid_at?: SessionDataBidAt;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type SessionDataDoneCeilingPrice = number | null;

View File

@ -14,4 +14,5 @@ export interface StatKpi {
closed_count?: number; closed_count?: number;
regen_avg_round?: number; regen_avg_round?: number;
markup_suppression_rate?: number; markup_suppression_rate?: number;
offline_award_count?: number;
} }

View File

@ -57,6 +57,15 @@ export function CardLearningView({ data }: { data: LearningData }) {
<TableHead className="w-32 text-right"> <TableHead className="w-32 text-right">
<HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." /> <HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." />
</TableHead> </TableHead>
<TableHead className="w-28 text-right">
<HeadWithHelp label="Q값" help="강화학습이 이 카드에 매긴 값어치입니다(선택 시점 평균). 높을수록 이 국면에서 성과가 좋았다고 학습된 카드입니다. 봇은 Q값에 탐색 보너스를 더한 점수가 가장 높은 카드를 고릅니다." />
</TableHead>
<TableHead className="w-28 text-right">
<HeadWithHelp label="탐색 보너스" help="아직 덜 써봐서 붙는 가산점입니다(UCB Q). 이 값이 Q값보다 크면 성과가 좋아서가 아니라 '아직 검증이 덜 돼서' 뽑힌 것입니다. 방문수가 쌓이면 0에 수렴합니다." />
</TableHead>
<TableHead className="w-24 text-right">
<HeadWithHelp label="방문수" help="이 카드가 학습에서 선택된 누적 횟수입니다. 적으면 Q값이 아직 추측에 가깝고, 많을수록 검증된 값입니다." />
</TableHead>
<TableHead className="w-36 text-right"> </TableHead> <TableHead className="w-36 text-right"> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -84,6 +93,9 @@ export function CardLearningView({ data }: { data: LearningData }) {
<TableCell className="text-right">{c.uses}</TableCell> <TableCell className="text-right">{c.uses}</TableCell>
<TableCell className="text-right">{pct(c.share)}</TableCell> <TableCell className="text-right">{pct(c.share)}</TableCell>
<TableCell className="text-right">{c.avgTurn ? `${c.avgTurn}R` : '-'}</TableCell> <TableCell className="text-right">{c.avgTurn ? `${c.avgTurn}R` : '-'}</TableCell>
<TableCell className="text-right font-mono">{num4(c.avgQ)}</TableCell>
<TableCell className="text-right font-mono text-muted-foreground">{num4(c.exploreBonus)}</TableCell>
<TableCell className="text-right">{c.visits || '-'}</TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'} {c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
</TableCell> </TableCell>
@ -286,6 +298,11 @@ function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`; return `${(v * 100).toFixed(0)}%`;
} }
// Q값·탐색 보너스는 0에 가까운 소수라 반올림하면 차이가 사라진다 — 네 자리까지 그대로 보여준다.
function num4(v: number | null): string {
return v == null ? '-' : v.toFixed(4);
}
// 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다. // 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다.
function rate(v: number): string { function rate(v: number): string {
return `${(v / 10).toFixed(1)}%`; return `${(v / 10).toFixed(1)}%`;

View File

@ -24,6 +24,9 @@ export function useLearningData(): { data: LearningData; isLoading: boolean } {
share: c.share ?? 0, share: c.share ?? 0,
avgTurn: c.avg_turn ?? 0, avgTurn: c.avg_turn ?? 0,
lastUsedAt: c.last_used_at ?? null, lastUsedAt: c.last_used_at ?? null,
avgQ: c.avg_q ?? null,
exploreBonus: c.explore_bonus ?? null,
visits: c.visits ?? 0,
})), })),
}, },
}; };

View File

@ -10,6 +10,10 @@ export type CardRow = {
share: number; // 전체 카드 사용 중 비중 share: number; // 전체 카드 사용 중 비중
avgTurn: number; // 평균 몇 번째 라운드에 나갔는지 avgTurn: number; // 평균 몇 번째 라운드에 나갔는지
lastUsedAt: string | null; lastUsedAt: string | null;
// 선택 근거 — 봇은 UCB(=Q + 탐색보너스)가 가장 높은 카드를 고른다.
avgQ: number | null; // 선택 시점 Q값 평균
exploreBonus: number | null; // UCB - Q. 클수록 '아직 덜 검증돼서' 뽑힌 것
visits: number; // 마지막 선택 시점 누적 방문수
}; };
export type LearningData = { export type LearningData = {

View File

@ -0,0 +1,116 @@
import { useState } from 'react';
import { X, Gavel, Loader2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Typography } from '@/components/ui/typography';
import { SessionStatus } from '@/api/generated/model';
import { awardPrice, sessionStatusLabel, type SessionView } from '../../types';
type AwardModalProps = {
open: boolean;
/** 낙찰시킬 협력사의 세션 — 마지막 제출가를 계약가 기본값으로 쓴다. */
winner: SessionView;
/** 확정 → 직접 낙찰 호출. 성공(true) 시 모달 닫힘. */
onConfirm: (contractPrice: number, contractNote: string) => Promise<boolean>;
onClose: () => void;
};
// 개찰 견적의 직접 낙찰 모달.
// 결렬·미응찰 건은 오프라인으로 다시 협상하고 그 결과를 여기에 반영한다 — 그래서 계약가는
// 시스템 제출가를 기본값으로 채워두되 담당자가 고쳐 넣을 수 있고, 근거를 메모로 남긴다.
export function AwardModal({ open, winner, onConfirm, onClose }: AwardModalProps) {
const submitted = awardPrice(winner);
const [price, setPrice] = useState<string>(submitted != null ? String(submitted) : '');
const [note, setNote] = useState('');
const [saving, setSaving] = useState(false);
useScrollLock(open);
if (!open) return null;
const parsed = Number(price.replace(/[^0-9]/g, ''));
const canSubmit = parsed > 0 && !saving;
const changed = submitted != null && parsed > 0 && parsed !== submitted;
const handleConfirm = async () => {
if (!canSubmit) return;
setSaving(true);
try {
const ok = await onConfirm(parsed, note.trim());
if (ok) onClose();
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-background shadow-xl">
<div className="flex items-start justify-between border-b border-border p-5">
<div className="min-w-0">
<Typography variant="h4" className="flex items-center gap-2">
<Gavel className="size-4 text-primary" />
</Typography>
<Typography variant="muted" className="mt-1 truncate">
{winner.supplier_name} · {sessionStatusLabel(winner.status)}
</Typography>
</div>
<button type="button" onClick={onClose} aria-label="닫기" className="rounded-md p-1 hover:bg-muted">
<X className="size-4" />
</button>
</div>
<div className="space-y-4 p-5">
<div className="space-y-1.5">
<Typography variant="small" className="font-semibold">
</Typography>
<Input
inputMode="numeric"
value={parsed ? parsed.toLocaleString() : ''}
onChange={(e) => setPrice(e.target.value)}
placeholder="예: 12,800,000"
autoFocus
/>
<Typography variant="muted">
{submitted != null
? `협력사 제출가 ₩${submitted.toLocaleString()}${changed ? ' — 오프라인 협상으로 바뀐 금액을 넣었습니다.' : ''}`
: winner.status === SessionStatus.NOT_PARTICIPATED
? '이 협력사는 협상에 참여하지 않았습니다 — 오프라인 합의가를 직접 넣어 주세요.'
: '제출한 가격이 없습니다 — 오프라인 합의가를 직접 넣어 주세요.'}
</Typography>
</div>
<div className="space-y-1.5">
<Typography variant="small" className="font-semibold">
<span className="font-normal text-muted-foreground">()</span>
</Typography>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={255}
className="w-full resize-none rounded border border-border bg-background p-2 text-xs"
placeholder="예: 오프라인 협상 결과, 8/12 통화로 단가 합의"
/>
</div>
<Typography variant="muted">
. .
</Typography>
</div>
<div className="flex justify-end gap-2 border-t border-border p-4">
<Button variant="outline" onClick={onClose} disabled={saving}>
</Button>
<Button onClick={handleConfirm} disabled={!canSubmit}>
{saving && <Loader2 className="size-4 animate-spin" />}
</Button>
</div>
</div>
</div>
);
}

View File

@ -7,12 +7,14 @@ import { typographyVariants } from '@/components/ui/typography';
import { InfoField } from './InfoField'; import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill'; import { QuotationStatusBadge } from './StatusPill';
import { ResultSummaryBand } from './ResultSummaryBand'; import { ResultSummaryBand } from './ResultSummaryBand';
import { PriceRail } from './PriceRail';
import type { QuotationData } from '@/api/generated/model/quotationData'; import type { QuotationData } from '@/api/generated/model/quotationData';
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings'; import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { import {
type Product, type Product,
type QuotationSetting, type QuotationSetting,
type SessionView, type SessionView,
buildPriceRail,
quotationTypeLabel, quotationTypeLabel,
awardCriterionLabel, awardCriterionLabel,
fmtDateTime, fmtDateTime,
@ -33,14 +35,12 @@ function SectionCard({ title, children }: { title: string; children: ReactNode }
type DrawerHeaderCardsProps = { type DrawerHeaderCardsProps = {
quotation: QuotationData; quotation: QuotationData;
quotationSettings: QuotationSetting[]; quotationSettings: QuotationSetting[];
/** 협상 세션 뷰 — 결과 밴드가 낙찰가/절감 계산에 쓴다. */ /** 협상 세션 뷰 — 가격 레일과 결과 밴드가 기준가/낙찰가 계산에 쓴다. */
sessionViews: SessionView[]; sessionViews: SessionView[];
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */ /** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
currentProduct: Product | undefined; currentProduct: Product | undefined;
/** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */ /** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가는 견적 단위(1견적=1상품)라 세션 공통값. */
onShowTarget: (sessionId: string) => void; onShowTarget: (sessionId: string) => void;
/** 접힘 상태 — 상세 그리드는 감추고 상단 결과 요약(목표가·낙찰·절감) 밴드만 남긴다. */
collapsed?: boolean;
}; };
export function DrawerHeaderCards({ export function DrawerHeaderCards({
@ -49,39 +49,23 @@ export function DrawerHeaderCards({
sessionViews, sessionViews,
currentProduct, currentProduct,
onShowTarget, onShowTarget,
collapsed = false,
}: DrawerHeaderCardsProps) { }: DrawerHeaderCardsProps) {
const label = useLabels(); // 회사 설정 용어(목표 마진 등) const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외 const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
if (collapsed) {
return (
<div className="text-xs font-mono">
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
</div>
);
}
// Quotations DDL 표시값 const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
const q_name = quotation.name || '-'; // 타결 상한율(‰) — 견적 override 우선, 없으면 적용 세팅값.
const q_number = quotation.number || '-'; const ceilingRate = quotation.done_ceiling_rate ?? selectedSettingObj?.done_ceiling_rate ?? 50;
const q_round = quotation.round || 1;
const won = (n?: number | null) => (n != null ? `${Number(n).toLocaleString()}` : '-');
const q_end_time = quotation.end_time ? fmtDateTime(quotation.end_time) : '-'; const q_end_time = quotation.end_time ? fmtDateTime(quotation.end_time) : '-';
const q_created_at = fmtDateTime(quotation.created_at);
const q_manager = const q_manager =
quotation.manager_name || quotation.manager_email quotation.manager_name || quotation.manager_email
? `${quotation.manager_name || '-'} (${quotation.manager_email || '-'})` ? `${quotation.manager_name || '-'} (${quotation.manager_email || '-'})`
: '-'; : '-';
const q_memo = quotation.memo || '-'; const q_memo = quotation.memo || '-';
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id); // 상품 카드는 '가격 관련' 값만 노출(모델명·규격 등 스펙은 상품 상세에서).
// 목표가·앵커링가는 견적 단위값(1견적=1상품 → 모든 세션 공통). 대표 세션 하나에서 읽어 견적 정보에 표기한다.
const repSession = sessionViews[0];
const repSessionId = repSession?.session_id;
// 상품 카드는 '가격 관련' 값만 노출(모델명·규격·제조사 등 스펙은 상품 상세에서 확인).
const won = (n?: number | null) => (n != null ? `${Number(n).toLocaleString()}` : '-');
const productPriceRows = currentProduct const productPriceRows = currentProduct
? [ ? [
{ key: 'price', label: label('item.price'), value: won(currentProduct.price) }, { key: 'price', label: label('item.price'), value: won(currentProduct.price) },
@ -92,39 +76,26 @@ export function DrawerHeaderCards({
: []; : [];
return ( return (
<div className="mt-4 space-y-4 text-xs"> <div className="text-xs">
{/* 결과 요약: 목표가 대비 낙찰/절감 */} <div className="space-y-4">
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} /> {/* 마감 결과·낙찰 협력사 + 가격 스펙트럼 (금액은 레일이 책임진다) */}
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} ceilingRate={ceilingRate} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 견적 정보 */} {/* 견적 정보 — 견적명·번호·차수는 헤더 제목줄에 있어 여기서 반복하지 않는다. */}
<SectionCard title="견적 정보"> <SectionCard title="견적 정보">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground"> <div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField label="견적명" value={q_name} valueClassName="font-sans" /> <InfoField label="유형" value={quotationTypeLabel(quotation.type)} valueClassName="font-sans" />
<InfoField label="견적번호" value={q_number} /> <InfoField
<InfoField label="유형" value={quotationTypeLabel(quotation.type)} /> label="낙찰 기준"
<InfoField label="차수" value={`${q_round}`} /> value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)}
<InfoField label="낙찰 기준" value={awardCriterionLabel(quotation.type, quotation.mid_action, quotation.over_action)} valueClassName="font-sans" /> valueClassName="font-sans"
<InfoField label="목표가"> />
{repSessionId ? (
<button
type="button"
onClick={() => onShowTarget(repSessionId)}
title="목표가 산정 내역 보기"
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans')}
>
{won(repSession?.target_price)}
</button>
) : (
<span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span>
)}
</InfoField>
<InfoField label="앵커링가" value={won(repSession?.anchoring_price)} valueClassName="font-sans" />
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1"> <InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
<QuotationStatusBadge status={quotation.status} /> <QuotationStatusBadge status={quotation.status} />
</InfoField> </InfoField>
<InfoField label="마감시각" value={q_end_time} /> <InfoField label="마감시각" value={q_end_time} />
<InfoField label="생성일" value={q_created_at} /> <InfoField label="생성일" value={fmtDateTime(quotation.created_at)} />
<InfoField label="담당자" value={q_manager} valueClassName="font-sans" /> <InfoField label="담당자" value={q_manager} valueClassName="font-sans" />
<InfoField <InfoField
label="메모" label="메모"
@ -135,9 +106,8 @@ export function DrawerHeaderCards({
</div> </div>
</SectionCard> </SectionCard>
{/* 상품 정보(가격) + 견적 세팅 */} {/* 상품 가격과 협상 규칙은 함께 읽힌다 — 한 카드로 합친다. */}
<div className="space-y-4"> <SectionCard title="상품 · 협상 규칙">
<SectionCard title="상품 정보 (가격)">
{currentProduct ? ( {currentProduct ? (
<div className="flex gap-4"> <div className="flex gap-4">
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center"> <div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
@ -169,6 +139,18 @@ export function DrawerHeaderCards({
title={row.value} title={row.value}
/> />
))} ))}
{selectedSettingObj ? (
<>
<InfoField
label={label('target_margin')}
value={selectedSettingObj.target_margin}
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
/>
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
</>
) : (
<InfoField label="견적 세팅" value="미적용" valueClassName="font-sans" />
)}
</div> </div>
</div> </div>
</div> </div>
@ -176,21 +158,6 @@ export function DrawerHeaderCards({
<div className="text-muted-foreground py-6 text-center"> .</div> <div className="text-muted-foreground py-6 text-center"> .</div>
)} )}
</SectionCard> </SectionCard>
<SectionCard title="견적 세팅">
{selectedSettingObj ? (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
<InfoField
label={label('target_margin')}
value={selectedSettingObj.target_margin}
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
/>
<InfoField label="카드 사용 횟수" value={selectedSettingObj.card_use_count} />
</div>
) : (
<div className="text-muted-foreground py-6 text-center"> .</div>
)}
</SectionCard>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,149 @@
import type { ReactNode } from 'react';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import type { PriceRailView } from '../../types';
const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-');
/**
* .
* · .
*
* 1 :
* 1:1 = ( )
* 1:N = ( (×) )
* 4 갈린다: 낙찰가 / () / ·( ).
*/
export function PriceRail({ rail, onShowTarget }: { rail: PriceRailView; onShowTarget?: () => void }) {
const savingsText =
rail.savings != null
? `목표가 대비 ${rail.savings >= 0 ? '' : '+'}${won(Math.abs(rail.savings))}` +
(rail.savingsRate != null ? ` (${rail.savings >= 0 ? '' : '+'}${Math.abs(rail.savingsRate * 100).toFixed(1)}%)` : '')
: null;
return (
<div className="grid grid-cols-2 border-b border-border bg-card sm:grid-cols-4">
{rail.oneToOne ? (
<Cell
label="앵커링가"
dot="bg-zinc-400"
value={won(rail.anchorPrice)}
sub={
rail.anchorPrice != null && rail.targetPrice
? `목표가 ${pctGap(rail.targetPrice, rail.anchorPrice)}`
: undefined
}
/>
) : (
<Cell
label="투찰 현황"
dot="bg-zinc-400"
value={`${rail.bidCount} / ${rail.supplierCount}`}
sub="협력사별 앵커링가는 아래 표에서"
/>
)}
<Cell label="목표가" dot="bg-primary" value={won(rail.targetPrice)} onSub={onShowTarget} />
<Cell
label="타결 상한가"
dot="bg-amber-500"
value={won(rail.ceilingPrice)}
valueClassName="text-amber-600 dark:text-amber-400"
sub={`목표가 +${(rail.ceilingRate / 10).toFixed(1)}% · 초과 시 결렬`}
/>
<Cell
label={rail.resultLabel}
badge={rail.resultBadge}
dot={rail.outcome === 'awarded' ? 'bg-emerald-500' : 'bg-amber-500'}
value={rail.resultPrice != null ? won(rail.resultPrice) : '미응찰'}
valueClassName={
rail.resultPrice == null
? 'text-muted-foreground'
: rail.outcome === 'awarded'
? 'text-emerald-600 dark:text-emerald-400'
: 'text-amber-600 dark:text-amber-400'
}
className={rail.outcome === 'awarded' ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : undefined}
sub={savingsText ?? undefined}
/>
</div>
);
}
/** 목표가 대비 하락폭(%) — 앵커가 목표가보다 낮은 정상 케이스만 의미가 있다. */
function pctGap(target: number, anchor: number): string {
if (!target) return '0.0%';
return `${(((target - anchor) / target) * 100).toFixed(1)}%`;
}
function Cell({
label,
badge,
dot,
value,
sub,
onSub,
className,
valueClassName,
}: {
label: string;
badge?: string | null;
dot: string;
value: ReactNode;
sub?: string;
/** 있으면 보조줄이 버튼(목표가 산정 내역) */
onSub?: () => void;
className?: string;
valueClassName?: string;
}) {
return (
<div
className={cn(
'min-w-0 border-r border-border px-4 py-2.5 last:border-r-0',
// 2열(모바일)에서는 윗줄에만 밑줄을 긋고 2번째 칸의 오른쪽 선을 지운다.
// 컨테이너가 이미 아래 테두리를 가지므로 아랫줄에 또 그으면 선이 겹쳐 두꺼워진다.
'[&:nth-child(-n+2)]:border-b sm:[&:nth-child(-n+2)]:border-b-0',
'[&:nth-child(2)]:border-r-0 sm:[&:nth-child(2)]:border-r',
className,
)}
>
<div className="mb-0.5 flex items-center gap-1.5">
<span className={cn('size-1.5 shrink-0 rounded-full', dot)} />
<Typography as="span" variant="caption" className="text-[10.5px] leading-tight">
{label}
</Typography>
{badge && (
<span className="rounded-full bg-amber-100 px-1.5 py-px text-[9.5px] font-bold text-amber-700 dark:bg-amber-950/50 dark:text-amber-400">
{badge}
</span>
)}
</div>
<Typography
as="p"
variant="small"
className={cn('truncate font-mono text-[15px] font-bold tabular-nums tracking-tight text-foreground', valueClassName)}
>
{value}
</Typography>
{/* 보조줄은 값이 없어도 자리를 지킨다 — 칸마다 높이가 달라지면 레일 바닥이 들쭉날쭉해진다. */}
<div className="min-h-[16px] leading-4">
{onSub ? (
<button
type="button"
onClick={onSub}
title="목표가 산정 내역 보기"
className="cursor-pointer text-[10.5px] text-primary hover:underline"
>
</button>
) : sub ? (
<Typography as="p" variant="caption" className="truncate text-[10.5px] tabular-nums">
{sub}
</Typography>
) : null}
</div>
</div>
);
}

View File

@ -1,152 +1,228 @@
import { Fragment } from 'react'; import { useLayoutEffect, useRef, useState } from 'react';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-'); const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-');
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
type Tone = 'amber' | 'emerald' | 'zinc'; /** 라벨 사이 최소 간격(px). 이보다 가까우면 다른 레인으로 보내거나 밀어낸다. */
const TONE_DOT: Record<Tone, string> = { const LABEL_GAP = 10;
amber: 'bg-amber-500',
emerald: 'bg-emerald-500',
zinc: 'bg-zinc-400',
};
const TONE_TEXT: Record<Tone, string> = {
amber: 'text-amber-600',
emerald: 'text-emerald-600',
zinc: 'text-muted-foreground',
};
type Point = { name: string; value: number; tone: Tone; strong?: boolean; place?: 'above' | 'below' }; export type SpectrumBid = { name: string; value: number; isWinner?: boolean };
type Mark = { key: string; name: string; value: number; dot: string; tone?: string; strong?: boolean };
type Placed = { left: number; lane: 0 | 1 };
/** /**
* ·· . * · .
* buildQuotationResult ( ). min~max . * . ( ) .
* (/) ( ) . *
* . / ,
* ( clamp).
*/ */
export function PricingSpectrum({ export function PricingSpectrum({
targetPrice, targetPrice,
lowestBid, ceilingPrice,
highestBid, anchorPrice,
bids,
}: { }: {
targetPrice: number | null; targetPrice: number | null;
lowestBid: number | null; /** 타결 상한가 — 이 위는 결렬 구간. */
highestBid: number | null; ceilingPrice: number | null;
/** 1:1 협상에서만. 1:N 은 협력사마다 달라 대표값이 없다. */
anchorPrice?: number | null;
/** 가격을 제출한 협력사들(투찰가 또는 거부 시 희망가). */
bids: SpectrumBid[];
}) { }) {
// 투찰 0건 → 목표가만 한 줄(폴백). lowestBid/highestBid 는 같은 배열에서 나와 함께 null. const winner = bids.find((b) => b.isWinner) ?? null;
if (lowestBid == null || highestBid == null) { const lowest = bids.length ? bids.reduce((a, b) => (b.value < a.value ? b : a)) : null;
const spot = winner ?? lowest; // 강조점 — 낙찰가가 있으면 그것, 없으면 최저 제출가.
// 라벨을 다는 기준값들(값 오름차순). 나머지 제출가는 점만 찍고 title 로 알려준다.
const marks: Mark[] = [
anchorPrice != null ? { key: 'anchor', name: '앵커링가', value: anchorPrice, dot: 'bg-zinc-400' } : null,
targetPrice != null ? { key: 'target', name: '목표가', value: targetPrice, dot: 'bg-primary', tone: 'text-primary' } : null,
ceilingPrice != null
? { key: 'ceiling', name: '타결 상한', value: ceilingPrice, dot: 'bg-amber-500', tone: 'text-amber-600 dark:text-amber-400' }
: null,
spot
? {
key: 'spot',
name: winner ? `낙찰 · ${winner.name}` : `최저 · ${spot.name}`,
value: spot.value,
dot: winner ? 'bg-emerald-500' : 'bg-zinc-500',
tone: winner ? 'text-emerald-600 dark:text-emerald-400' : undefined,
strong: true,
}
: null,
]
.filter((m): m is Mark => m != null)
.sort((a, b) => a.value - b.value);
const values = [...marks.map((m) => m.value), ...bids.map((b) => b.value)];
const trackRef = useRef<HTMLDivElement>(null);
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [placed, setPlaced] = useState<Placed[]>([]);
// 축 도메인. 타결 상한가가 최댓값이면(= 상한을 넘긴 제출이 없으면) 위쪽에 여유를 크게 둬서
// '초과 구간'이 눈에 보이게 한다 — 여유가 없으면 상한선이 트랙 끝에 처박혀 결렬 구간이 사라진다.
const lo = values.length ? Math.min(...values) : 0;
const hi = values.length ? Math.max(...values) : 0;
const base = hi - lo || Math.max(hi * 0.02, 1);
const padHi = ceilingPrice != null && hi <= ceilingPrice ? base * 0.22 : base * 0.08;
const min = lo - base * 0.08;
const span = hi + padHi - min;
const posOf = (v: number) => clamp(span > 0 ? ((v - min) / span) * 100 : 50, 0, 100);
// 렌더된 라벨 폭을 재서 레인·위치를 정한다. 폭이 바뀌면(창 크기·글꼴) 다시 잰다.
useLayoutEffect(() => {
const track = trackRef.current;
if (!track) return;
const layout = () => {
const trackW = track.clientWidth;
if (!trackW) return;
const laneEnd: [number, number] = [-Infinity, -Infinity];
const next: Placed[] = marks.map((m, i) => {
const w = labelRefs.current[i]?.offsetWidth ?? 0;
const wanted = clamp((posOf(m.value) / 100) * trackW - w / 2, 0, Math.max(trackW - w, 0));
// 부딪히지 않는 레인을 먼저 찾고, 둘 다 막히면 덜 채워진 레인에서 오른쪽으로 민다.
const fits0 = wanted >= laneEnd[0] + LABEL_GAP;
const fits1 = wanted >= laneEnd[1] + LABEL_GAP;
const lane: 0 | 1 = fits0 ? 0 : fits1 ? 1 : laneEnd[0] <= laneEnd[1] ? 0 : 1;
const left = clamp(Math.max(wanted, laneEnd[lane] + LABEL_GAP), 0, Math.max(trackW - w, 0));
laneEnd[lane] = left + w;
return { left, lane };
});
setPlaced(next);
};
layout();
const ro = new ResizeObserver(layout);
ro.observe(track);
return () => ro.disconnect();
// 값 목록이 바뀌면 다시 배치한다(라벨 문자열·개수가 바뀌므로).
}, [marks.map((m) => `${m.key}:${m.value}:${m.name}`).join('|')]); // eslint-disable-line react-hooks/exhaustive-deps
if (values.length === 0) {
return ( return (
<Typography as="p" variant="caption" className="text-[11px]"> <Typography as="p" variant="caption" className="text-[11px]">
<span className="text-amber-600"></span>{' '} .
<span className="font-mono font-semibold text-foreground">{won(targetPrice)}</span>
<span className="text-muted-foreground"> · </span>
</Typography> </Typography>
); );
} }
const singleBid = lowestBid === highestBid; // 투찰 1건 → 최저=최고, 라벨 하나로 축약. // 아무도 가격을 내지 않았으면 타결/결렬 구간을 색으로 단정하지 않는다 — 판정할 대상이 없다.
const hasBids = bids.length > 0;
// 표시할 점(목표가는 없을 수 있음). 값 순으로 정렬해 라벨 방향을 번갈아(below/above) 주면 const laneUsed = (lane: 0 | 1) => placed.some((p) => p.lane === lane);
// 위치가 가까운(정렬상 인접) 라벨끼리 항상 반대쪽에 놓여 안 겹친다.
const points: Point[] = [
targetPrice != null ? { name: '목표가', value: targetPrice, tone: 'amber' } : null,
{ name: singleBid ? '투찰가' : '최저 투찰가', value: lowestBid, tone: 'emerald', strong: true },
singleBid ? null : { name: '최고 투찰가', value: highestBid, tone: 'zinc' },
].filter((p): p is Point => p != null);
const vals = points.map((p) => p.value);
const min = Math.min(...vals);
const max = Math.max(...vals);
const span = max - min;
const posOf = (v: number) => (span > 0 ? ((v - min) / span) * 100 : 50);
[...points]
.sort((a, b) => a.value - b.value)
.forEach((p, i) => {
p.place = i % 2 === 0 ? 'below' : 'above';
});
return ( return (
<div className="w-full px-1"> <div className="w-full">
<div className="relative mx-1 my-3.5 h-1"> {/* 위 레인 자리(라벨이 올라갈 때만 높이 확보) */}
{/* 트랙 */} <div className={cn('relative', laneUsed(1) ? 'h-6' : 'h-0')} aria-hidden />
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-border/60" />
{/* 투찰 스프레드(최저~최고) */} <div ref={trackRef} className="relative h-1.5">
{!singleBid && ( <div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
{/* 타결 구간(상한 이하) / 상한 초과 구간 */}
{ceilingPrice != null && (
<>
<div <div
className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-gradient-to-r from-emerald-400 to-zinc-300" className={cn(
style={{ left: `${posOf(lowestBid)}%`, width: `${posOf(highestBid) - posOf(lowestBid)}%` }} 'absolute top-1/2 h-1.5 -translate-y-1/2 rounded-l-full',
/> hasBids ? 'bg-emerald-100 dark:bg-emerald-950/60' : 'bg-muted-foreground/15',
)} )}
{points.map((p) => ( style={{ left: 0, width: `${posOf(ceilingPrice)}%` }}
<Fragment key={p.name}> />
<Dot pos={posOf(p.value)} tone={p.tone} strong={p.strong} /> <div
<BarLabel className={cn(
pos={posOf(p.value)} 'absolute top-1/2 h-1.5 -translate-y-1/2 rounded-r-full',
place={p.place ?? 'below'} hasBids ? 'bg-rose-100 dark:bg-rose-950/50' : 'bg-muted-foreground/25',
name={p.name} )}
value={won(p.value)} style={{ left: `${posOf(ceilingPrice)}%`, right: 0 }}
tone={p.tone} />
strong={p.strong} </>
)}
{/* 판정 게이트 */}
{anchorPrice != null && <Gate pos={posOf(anchorPrice)} className="bg-zinc-400" />}
{targetPrice != null && <Gate pos={posOf(targetPrice)} className="bg-primary" />}
{ceilingPrice != null && <Gate pos={posOf(ceilingPrice)} className="bg-amber-500" />}
{/* 협력사 제출가 */}
{bids.map((b) => (
<span
key={`${b.name}-${b.value}`}
title={`${b.name} · ${won(b.value)}`}
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card',
b.isWinner
? 'size-3.5 bg-emerald-500 ring-2 ring-emerald-200 dark:ring-emerald-900'
: 'size-2.5 bg-zinc-400 dark:bg-zinc-500',
)}
style={{ left: `${posOf(b.value)}%` }}
/> />
</Fragment>
))} ))}
</div>
</div>
);
}
/** 트랙 위 점. 위치는 0~100% 로 클램프. */ {/* 라벨 — 실측 배치. 첫 렌더(placed 비어있음)에는 기준선 위치에 그대로 두고 바로 재배치된다. */}
function Dot({ pos, tone, strong }: { pos: number; tone: Tone; strong?: boolean }) { {marks.map((m, i) => {
const p = placed[i];
const lane = p?.lane ?? 0;
return ( return (
<div <span
key={m.key}
ref={(el) => {
labelRefs.current[i] = el;
}}
className={cn( className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-background', 'absolute inline-flex items-center gap-1 whitespace-nowrap leading-none',
strong ? 'h-2.5 w-2.5' : 'h-2 w-2', lane === 0 ? 'top-3.5' : 'bottom-3.5',
TONE_DOT[tone],
)} )}
style={{ left: `${clamp(pos, 0, 100)}%` }} style={
/> p
); ? { left: `${p.left}px` }
: { left: `${posOf(m.value)}%`, transform: 'translateX(-50%)', visibility: 'hidden' }
} }
/** 점 위/아래 한 줄 라벨(이름+금액). 양 끝에선 안쪽으로 정렬해 넘침 방지. */
function BarLabel({
pos,
place,
name,
value,
tone,
strong,
}: {
pos: number;
place: 'above' | 'below';
name: string;
value: string;
tone: Tone;
strong?: boolean;
}) {
const align =
pos <= 8 ? 'translate-x-0 text-left' : pos >= 92 ? '-translate-x-full text-right' : '-translate-x-1/2 text-center';
return (
<div
className={cn(
'absolute whitespace-nowrap leading-none',
place === 'above' ? 'bottom-full mb-1' : 'top-full mt-1',
align,
)}
style={{ left: `${clamp(pos, 0, 100)}%` }}
> >
<Typography as="span" variant="caption" className={cn('text-[10px]', TONE_TEXT[tone])}> <span className={cn('size-1.5 shrink-0 rounded-full', m.dot)} />
{name}{' '} <Typography as="span" variant="caption" className={cn('text-[10px]', m.tone)}>
{m.name}
</Typography> </Typography>
<Typography <Typography
as="span" as="span"
variant="small" variant="small"
className={cn('font-mono text-[11px] text-foreground', strong ? 'font-bold' : 'font-semibold')} className={cn(
'font-mono text-[10.5px] tabular-nums text-foreground',
m.strong ? 'font-bold' : 'font-semibold',
m.tone,
)}
> >
{value} {won(m.value)}
</Typography> </Typography>
</span>
);
})}
</div>
{/* 아래 레인 자리. 제출 건수는 있을 때만 — 없다는 사실은 상단 결과 배지와 레일이 이미 말한다. */}
<div className={cn(laneUsed(0) ? 'mt-6' : 'mt-1.5')}>
{hasBids && (
<Typography as="span" variant="caption" className="text-[10.5px]">
{bids.length}
</Typography>
)}
</div>
</div> </div>
); );
} }
/** 트랙을 가로지르는 판정선. */
function Gate({ pos, className }: { pos: number; className: string }) {
return (
<span
className={cn('absolute -top-1.5 -bottom-1.5 w-0.5 -translate-x-1/2 rounded-full', className)}
style={{ left: `${pos}%` }}
/>
);
}

View File

@ -183,7 +183,7 @@ export function RegenerateModal({
<div className="my-4 space-y-4 text-xs"> <div className="my-4 space-y-4 text-xs">
{/* ── 직전 라운드 결과 — 무엇을 바꿔야 할지의 근거 ── */} {/* ── 직전 라운드 결과 — 무엇을 바꿔야 할지의 근거 ── */}
<Section title="직전 라운드 결과" sub={`${quotation.round ?? 1}`}> <Section title="직전 라운드 결과" sub={`${quotation.round ?? 1}`}>
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} /> <ResultSummaryBand quotation={quotation} sessionViews={sessionViews} ceilingRate={inheritedCeilingRate} />
{hint && ( {hint && (
<div className="mt-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20 px-2.5 py-2"> <div className="mt-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20 px-2.5 py-2">
<Lightbulb size={13} className="mt-0.5 shrink-0 text-amber-600" /> <Lightbulb size={13} className="mt-0.5 shrink-0 text-amber-600" />

View File

@ -1,20 +1,20 @@
import type { ReactNode } from 'react'; import { BadgeCheck } from 'lucide-react';
import { BadgeCheck, TrendingDown, TrendingUp } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import type { QuotationData } from '@/api/generated/model/quotationData'; import type { QuotationData } from '@/api/generated/model/quotationData';
import { StatusPill, type PillTone } from './StatusPill'; import { StatusPill, sessionStatusTone, type PillTone } from './StatusPill';
import { PricingSpectrum } from './PricingSpectrum'; import { PricingSpectrum, type SpectrumBid } from './PricingSpectrum';
import { SessionStatus } from '@/api/generated/model';
import { import {
awardPrice,
buildQuotationResult, buildQuotationResult,
ceilingPriceOf,
is1v1,
sessionStatusLabel,
CHAIN_ROUND_STATE_LABEL, CHAIN_ROUND_STATE_LABEL,
type ChainRoundState, type ChainRoundState,
type SessionView, type SessionView,
} from '../../types'; } from '../../types';
const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-');
// 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요). // 결과 상태별 배지 톤(협상현황 pill 팔레트 재사용). 개찰=주황(낙찰자 미정, 수동 처리 필요).
const OUTCOME_TONE: Record<ChainRoundState, PillTone> = { const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
awarded: 'emerald', awarded: 'emerald',
@ -22,85 +22,86 @@ const OUTCOME_TONE: Record<ChainRoundState, PillTone> = {
active: 'zinc', active: 'zinc',
}; };
/** 견적 결과 요약 밴드 — 마감 사유/낙찰 협력사 + 절감, 하단에 단가 조정 흐름 스펙트럼(최고·최저 투찰가/목표가). */ /**
* / , .
* (···) . .
*/
export function ResultSummaryBand({ export function ResultSummaryBand({
quotation, quotation,
sessionViews, sessionViews,
ceilingRate,
}: { }: {
quotation: QuotationData; quotation: QuotationData;
sessionViews: SessionView[]; sessionViews: SessionView[];
/** 타결 상한율(‰) — 견적 override 없으면 적용 세팅값. */
ceilingRate: number;
}) { }) {
const r = buildQuotationResult(quotation, sessionViews); const r = buildQuotationResult(quotation, sessionViews);
const good = r.savings != null && r.savings >= 0; const rep = sessionViews.find((s) => s.target_price > 0) ?? sessionViews[0];
const pct = r.savingsRate != null ? `${Math.abs(r.savingsRate * 100).toFixed(1)}%` : null; const winnerId = quotation.preferred_sp_id ?? null;
const SavingsIcon = good ? TrendingDown : TrendingUp; // 1:1 은 협력사가 하나라 그 세션이 곧 이 협상이다(세션이 여럿이면 대표를 세우지 않는다).
const oneToOne = is1v1(quotation.type);
const solo = oneToOne && sessionViews.length === 1 ? sessionViews[0] : null;
// 가격을 제출한 협력사만 점으로. 낙찰자는 강조.
const bids: SpectrumBid[] = sessionViews.flatMap((s) => {
const value = awardPrice(s);
if (value == null) return [];
return [{ name: s.supplier_name, value, isWinner: winnerId != null && s.supplier_id === winnerId }];
});
return ( return (
<Card className="p-2.5 gap-2 rounded shadow-xs border-border/80 flex flex-col"> <div className="flex flex-col gap-3 rounded border border-border/80 bg-card p-3 shadow-xs">
{/* 결과/사유 + 낙찰 협력사 + 절감 */} <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<div className="flex items-center justify-between gap-2"> {/* 1:1
<div className="flex items-center gap-2 min-w-0"> '거부해서 개찰됐다' ( ). */}
<StatusPill tone={OUTCOME_TONE[r.outcome]}>{CHAIN_ROUND_STATE_LABEL[r.outcome]}</StatusPill> {solo ? (
{/* 낙찰 건은 협력사명을 주줄로(사유 '낙찰'은 pill 과 중복이라 생략), 그 외엔 마감사유를 표기. */} <>
{r.winnerName ? ( <Typography as="span" variant="small" className="truncate font-sans text-[12px] font-bold text-foreground">
<div className="min-w-0"> {solo.supplier_name}
<Typography as="p" variant="caption" className="text-[10px] leading-tight text-muted-foreground">
</Typography> </Typography>
{/* 1:1 ' () ()' .
'진행중' . */}
<StatusPill
tone={
solo.status === SessionStatus.REJECTED
? 'rose' // 거부는 결과(개찰)보다 강한 신호다 — 색을 결과 톤에 묻히게 두지 않는다.
: r.outcome === 'active'
? sessionStatusTone(solo.status)
: OUTCOME_TONE[r.outcome]
}
>
{r.outcome === 'active'
? sessionStatusLabel(solo.status)
: `${sessionStatusLabel(solo.status)}${CHAIN_ROUND_STATE_LABEL[r.outcome]}`}
</StatusPill>
</>
) : (
<StatusPill tone={OUTCOME_TONE[r.outcome]}>{CHAIN_ROUND_STATE_LABEL[r.outcome]}</StatusPill>
)}
{solo ? null : r.winnerName ? (
<Typography <Typography
as="p" as="p"
variant="small" variant="small"
className="flex items-center gap-1 text-[12px] font-bold font-sans text-foreground truncate" className="flex min-w-0 items-center gap-1 truncate font-sans text-[12px] font-bold text-foreground"
> >
<BadgeCheck size={13} className="text-emerald-600 shrink-0" /> <BadgeCheck size={13} className="shrink-0 text-emerald-600" />
{r.winnerName} {r.winnerName}
</Typography> </Typography>
</div>
) : ( ) : (
<Typography as="p" variant="small" className="text-[12px] font-bold truncate min-w-0"> <Typography as="p" variant="small" className="min-w-0 truncate text-[12px] font-bold">
{r.closeReason} {r.closeReason}
</Typography> </Typography>
)} )}
</div> </div>
<Stat label={r.provisional ? '예상 절감' : '목표가 대비'}> <PricingSpectrum
{r.savings == null ? ( targetPrice={r.targetPrice}
<Typography as="span" variant="small" className="font-mono text-muted-foreground text-[13px]"> ceilingPrice={ceilingPriceOf(rep, ceilingRate)}
- anchorPrice={is1v1(quotation.type) ? (rep?.anchoring_price ?? null) : null}
</Typography> bids={bids}
) : ( />
<Typography
as="span"
variant="small"
className={cn(
'inline-flex items-center gap-1 font-mono font-bold text-[13px]',
good ? 'text-emerald-600' : 'text-amber-600',
)}
>
<SavingsIcon size={13} />
{good ? '' : ''}
{won(Math.abs(r.savings))}
{pct ? ` (${pct})` : ''}
</Typography>
)}
</Stat>
</div>
{/* 단가 조정 흐름 스펙트럼: 최고 투찰가 → 최저 투찰가 · 목표가 */}
<PricingSpectrum targetPrice={r.targetPrice} lowestBid={r.lowestBid} highestBid={r.highestBid} />
</Card>
);
}
/** 밴드 안의 라벨/값 한 칸. */
function Stat({ label, sub, children }: { label: string; sub?: string; children: ReactNode }) {
return (
<div className="flex flex-col justify-center rounded border border-border bg-muted/30 px-3 py-1.5 min-w-[108px]">
<Typography as="span" variant="caption" className="text-[10px] leading-tight">
{label}
{sub ? ` · ${sub}` : ''}
</Typography>
{children}
</div> </div>
); );
} }

View File

@ -9,7 +9,8 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@
import { SessionStatus } from '@/api/generated/model'; import { SessionStatus } from '@/api/generated/model';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { StatusPill, sessionStatusTone } from './StatusPill'; import { StatusPill, sessionStatusTone } from './StatusPill';
import { awardPrice, mapServerSessionView, sessionStatusLabel } from '../../types'; import { AwardModal } from './AwardModal';
import { awardPrice, offlineAward, mapServerSessionView, sessionStatusLabel } from '../../types';
import { useCompanySettings } from '@/features/settings/useCompanySettings'; import { useCompanySettings } from '@/features/settings/useCompanySettings';
type SessionView = ReturnType<typeof mapServerSessionView>; type SessionView = ReturnType<typeof mapServerSessionView>;
@ -37,7 +38,7 @@ export function SessionsStatusTab({
/** 한 세션(공급사)에 초청 메일 발송/재발송. */ /** 한 세션(공급사)에 초청 메일 발송/재발송. */
onNotifyOne: (sessionId: string) => Promise<void>; onNotifyOne: (sessionId: string) => Promise<void>;
/** 고른 협력사를 낙찰 처리. 성공 시 true. */ /** 고른 협력사를 낙찰 처리. 성공 시 true. */
onAward: (supplierId: string, supplierName: string) => Promise<boolean>; onAward: (supplierId: string, supplierName: string, contractPrice: number, contractNote: string) => Promise<boolean>;
}) { }) {
const { settings } = useCompanySettings(); const { settings } = useCompanySettings();
const sessionFields = settings.session_fields ?? []; const sessionFields = settings.session_fields ?? [];
@ -55,34 +56,32 @@ export function SessionsStatusTab({
const [sendingId, setSendingId] = useState<string | null>(null); const [sendingId, setSendingId] = useState<string | null>(null);
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null); const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
const [awarding, setAwarding] = useState(false); const [awarding, setAwarding] = useState(false);
const [awardOpen, setAwardOpen] = useState(false);
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length; const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
// 낙찰 후보 = 가격을 써낸 협력사(투찰한 협상완료 + 공급 희망가를 남긴 협상거부). // 낙찰 후보 = 이 견적에 초청된 협력사 전부. 결렬·미응찰 건은 오프라인으로 다시 협상하고 그 결과를
// 협상이 결렬돼도 최종 제출가로 계약을 진행하므로 거부 건도 후보에 넣는다. // 반영하는 자리라, 시스템에 가격을 안 낸 협력사도 고를 수 있어야 한다(계약가는 담당자가 입력).
const candidates = sessionViews.filter((s) => awardPrice(s) != null); const candidates = sessionViews;
const showAward = canAward && candidates.length > 0; const showAward = canAward && candidates.length > 0;
// 최저가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨). // 최저 제출가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
const lowestBid = candidates.length ? Math.min(...candidates.map((s) => awardPrice(s) as number)) : null; const submitted = candidates.map(awardPrice).filter((v): v is number => v != null);
const lowestBid = submitted.length ? Math.min(...submitted) : null;
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null; const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
const colCount = showAward ? 13 : 12; // 상품은 견적당 하나다 — 모든 행이 같은 값이라 컬럼을 두지 않는다(헤더 상품 카드가 정본).
// 마감시각은 세션 컬럼이라 갈릴 수 있어, 실제로 다를 때만 세운다.
const showEndTimeCol = new Set(sessionViews.map((s) => s.end_time)).size > 1;
const colCount = (showAward ? 13 : 12) - (showEndTimeCol ? 0 : 1);
const handleAward = async () => { const handleAward = async (contractPrice: number, contractNote: string): Promise<boolean> => {
if (!selectedWinner) return; if (!selectedWinner) return false;
const name = selectedWinner.supplier_name;
const winnerPrice = awardPrice(selectedWinner);
const price = winnerPrice != null ? `${winnerPrice.toLocaleString()}` : '-';
if (
!(await confirm({
title: '직접 낙찰',
description: `[${name}] (계약가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
confirmText: '낙찰 확정',
}))
)
return;
setAwarding(true); setAwarding(true);
try { try {
const ok = await onAward(selectedWinner.supplier_id, name); const ok = await onAward(selectedWinner.supplier_id, selectedWinner.supplier_name, contractPrice, contractNote);
if (ok) setSelectedWinnerId(null); if (ok) {
setSelectedWinnerId(null);
setAwardOpen(false);
}
return ok;
} finally { } finally {
setAwarding(false); setAwarding(false);
} }
@ -156,7 +155,7 @@ export function SessionsStatusTab({
{selectedWinner && <span className="ml-1 font-bold">: {selectedWinner.supplier_name}</span>} {selectedWinner && <span className="ml-1 font-bold">: {selectedWinner.supplier_name}</span>}
</Typography> </Typography>
<button <button
onClick={handleAward} onClick={() => setAwardOpen(true)}
disabled={!selectedWinner || awarding} disabled={!selectedWinner || awarding}
title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'} title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'}
className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed" className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
@ -168,22 +167,22 @@ export function SessionsStatusTab({
)} )}
<div className="hidden xl:block border border-border rounded-lg bg-card overflow-x-auto"> <div className="hidden xl:block border border-border rounded-lg bg-card overflow-x-auto">
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]"> <Table className={cn('w-full text-left text-xs border-collapse font-mono', showEndTimeCol ? 'min-w-[1140px]' : 'min-w-[1040px]')}>
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border"> <TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow> <TableRow>
{showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14"></TableHead>} {showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14"></TableHead>}
<TableHead className="p-3 font-semibold font-sans"></TableHead> <TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead> <TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead> <TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-center font-sans"></TableHead> <TableHead className="p-3 font-semibold text-center font-sans"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead> <TableHead className="p-3 font-semibold text-right"></TableHead>
<TableHead className="p-3 font-semibold text-right"></TableHead> <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"></TableHead> {showEndTimeCol && <TableHead className="p-3 font-semibold"></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 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 font-sans"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="divide-y divide-border"> <TableBody className="divide-y divide-border">
@ -195,7 +194,7 @@ export function SessionsStatusTab({
</TableRow> </TableRow>
)} )}
{sessionViews.map((sess) => { {sessionViews.map((sess) => {
const isCandidate = awardPrice(sess) != null; const isCandidate = true; // 오프라인 협상 반영 — 가격을 안 낸 협력사도 낙찰 대상
const isRecommended = isCandidate && awardPrice(sess) === lowestBid; const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId; const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
return ( return (
@ -314,7 +313,6 @@ export function SessionsStatusTab({
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center"> <TableCell className="p-3 text-center">
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill> <StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell> </TableCell>
@ -334,14 +332,24 @@ export function SessionsStatusTab({
</TableCell> </TableCell>
<TableCell className="p-3 text-right font-bold text-foreground"> <TableCell className="p-3 text-right font-bold text-foreground">
{sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-'} {sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-'}
{offlineAward(sess) && (
<Typography as="span" variant="caption" className="block text-[10px] font-bold text-success">
{offlineAward(sess)!.price.toLocaleString()}
</Typography>
)}
</TableCell> </TableCell>
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell> <TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
{showEndTimeCol && (
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell> <TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
)}
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell> <TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
<TableCell className="p-3 text-right text-rose-600 font-mono"> <TableCell className="p-3 text-right text-rose-600 font-mono">
{sess.reject_price ? `${sess.reject_price.toLocaleString()}` : '-'} {sess.reject_price ? `${sess.reject_price.toLocaleString()}` : '-'}
</TableCell> </TableCell>
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell> <TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
<TableCell className="p-3 font-sans">
<ExtraInfoCell rows={extraRows(sess)} onOpen={() => setExtraSession(sess)} />
</TableCell>
</TableRow> </TableRow>
); );
})} })}
@ -357,23 +365,28 @@ export function SessionsStatusTab({
</Typography> </Typography>
)} )}
{sessionViews.map((sess) => { {sessionViews.map((sess) => {
const isCandidate = awardPrice(sess) != null; const isCandidate = true; // 오프라인 협상 반영 — 가격을 안 낸 협력사도 낙찰 대상
const isRecommended = isCandidate && awardPrice(sess) === lowestBid; const isRecommended = isCandidate && awardPrice(sess) === lowestBid;
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId; const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
const rows: { label: string; value: string }[] = [ const rows: { label: string; value: string }[] = [
{ label: '상품', value: sess.item_name || '-' },
{ {
label: '앵커링가', label: '앵커링가',
value: sess.anchoring_price > 0 ? `${sess.anchoring_price.toLocaleString()}` : '-', value: sess.anchoring_price > 0 ? `${sess.anchoring_price.toLocaleString()}` : '-',
}, },
{ label: '투찰가', value: sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-' }, { label: '투찰가', value: sess.bid_price ? `${sess.bid_price.toLocaleString()}` : '-' },
{ label: '투찰시각', value: sess.bid_at || '-' }, { label: '투찰시각', value: sess.bid_at || '-' },
{ label: '마감시각', value: sess.end_time || '-' },
]; ];
// 거부 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게). // 거부 정보는 값이 있을 때만(빈 줄로 카드가 길어지지 않게).
if (sess.reject_reason) rows.push({ label: '거부사유', value: sess.reject_reason }); 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_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_delivery_type) rows.push({ label: '거부배송방식', value: sess.reject_delivery_type });
const offline = offlineAward(sess);
if (offline) rows.push({ label: '계약가(오프라인)', value: `${offline.price.toLocaleString()}` });
if (showEndTimeCol) rows.push({ label: '마감시각', value: sess.end_time || '-' });
const extras = extraRows(sess);
if (extras.length > 0) {
rows.push({ label: '부가정보', value: extras.map((r) => `${r.label} ${String(r.value)}`).join(' · ') });
}
return ( return (
<div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}> <div key={sess.session_id} className={cn('p-3', isWinner && 'bg-success/10')}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -489,6 +502,37 @@ export function SessionsStatusTab({
</div> </div>
</div> </div>
)} )}
{awardOpen && selectedWinner && (
<AwardModal
open={awardOpen}
winner={selectedWinner}
onConfirm={handleAward}
onClose={() => setAwardOpen(false)}
/>
)}
</div> </div>
); );
} }
/** 협상현황 표의 부가정보 칸 — 입력값을 한 줄로 요약하고, 클릭하면 전체를 모달로 연다. */
function ExtraInfoCell({
rows,
onOpen,
}: {
rows: { label: string; value: unknown }[];
onOpen: () => void;
}) {
if (rows.length === 0) return <span className="text-muted-foreground">-</span>;
const text = rows.map((r) => `${r.label} ${String(r.value)}`).join(' · ');
return (
<button
type="button"
onClick={onOpen}
title={text}
className="block max-w-[220px] truncate text-left text-emerald-700 hover:underline cursor-pointer dark:text-emerald-400"
>
{text}
</button>
);
}

View File

@ -22,12 +22,16 @@ import {
mapSetting, mapSetting,
mapServerSessionView, mapServerSessionView,
mapServerCardView, mapServerCardView,
buildPriceRail,
quotationTypeLabel,
chainRoundState, chainRoundState,
type NegotiationCard, type NegotiationCard,
} from '../../types'; } from '../../types';
import { QuotationStatus } from '@/api/generated/model'; import { QuotationStatus } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards'; import { DrawerHeaderCards } from './DrawerHeaderCards';
import { PriceRail } from './PriceRail';
import { RoundTimeline } from './RoundTimeline'; import { RoundTimeline } from './RoundTimeline';
import { StatusPill } from './StatusPill';
import { RegenerateModal } from './RegenerateModal'; import { RegenerateModal } from './RegenerateModal';
import type { RegenerateInput } from '../../hooks/useQuotations'; import type { RegenerateInput } from '../../hooks/useQuotations';
import { SessionsStatusTab } from './SessionsStatusTab'; import { SessionsStatusTab } from './SessionsStatusTab';
@ -41,7 +45,13 @@ type QuotationDetailSheetProps = {
quotation: QuotationData; quotation: QuotationData;
onCloseQuotation: (id: string, name: string) => void; onCloseQuotation: (id: string, name: string) => void;
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */ /** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>; onAward: (
qtId: string,
winnerSupplierId: string,
winnerName: string,
contractPrice: number,
contractNote: string,
) => Promise<boolean>;
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */ /** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
onSwitchRound: (qtId: string) => void; onSwitchRound: (qtId: string) => void;
/** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */ /** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */
@ -139,6 +149,11 @@ export function QuotationDetailSheet({
// 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다. // 세션은 모두 같은 상품을 가리키므로(1견적=1상품) 단건 상품 하나로 item_name 해석이 끝난다.
const productList = currentProduct ? [currentProduct] : []; const productList = currentProduct ? [currentProduct] : [];
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList)); const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, productList));
// 헤더에 고정할 가격 레일 — 접힘/펼침·탭과 무관하게 항상 같은 값을 본다.
const railCeilingRate = quotation.done_ceiling_rate ?? settingCeilingRate;
const priceRail = buildPriceRail(quotation, sessionViews, railCeilingRate);
const railTargetSessionId =
(sessionViews.find((s) => s.target_price > 0) ?? sessionViews[0])?.session_id ?? null;
const quotationCardViews = serverCards.map(mapServerCardView); const quotationCardViews = serverCards.map(mapServerCardView);
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산). // 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
@ -182,10 +197,13 @@ export function QuotationDetailSheet({
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30"> <div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase"> <Typography variant="h3">{q_name}</Typography>
<span> // {q_number}</span> <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[12px] font-bold tracking-tight text-foreground">{q_number}</span>
<StatusPill tone="zinc">{quotationTypeLabel(quotation.type)}</StatusPill>
{/* 차수는 라운드가 하나뿐이라 타임라인이 안 뜰 때만 — 뜨면 그쪽이 현재 차수를 보여준다. */}
{chainRounds.length <= 1 && <StatusPill tone="zinc">{quotation.round ?? 1}</StatusPill>}
</div> </div>
<Typography variant="h3" className="mt-1">{q_name}</Typography>
{quotation.number && ( {quotation.number && (
<RoundTimeline number={quotation.number} currentQtId={qtId} onSwitchRound={onSwitchRound} /> <RoundTimeline number={quotation.number} currentQtId={qtId} onSwitchRound={onSwitchRound} />
)} )}
@ -252,25 +270,21 @@ export function QuotationDetailSheet({
</div> </div>
</div> </div>
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */} {/* 가격 레일 — 접힘·펼침·탭 전환과 무관하게 항상 붙어 있다. 상세를 펼쳐 스크롤해도 기준가는 남는다. */}
{showHeaderCards ? ( <div className="shrink-0">
<div <PriceRail
style={{ flex: '2 1 0%' }} rail={priceRail}
className="min-h-0 overflow-y-auto overscroll-contain px-6 pb-6 bg-muted/30 border-b border-border" onShowTarget={railTargetSessionId ? () => setTargetSessionId(railTargetSessionId) : undefined}
>
<DrawerHeaderCards
quotation={quotation}
quotationSettings={quotationSettings}
sessionViews={sessionViews}
currentProduct={currentProduct}
onShowTarget={setTargetSessionId}
/> />
</div> </div>
) : (
/* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */ {/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
<div className="shrink-0 px-6 pt-4 pb-4 bg-muted/30 border-b border-border"> {showHeaderCards && (
<div
style={{ flex: '2 1 0%' }}
className="min-h-0 overflow-y-auto overscroll-contain px-6 py-4 bg-muted/30 border-b border-border"
>
<DrawerHeaderCards <DrawerHeaderCards
collapsed
quotation={quotation} quotation={quotation}
quotationSettings={quotationSettings} quotationSettings={quotationSettings}
sessionViews={sessionViews} sessionViews={sessionViews}
@ -290,13 +304,11 @@ export function QuotationDetailSheet({
key={tab.id} key={tab.id}
onClick={() => { onClick={() => {
setActiveTab(tab.id); setActiveTab(tab.id);
if (tab.id === 'chat') { // 탭을 고르면 견적 상세 정보를 접어 본문에 높이를 넘긴다(가격 레일은 그대로 남는다).
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
setShowHeaderCards(false); setShowHeaderCards(false);
if (serverSessions.length > 0 && !selectedSessionId) { if (tab.id === 'chat' && serverSessions.length > 0 && !selectedSessionId) {
setSelectedSessionId(serverSessions[0].session_id); setSelectedSessionId(serverSessions[0].session_id);
} }
}
}} }}
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${ className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
activeTab === tab.id activeTab === tab.id
@ -323,7 +335,9 @@ export function QuotationDetailSheet({
onOpenChat={goToChat} onOpenChat={goToChat}
onNotifyAll={() => onNotify(qtId)} onNotifyAll={() => onNotify(qtId)}
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)} onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)} onAward={(supplierId, supplierName, contractPrice, contractNote) =>
onAward(qtId, supplierId, supplierName, contractPrice, contractNote)
}
/> />
)} )}

View File

@ -134,9 +134,18 @@ export function useQuotations(params: ListQuotationsParams) {
// 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림). // 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림).
// 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화. // 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화.
const awardQuotation = async (qtId: string, winnerSupplierId: string, winnerName: string): Promise<boolean> => { const awardQuotation = async (
qtId: string,
winnerSupplierId: string,
winnerName: string,
contractPrice: number,
contractNote?: string,
): Promise<boolean> => {
try { try {
const res = await awardQuotationMutation.mutateAsync({ qtId, data: { winner_supplier_id: winnerSupplierId } }); const res = await awardQuotationMutation.mutateAsync({
qtId,
data: { winner_supplier_id: winnerSupplierId, contract_price: contractPrice, contract_note: contractNote || null },
});
if (!res?.result?.success) { if (!res?.result?.success) {
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
const code = res?.result?.code; const code = res?.result?.code;

View File

@ -244,6 +244,7 @@ export type SessionView = {
status: number; status: number;
target_price: number; target_price: number;
anchoring_price: number; anchoring_price: number;
done_ceiling_price: number; // 완료 상한가(원) — 생성 시 박제값. 0=미박제(구 견적)
bid_price: number | null; bid_price: number | null;
bid_at: string; bid_at: string;
reject_reason: string | null; reject_reason: string | null;
@ -283,14 +284,95 @@ export type QuotationResultView = {
savingsRate: number | null; savingsRate: number | null;
}; };
// 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격. // 타결 상한가 — 합의가가 이 금액 이하면 타결, 초과하면 결렬.
// 결렬 건도 최종 제출가로 계약을 진행하므로 낙찰 후보·결과 표기가 같은 기준을 본다. // 세션 박제값(생성 시점)이 정본이다. 생성 후 세팅이 바뀌어도 협상은 박제값으로 판정하기 때문.
// 박제 이전 견적은 값이 0/없음이라 같은 식(목표가×(1+율/1000), 10원 반올림)으로 환산해 보여준다.
export function ceilingPriceOf(session: SessionView | undefined, ceilingRate: number): number | null {
if (session?.done_ceiling_price) return session.done_ceiling_price;
if (!session?.target_price) return null;
return Math.round((session.target_price * (1000 + ceilingRate)) / 10000) * 10;
}
export type PriceRailView = {
oneToOne: boolean;
/** 1:1 전용 — 앵커링가는 (상품×공급사)마다 달라 1:N 에는 견적 대표값이 없다(협상현황 표의 협력사별 컬럼으로 본다). */
anchorPrice: number | null;
/** 1:N 전용 — 가격을 제출한 협력사 수 / 초청 협력사 수. */
bidCount: number;
supplierCount: number;
targetPrice: number | null;
ceilingPrice: number | null;
ceilingRate: number; // ‰
/** 마지막 칸 — 상태에 따라 낙찰가 / 최저 투찰가 / 현재 제시가로 바뀐다. */
resultLabel: string;
resultPrice: number | null;
resultBadge: string | null;
savings: number | null;
savingsRate: number | null;
outcome: ChainRoundState;
};
// 헤더 가격 레일 파생. 칸 수는 항상 넷이라 상태가 바뀌어도 화면이 흔들리지 않는다.
export function buildPriceRail(
q: QuotationData,
sessions: SessionView[],
ceilingRate: number,
): PriceRailView {
const r = buildQuotationResult(q, sessions);
const oneToOne = is1v1(q.type);
// 목표가·상한가는 상품 단위(1견적=1상품)라 대표 세션 하나로 읽는다. 앵커는 공급사 단위라 1:1 에서만.
const rep = sessions.find((s) => s.target_price > 0) ?? sessions[0];
const priced = sessions.map(awardPrice).filter((v): v is number => v != null);
const resultLabel =
r.outcome === 'awarded' ? '낙찰가'
: r.outcome === 'opened' ? '최저 투찰가'
: oneToOne ? '현재 제시가' : '현재 최저 투찰가';
const resultBadge =
r.outcome === 'awarded' ? null
: r.outcome === 'opened' ? '낙찰자 미정'
: '협상 중';
return {
oneToOne,
anchorPrice: oneToOne ? (rep?.anchoring_price ?? null) : null,
bidCount: priced.length,
supplierCount: sessions.length,
targetPrice: r.targetPrice,
ceilingPrice: ceilingPriceOf(rep, ceilingRate),
ceilingRate,
resultLabel,
resultPrice: r.outcome === 'awarded' ? r.winnerPrice : r.lowestBid,
resultBadge,
// 절감은 낙찰 확정 건만 — 진행 중 잠정 최저가로 절감을 말하면 나중에 뒤집힌다.
savings: r.outcome === 'awarded' ? r.savings : null,
savingsRate: r.outcome === 'awarded' ? r.savingsRate : null,
outcome: r.outcome,
};
}
// 협력사가 시스템에 제출한 가격 — 타결했으면 투찰가, 거부로 끝났으면 그때 써낸 공급 희망 가격.
// 직접 낙찰의 계약가 기본값·최저가 추천에 쓴다. 확정 계약가는 contractPrice 를 본다.
export function awardPrice(s: SessionView): 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.DONE && s.bid_price != null) return s.bid_price;
if (s.status === SessionStatus.REJECTED && s.reject_price != null) return s.reject_price; if (s.status === SessionStatus.REJECTED && s.reject_price != null) return s.reject_price;
return null; return null;
} }
// 오프라인 협상 결과로 담당자가 확정한 계약가(sessions.custom.offline_award).
// 결렬·미응찰 건을 오프라인으로 다시 협상해 낙찰시킨 경우라 협력사 제출가와 다를 수 있다.
export function offlineAward(s: SessionView): { price: number; note: string; at: string } | null {
const raw = s.custom?.offline_award as { price?: unknown; note?: unknown; at?: unknown } | undefined;
const price = Number(raw?.price);
if (!raw || !Number.isFinite(price) || price <= 0) return null;
return { price, note: String(raw.note ?? ''), at: String(raw.at ?? '') };
}
// 확정 계약가 — 담당자가 넣은 값이 있으면 그것, 없으면 협력사 제출가. 결과 표기·절감 계산의 기준.
export function contractPrice(s: SessionView): number | null {
return offlineAward(s)?.price ?? awardPrice(s);
}
export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView { export function buildQuotationResult(q: QuotationData, sessions: SessionView[]): QuotationResultView {
const winnerId = q.preferred_sp_id ?? null; const winnerId = q.preferred_sp_id ?? null;
const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null; const winnerSession = winnerId ? sessions.find((s) => s.supplier_id === winnerId) ?? null : null;
@ -299,7 +381,8 @@ export function buildQuotationResult(q: QuotationData, sessions: SessionView[]):
const bids = sessions.map(awardPrice).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 lowestBid = bids.length ? Math.min(...bids) : null;
const highestBid = bids.length ? Math.max(...bids) : null; const highestBid = bids.length ? Math.max(...bids) : null;
const winnerPrice = (winnerSession ? awardPrice(winnerSession) : null) ?? lowestBid; // 낙찰가는 확정 계약가 기준 — 오프라인으로 다시 협상해 낙찰한 건은 제출가와 다르다.
const winnerPrice = (winnerSession ? contractPrice(winnerSession) : null) ?? lowestBid;
const provisional = !winnerSession; const provisional = !winnerSession;
const sessionTarget = const sessionTarget =
@ -392,6 +475,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
status: sd.status, status: sd.status,
target_price: sd.target_price ?? 0, target_price: sd.target_price ?? 0,
anchoring_price: sd.anchoring_price ?? 0, anchoring_price: sd.anchoring_price ?? 0,
done_ceiling_price: sd.done_ceiling_price ?? 0,
bid_price: sd.bid_price ?? null, bid_price: sd.bid_price ?? null,
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-', bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
reject_reason: sd.reject_reason ?? null, reject_reason: sd.reject_reason ?? null,

View File

@ -34,7 +34,14 @@ export function StatisticsView({ data }: { data: StatData }) {
delta={{ text: `${signedWonCompact(k.savingsDeltaMoM)} 전월비`, good: k.savingsDeltaMoM >= 0 }} delta={{ text: `${signedWonCompact(k.savingsDeltaMoM)} 전월비`, good: k.savingsDeltaMoM >= 0 }}
/> />
<StatTile label="평균 절감률" value={pct(k.savingsRate)} icon={Percent} tone="emerald" /> <StatTile label="평균 절감률" value={pct(k.savingsRate)} icon={Percent} tone="emerald" />
<StatTile label="낙찰률" value={pct(k.awardRate)} icon={Award} tone="blue" /> <StatTile
label="낙찰률"
value={pct(k.awardRate)}
icon={Award}
tone="blue"
// 오프라인으로 다시 협상해 담당자가 반영한 건수 — 절감액엔 포함되지만 AI 협상 성과와 섞이지 않게 따로 짚는다.
delta={k.offlineAwardCount > 0 ? { text: `오프라인 반영 ${k.offlineAwardCount}` } : undefined}
/>
<StatTile label="평균 앵커 도달률" value={pct(k.anchorReachRate)} icon={Target} tone="purple" /> <StatTile label="평균 앵커 도달률" value={pct(k.anchorReachRate)} icon={Target} tone="purple" />
<StatTile label="마감 견적" value={`${k.closedCount}`} icon={CircleCheckBig} tone="zinc" /> <StatTile label="마감 견적" value={`${k.closedCount}`} icon={CircleCheckBig} tone="zinc" />
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" /> <StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />

View File

@ -18,6 +18,7 @@ function mapScope(s?: ApiScope): StatData {
closedCount: k.closed_count ?? 0, closedCount: k.closed_count ?? 0,
regenAvgRound: k.regen_avg_round ?? 0, regenAvgRound: k.regen_avg_round ?? 0,
markupSuppressionRate: k.markup_suppression_rate ?? 0, markupSuppressionRate: k.markup_suppression_rate ?? 0,
offlineAwardCount: k.offline_award_count ?? 0,
}, },
trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })), trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })),
markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })), markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })),

View File

@ -12,6 +12,7 @@ export interface StatKpi {
closedCount: number; // 마감 견적 수(창) closedCount: number; // 마감 견적 수(창)
regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음) regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음)
markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율) markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율)
offlineAwardCount: number; // 낙찰 중 오프라인 협상 결과를 담당자가 반영한 건수(절감액엔 포함)
} }
export interface MonthPoint { export interface MonthPoint {