diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 8cb556b..5364f97 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -320,7 +320,8 @@ class ChatService: await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id) session.context["last_state"] = idx 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.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)) await QTablePolicyStore.persist_cell(repo, version_id, policy, 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]) @staticmethod @@ -499,13 +500,26 @@ class ChatService: prior[a] = 0.3 * (n - rank) / n 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 = { "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, "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: await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)]) except Exception as ex: diff --git a/agent/services/negotiation_service.py b/agent/services/negotiation_service.py index 1b0744d..f7b3167 100644 --- a/agent/services/negotiation_service.py +++ b/agent/services/negotiation_service.py @@ -97,10 +97,10 @@ class NegotiationService: # 7) experience_logs 기록 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 - 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) data = { "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, "reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING, "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, } try: diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 1413723..b42393c 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -143,6 +143,9 @@ class SessionCRUD(ISessionCRUD): sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조 # 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다. 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(quotations, quotations.qt_id == sessions.quotation_id) @@ -224,7 +227,7 @@ class SessionCRUD(ISessionCRUD): ) -> ErrorType: try: values = {"status": status, "reject_reason": reject_reason} - # 공급 희망 가격은 채팅 중 거부에서만 들어온다 — 목록 거부는 가격 없이 사유만 남긴다. + # 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다. if reject_price is not None: values["reject_price"] = reject_price query = ( diff --git a/backend/router/v1/chat/protocol.py b/backend/router/v1/chat/protocol.py index 74f2081..701dfc3 100644 --- a/backend/router/v1/chat/protocol.py +++ b/backend/router/v1/chat/protocol.py @@ -79,6 +79,8 @@ class Res_ChatInit(Res_WebPacketProtocol): item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)") item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)") 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) 치환용. 없으면 프론트 기본값") diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 8e000ef..c24e661 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -22,6 +22,8 @@ class ListItem(WebPacketProtocol): renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열") result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)") has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용") + reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열") + reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null") class Res_SessionList(Res_WebPacketProtocol): @@ -37,7 +39,7 @@ class Res_Participate(Res_WebPacketProtocol): class Req_Reject(WebPacketProtocol): 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 에 병합") diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 9c99d83..d882fc8 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -71,6 +71,14 @@ class ChatService: opinion = None if ", 의견-" in s: 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 if ", 합의불가사유-" in s: price_part, reason = s.split(", 합의불가사유-", 1) @@ -251,6 +259,9 @@ class ChatService: res.item_vat_yn = item.vat_yn res.item_delivery_fee_yn = item.delivery_fee_yn res.custom = sess.custom or {} + # 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다. + res.reject_reason = sess.reject_reason or "" + res.reject_price = sess.reject_price # 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백. _e, settings = await DB_SESSION_MNG.execute_lambda( diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index 29cfb33..15f3c42 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -184,6 +184,8 @@ class NegotiationService: renegotiation_memo=renego.get("memo") or "", result=NegotiationService._to_result(r[10], r[11], r[13], r[14]), has_chat=bool(r[15]), + reject_reason=r[16] or "", + reject_price=r[17], ) @staticmethod diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 819bc20..8d5bce6 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -220,6 +220,23 @@ async def test_chat_init_returns_meta(client, chat_seed): 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): token = await _login_token(client) body = (await _init(client, token, chat_seed["sids"]["X"])).json() diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index 11385bf..f61b8e1 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -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 +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): token = await _login_token(client) r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음 diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts index f3bded6..f4733f0 100644 --- a/frontend/src/apis/chat/chat.type.ts +++ b/frontend/src/apis/chat/chat.type.ts @@ -49,6 +49,8 @@ export interface ChatInitResponse { item_delivery_fee_yn?: boolean custom?: Record labels?: Record + reject_reason?: string + reject_price?: number | null } export interface ChatMessagesResponse { @@ -108,5 +110,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData { quotation_memo: r.quotation_memo ?? '', quotation_end_time: r.quotation_end_time ?? '', labels: r.labels ?? {}, + reject_reason: r.reject_reason ?? '', + reject_price: r.reject_price ?? null, } } diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index d744eab..69a50e0 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -63,6 +63,8 @@ export interface SessionListItem { renegotiation_memo: string // 담당자 심사 메모(반려 사유) result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용 + reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 '' + reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null } /** 공급사 관점 협상 결과 (sessions 파생) */ @@ -119,9 +121,9 @@ export interface ParticipateResponse { } // --- 거부 (POST /v1/negotiation/sessions/{id}/reject) --------------------- -// 목록의 협상 거부와 채팅 중 협상 거부가 같은 엔드포인트를 쓴다. -// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. -// reject_price/opinion: 채팅 중 거부에서만 채운다(목록 거부는 사유만). +// 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다. +// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수. +// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합). export interface RejectRequest { reject_reason: string reject_price?: number diff --git a/frontend/src/components/RejectDetailPopup.tsx b/frontend/src/components/RejectDetailPopup.tsx new file mode 100644 index 0000000..9f58b10 --- /dev/null +++ b/frontend/src/components/RejectDetailPopup.tsx @@ -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 ( + +
+
+
+

협상 거부 사유 (보기)

+

+ {subtitle} + {subtitle ? ' · ' : ''}제출 후에는 수정할 수 없습니다. +

+
+ +
+ +
+ + + +
+ +
+ +
+
+
+ ) +} + +// 부가정보 팝업의 읽기전용 필드와 같은 모양(라벨 + 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 ( +
+ + {rows ? ( +