diff --git a/backend/router/v1/chat/protocol.py b/backend/router/v1/chat/protocol.py index cdc1892..33a7835 100644 --- a/backend/router/v1/chat/protocol.py +++ b/backend/router/v1/chat/protocol.py @@ -78,6 +78,7 @@ 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). 재진입 시 폼 프리필용") + labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값") # 대화 히스토리(재진입 복원) diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 53a51f8..572ffe2 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -24,6 +24,7 @@ from common.logger import LOG from common.models.gmodel import UserInfo from crud.chat_crud import ChatCRUD, IChatCRUD from crud.session_crud import ISessionCRUD, SessionCRUD +from crud.user_crud import IUserCRUD, UserCRUD from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend from services.agent_client import AgentChatContext, IAgentClient, get_agent_client from services.auth_service import AuthService @@ -44,11 +45,13 @@ class ChatService: auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD), chat_crud: IChatCRUD = Depends(ChatCRUD), + user_crud: IUserCRUD = Depends(UserCRUD), agent: IAgentClient = Depends(get_agent_client), ): self.auth = auth self.session_crud = session_crud self.chat_crud = chat_crud + self.user_crud = user_crud self.agent = agent # ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ---- @@ -59,6 +62,27 @@ class ChatService: digits = "".join(ch for ch in text if ch.isdigit()) return int(digits) if digits else None + @staticmethod + def _parse_reject(text: Optional[str]) -> dict: + """통일 결렬 폼 제출 문자열 파싱 → {offer_price, reason, opinion}. + 형식: '공급희망가격-{원}, 합의불가사유-{사유}, 의견-{의견}' (사유는 '기타-{내용}' 가능). + 의견은 자유서술이라 콤마 포함 가능 → 맨 뒤 '의견-' 기준으로 먼저 떼어낸다.""" + s = text or "" + opinion = None + if ", 의견-" in s: + s, opinion = s.split(", 의견-", 1) + reason = None + if ", 합의불가사유-" in s: + price_part, reason = s.split(", 합의불가사유-", 1) + else: + price_part = s + price_digits = "".join(ch for ch in price_part.replace("공급희망가격-", "") if ch.isdigit()) + return { + "offer_price": int(price_digits) if price_digits else None, + "reason": reason or None, + "opinion": (opinion.strip() or None) if opinion is not None else None, + } + @staticmethod def _in_price_range(price: int, target_price: Optional[int]) -> bool: if not target_price: @@ -229,6 +253,13 @@ class ChatService: res.item_vat_yn = item.vat_yn res.item_delivery_fee_yn = item.delivery_fee_yn res.custom = sess.custom or {} + + # 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백. + _e, settings = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), DBWRType.DB_READ.value, + lambda s: self.user_crud.get_company_settings(s, sess.supplier_id), + ) + res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {} return res async def _ensure_in_progress(self, sess, quote) -> None: @@ -455,10 +486,15 @@ class ChatService: funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid)) else: new_status = SessionStatus.REJECTED.value + parsed = self._parse_reject(user_input) funcs.append(lambda s: self.chat_crud.finalize_session( s, sess.session_id, new_status, - reject_reason=(user_input or None), reject_price=price, + reject_reason=parsed["reason"], reject_price=parsed["offer_price"], )) + if parsed["opinion"]: + funcs.append(lambda s, op=parsed["opinion"]: self.session_crud.merge_session_custom( + s, sess.session_id, sess.supplier_id, {"opinion": op}, + )) err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs) if err_type != ErrorType.SUCCESS: diff --git a/frontend/src/apis/chat/chat.type.ts b/frontend/src/apis/chat/chat.type.ts index 1f76d03..913f6bd 100644 --- a/frontend/src/apis/chat/chat.type.ts +++ b/frontend/src/apis/chat/chat.type.ts @@ -47,6 +47,7 @@ export interface ChatInitResponse { item_vat_yn?: boolean item_delivery_fee_yn?: boolean custom?: Record + labels?: Record } export interface ChatMessagesResponse { @@ -104,5 +105,6 @@ export function mapInit(r: ChatInitResponse): ChatInitData { item_spec: r.item_spec ?? '', quotation_memo: r.quotation_memo ?? '', quotation_end_time: r.quotation_end_time ?? '', + labels: r.labels ?? {}, } } diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 6767c55..8bb4901 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -7,8 +7,6 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis' import { Indicator } from '@/features/chat/components/templates/Indicator' import { Summary } from '@/features/chat/components/templates/Summary' import { BidSummary } from '@/features/chat/components/templates/BidSummary' -import { RejectRSP } from '@/features/chat/components/templates/RejectRSP' -import { RejectCM } from '@/features/chat/components/templates/RejectCM' const AI_LABEL = '아이마켓코리아 (구매 MD)' @@ -16,12 +14,9 @@ export function ChatMessage() { const scrollRef = useRef(null) return (
- {/* lg:pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추는 값 — 그 카드가 없는 lg 미만에선 - 스텝바 바로 아래 여백으로만 남아 첫 메시지가 위로 안 붙으므로 뺀다. - 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
@@ -133,8 +128,6 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT isVAT={message.summary.item_isVAT} /> )} - {message.bot_chat_type === 'rejectRSP' && } - {message.bot_chat_type === 'rejectCM' && }
) diff --git a/frontend/src/features/chat/components/ExtraInfoBar.tsx b/frontend/src/features/chat/components/ExtraInfoBar.tsx index 8740787..f79b2f8 100644 --- a/frontend/src/features/chat/components/ExtraInfoBar.tsx +++ b/frontend/src/features/chat/components/ExtraInfoBar.tsx @@ -15,6 +15,9 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) { const fields: SessionField[] = user?.sessionFields ?? [] const save = useSaveExtraInfoMutation() const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필) + // 의견은 session_fields 와 무관한 공통 필드 — 타결·결렬 모두 항상 받는다. custom.opinion 에 저장. + const [opinion, setOpinion] = useState(null) + const opinionValue = opinion ?? String(existing?.opinion ?? '') // 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요). const [overrides, setOverrides] = useState>({}) @@ -23,17 +26,6 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) { const proceed = () => sendMessage(proceedText) - // 필드 미정의 회사 → 부가정보 없이 동의만. - if (fields.length === 0) { - return ( -
- -
- ) - } - const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value })) // 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지. @@ -45,6 +37,8 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) { if (f.type === 'boolean') custom[f.key] = !!v else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v } + if (opinionValue.trim()) custom.opinion = opinionValue.trim() + if (Object.keys(custom).length === 0) { proceed(); return } // 입력 없음 → 저장 생략하고 종료 save.mutate( { sessionId, request: { custom } }, { @@ -102,6 +96,20 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) { )} ))} + {/* 의견 — 타결·결렬 공통 필드(IMK #18). custom.opinion 저장. */} +
+ +