diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 25f2e35..8a7e1e8 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -144,8 +144,13 @@ class ChatEngine: ok = False # 공급사 유형 미보유 (PoC 단순화) elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하 ok = anchor > 0 and price <= anchor - elif cond == "check_iteration_limit": # = 카드 소진 - ok = cards_total > 0 and cards_used >= cards_total + elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진 + # round 는 매 가격입력마다 증가(기존가격제시=1). 그 이후 카운터제안이 MAX_ROUNDS 회를 + # 넘으면 종료한다. 카드선택(RL)이 실패(state ValueError)해도 used_action_ids 가 안 늘어 + # 카드 소진 조건만으로는 종료되지 않으므로, 라운드 상한을 독립적으로 둬 무한 가격입력을 막는다. + # (선행 chat_server 의 `iteration >= 3` 와 동일한 안전장치.) + counter_rounds = max(0, ctx.get("round", 0) - 1) + ok = counter_rounds >= MAX_ROUNDS or (cards_total > 0 and cards_used >= cards_total) elif cond == "default": ok = True if ok: @@ -167,17 +172,29 @@ class ChatEngine: return "wild_card_budget" # ---- render -------------------------------------------------------- - def _vars(self, session: ChatSession) -> Dict[str, Any]: + def vars_for(self, session: ChatSession) -> Dict[str, Any]: + """스크립트 치환 변수. 가격협상_확인 멘트(할인율)·카드 스크립트가 공유한다.""" ctx = session.context out = {} if "input_price" in ctx: out["input_price"] = int(ctx["input_price"]) if "target_price" in ctx: out["target"] = int(ctx["target_price"]) + if "anchor_price" in ctx: + out["anchor"] = int(ctx["anchor_price"]) if "offer_1pct" in ctx: out["offer_1pct"] = int(ctx["offer_1pct"]) + # 인하율 = (기존 공급가 - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0). + base = ctx.get("item_price") or 0 + if base > 1 and "input_price" in ctx: + out["discount_rate"] = f"{((base - ctx['input_price']) / base) * 100:.1f}" + else: + out["discount_rate"] = "0.0" return out + def _vars(self, session: ChatSession) -> Dict[str, Any]: + return self.vars_for(session) + def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView: if not step_key or step_key not in self.scripts: return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") diff --git a/agent/negotiation/chat/service/indicator.py b/agent/negotiation/chat/service/indicator.py new file mode 100644 index 0000000..66c12f7 --- /dev/null +++ b/agent/negotiation/chat/service/indicator.py @@ -0,0 +1,42 @@ +"""협상지표(협상 성공률) 산출 — 3구간 모델. + +선행 chat_server 의 ThreeSegIndicator 를 우리 구현으로 재작성(브랜드/특정사 비포함, CLEANROOM.md). +가격협상(카드선택) 턴에 화면 게이지로 표시할 값을 만든다. anchor < target 전제 +(anchor=우선협상 기준가, target=목표 매입가). 구매자 관점이라 제시가가 낮을수록 지표가 높다. + +- PZ1: 제시가 ≤ anchor → 99 (우선협상 가능 구간) +- PZ2: anchor < 제시가 < target → 99→80 선형 보간(19 스텝) +- PZ3: 제시가 ≥ target → 79 에서 지수감쇠, 최소 1 + +반환: (indicator_value 1~99, indicator_range "PZ1"|"PZ2"|"PZ3") 또는 입력이 부적합하면 None. +backend 컬럼이 NUMERIC(8,6)(절대값 <100)이라 값은 항상 1~99 범위로 보장한다. +""" + +import math +from typing import Optional, Tuple + + +def compute_indicator(anchor_price, current_price, target_price) -> Optional[Tuple[int, str]]: + try: + anchor = float(anchor_price) + current = float(current_price) + target = float(target_price) + except (TypeError, ValueError): + return None + if target <= 0 or current <= 0: + return None + + # PZ1 — 우선협상 구간(제시가가 앵커가 이하) + if current <= anchor: + return 99, "PZ1" + + # PZ2 — 협상 가능 구간(앵커가 초과 ~ 목표가 미만): 99→80 선형 보간 + if anchor < current < target: + value = int(math.floor(99 - ((current - anchor) / (target - anchor) * 19))) + return max(80, min(99, value)), "PZ2" + + # PZ3 — 목표가 이상: 79 에서 목표가 정규화 지수감쇠, 최소 1 + scale_exponent = int(math.floor(math.log10(target))) + k = math.pow(10, -scale_exponent) + raw_score = 79 * math.exp(-k * (current - target)) + return max(1, int(math.floor(raw_score))), "PZ3" diff --git a/agent/negotiation/chat/service/script_repository.py b/agent/negotiation/chat/service/script_repository.py index 9683395..8205ea4 100644 --- a/agent/negotiation/chat/service/script_repository.py +++ b/agent/negotiation/chat/service/script_repository.py @@ -64,6 +64,17 @@ class ScriptRepository: def wildcard_scripts(self) -> dict: return self._load_json("scripts_wildcard.json") + def card_scripts(self) -> dict: + """가격협상 카드 스크립트(action_id → 멘트). scripts_cards.json (테넌트→_base 폴백).""" + return self._load_json("scripts_cards.json") + + def card_script(self, action_id: int, variables: Optional[Dict[str, Any]] = None) -> Optional[str]: + """선택된 카드(action_id)의 스크립트를 변수 치환해서 반환. 없으면 None(호출부가 기본 문구 유지).""" + text = self.card_scripts().get(str(action_id)) + if not text: + return None + return self.format_script(text, variables) + def client_step_mapping(self) -> dict: return self._load_json("client_step_mapping.json") diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index 937d1e8..380b865 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -25,6 +25,8 @@ class Req_Chat(Req_WebPacketProtocol): # 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01). target_price: int = 10000 # KT 목표 매입가 anchor_price: int = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 + # 기존 공급가(품목 기준가). 가격협상_확인 멘트의 인하율(discount_rate) 산출용. 0 이면 인하율 미표시. + item_price: int = 0 class Res_Chat(Res_WebPacketProtocol): @@ -40,6 +42,11 @@ class Res_Chat(Res_WebPacketProtocol): desynced: bool = False # 가격협상 턴에서 선택된 협상 카드 + 학습 메타 card_id: Optional[str] = None + # ⑦ 표현 계약: 가격협상 턴은 카드 스크립트를 script 로, 협상지표 게이지를 indicator_value 로 내려보낸다. + # backend/front 가 이미 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료. + bot_chat_type: Optional[str] = None # 가격협상="indicator", 종료폼=summaryRSP/CM 등. 일반 텍스트는 None. + indicator_value: Optional[float] = None # 협상 성공률 1~99 (가격협상 턴) + indicator_range: Optional[str] = None # PZ1|PZ2|PZ3 (가격 구간) policy: Optional[str] = None q_value: Optional[float] = None updated_q: Optional[float] = None diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 3c10e9d..ca9429f 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -12,6 +12,7 @@ from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG from config.server_configs import agent_config from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView +from negotiation.chat.service.indicator import compute_indicator from negotiation.chat.service.chat_session_repository import ChatSessionRepository from negotiation.chat.service.script_repository import ScriptRepository from negotiation.policies.base import EpisodeState, PolicyContext, Transition @@ -59,20 +60,15 @@ class ChatService: "partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio, # 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)). "anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0, + # 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용. + "item_price": req.item_price, }, ) view = chat_engine.start(session) else: view = chat_engine.advance(session, req.user_input) - # 2) 학습 결합 (가격협상 카드선택 / 종료 보상) - if view.error is None and engine.action_space_size > 0: - if view.needs_card_selection: - await self._select_and_learn(engine, session, res) - elif view.outcome is not None: - await self._terminal_learn(engine, session, view.outcome, res) - - # 3) 응답 + # 2) 응답 기본 채움 (학습 블록이 가격협상 턴에서 script/indicator 를 덮어쓸 수 있어 먼저 채운다) res.session_id = session.session_id res.step = view.step res.client_step = view.client_step @@ -82,6 +78,14 @@ class ChatService: res.chat_end = view.chat_end res.outcome = view.outcome res.desynced = desynced + + # 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상) + if view.error is None and engine.action_space_size > 0: + if view.needs_card_selection: + await self._select_and_learn(engine, chat_engine, repo, session, res) + elif view.outcome is not None: + await self._terminal_learn(engine, session, view.outcome, res) + if view.error: res.result.SetResult(ErrorType.NEGO_INVALID_STEP) res.msg = view.error @@ -117,7 +121,8 @@ class ChatService: target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome, ) - async def _select_and_learn(self, engine: TenantEngine, session: ChatSession, res: Res_Chat): + async def _select_and_learn(self, engine: TenantEngine, chat_engine: ChatEngine, + scripts: ScriptRepository, session: ChatSession, res: Res_Chat): snap = self._snapshot(session, NegotiationOutcome.ONGOING) try: idx = state_index(snap, engine.config.state) @@ -144,6 +149,19 @@ class ChatService: res.visit_count = int(policy.qtable.visits[idx, decision.action_id]) res.reward_total = reward.total + # 가격협상 턴 연출(선행 chat_server 의 dynamic step type=indicator 재현): + # ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉. + # backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다. + card_script = scripts.card_script(decision.action_id, chat_engine.vars_for(session)) + if card_script: + res.script = card_script + c = session.context + ind = compute_indicator(c.get("anchor_price", 0), c.get("input_price", 0), c.get("target_price", 0)) + if ind is not None: + res.indicator_value = float(ind[0]) + res.indicator_range = ind[1] + res.bot_chat_type = "indicator" + async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat): oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE snap = self._snapshot(session, oc) diff --git a/agent/tenants/_base/resources/scripts_cards.json b/agent/tenants/_base/resources/scripts_cards.json new file mode 100644 index 0000000..a49edf4 --- /dev/null +++ b/agent/tenants/_base/resources/scripts_cards.json @@ -0,0 +1,12 @@ +{ + "_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).", + "0": "제안해 주신 {input_price}원, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?", + "1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가({target}원)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.", + "2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.", + "3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.", + "4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?", + "5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.", + "6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.", + "7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.", + "8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?" +} diff --git a/agent/tenants/_base/resources/scripts_renegotiation.json b/agent/tenants/_base/resources/scripts_renegotiation.json index a4a0322..8066802 100644 --- a/agent/tenants/_base/resources/scripts_renegotiation.json +++ b/agent/tenants/_base/resources/scripts_renegotiation.json @@ -73,7 +73,7 @@ "chat_end": false }, "가격협상_확인": { - "script": "{input_price}원으로 제안하시겠습니까?", + "script": "제시하신 가격은 {input_price}원으로, 기존 공급가 대비 약 {discount_rate}% 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.", "editor_script_id": "가격협상_확인", "next_input_mode": "yes_no", "input_options": ["예", "아니오"], @@ -91,7 +91,7 @@ "chat_end": false }, "가격협상_확인_버짓": { - "script": "{target}원으로 제안하시겠습니까?", + "script": "제시하신 금액은 {input_price}원입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.", "editor_script_id": "가격협상_확인_버짓", "next_input_mode": "yes_no", "input_options": ["예", "아니오"], diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/negotiation/chat_protocol.py index a483c1e..5fd30cb 100644 --- a/backend/router/v1/negotiation/chat_protocol.py +++ b/backend/router/v1/negotiation/chat_protocol.py @@ -34,6 +34,7 @@ class ChatSummary(WebPacketProtocol): supplier_name: str = "" supplier_manager_name: str = "" supplier_manager_email: str = "" + supplier_manager_phone: str = "" delivery_type: Optional[str] = None diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py index 055da89..6c1435d 100644 --- a/backend/services/agent_client.py +++ b/backend/services/agent_client.py @@ -44,6 +44,7 @@ class AgentChatContext: rq_type: str = "재협상" # 재협상 | 재견적 target_price: int = 0 # 갑 목표 매입가(원) anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산. + item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용. # 핸드오프 #4: agent 의 RL 상태(state) 계산 입력. # partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어 # 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④). @@ -75,6 +76,7 @@ class HttpAgentClient(IAgentClient): "user_input": user_input, "target_price": ctx.target_price, "anchor_price": ctx.anchor_price, + "item_price": ctx.item_price, # 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값. "revenue_amount": ctx.revenue_amount, "distribution_code": ctx.distribution_code, diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 6a2007d..4a947c9 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -18,7 +18,7 @@ from fastapi import Depends from sqlalchemy import func, select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import chats, items, quotation_settings, quotations, sessions, supplier_users, suppliers +from common.database.model.models import chats, items, quotation_settings, quotations, sessions, suppliers from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus from common.logger import LOG from common.models.gmodel import UserInfo @@ -369,10 +369,12 @@ class ChatService: partner_count = await self._count_partners(sess) # revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어 # AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요). + # 기존 공급가(품목 기준가) — agent 가격협상_확인 인하율(discount_rate) 산출 입력. + item_price = int(item.price) if item is not None and item.price else 0 return AgentChatContext( tenant_id=tenant_id, rq_type=rq_type, target_price=target_price, anchor_price=anchor, partner_count=partner_count, - client_step=client_step, + item_price=item_price, client_step=client_step, ) async def _resolve_anchor_price(self, sess, target_price: int) -> int: @@ -499,24 +501,20 @@ class ChatService: async def _build_summary(self, sess, quote, item, final_price: Optional[int], last_price: Optional[int]) -> dict: """협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가). 종료 스텝에서 1회만 호출. item/last_price 는 호출부(send)에서 1회 조회해 넘겨준다(중복 조회 제거).""" - def _supplier_name(s): - stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1) - return DB_SESSION_MNG.execute(s, stmt) - - err_type, rows = await DB_SESSION_MNG.execute_lambda(suppliers.DBType(), DBWRType.DB_READ.value, _supplier_name) - supplier_name = rows[0] if err_type == ErrorType.SUCCESS and rows else "" - - # 공급사 담당자(로그인 계정) 이름/이메일 - def _supplier_user(s): + # 우선협상 대상자(공급사) + 담당자 정보. 담당자명/이메일/연락처는 partner.suppliers 에 영속된 값을 쓴다 + # (supplier_users 는 로그인 계정이라 이메일이 비어 있을 수 있어, 요약 카드엔 공급사 담당자 정보를 사용). + def _supplier(s): stmt = ( - select(supplier_users.name, supplier_users.email) - .where(supplier_users.supplier_id == sess.supplier_id, supplier_users.deleted == False) # noqa: E712 - .limit(1) + select(suppliers.name, suppliers.manager_name, suppliers.manager_email, suppliers.manager_contact_number) + .where(suppliers.supplier_id == sess.supplier_id).limit(1) ) return DB_SESSION_MNG.execute(s, stmt) - err_type, su_rows = await DB_SESSION_MNG.execute_lambda(supplier_users.DBType(), DBWRType.DB_READ.value, _supplier_user) - sup_mgr_name, sup_mgr_email = (su_rows[0][0], su_rows[0][1]) if err_type == ErrorType.SUCCESS and su_rows else ("", "") + err_type, rows = await DB_SESSION_MNG.execute_lambda(suppliers.DBType(), DBWRType.DB_READ.value, _supplier) + if err_type == ErrorType.SUCCESS and rows: + supplier_name, sup_mgr_name, sup_mgr_email, sup_mgr_phone = rows[0] + else: + supplier_name = sup_mgr_name = sup_mgr_email = sup_mgr_phone = "" # 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백). last_price 는 호출부에서 전달. resolved_price = int(last_price if last_price else (final_price or 0)) @@ -553,6 +551,7 @@ class ChatService: supplier_name=supplier_name or "", supplier_manager_name=sup_mgr_name or "", supplier_manager_email=sup_mgr_email or "", + supplier_manager_phone=sup_mgr_phone or "", delivery_type=delivery_label, ).model_dump() diff --git a/frontend/src/features/chat/components/templates/Summary.tsx b/frontend/src/features/chat/components/templates/Summary.tsx index 6e86344..3901ce6 100644 --- a/frontend/src/features/chat/components/templates/Summary.tsx +++ b/frontend/src/features/chat/components/templates/Summary.tsx @@ -11,10 +11,10 @@ export function Summary({ data }: { data: ChatSummary }) { 수정 변경은 불가함을 안내 드립니다.
-
협상 개시 시간 : {data.nego_start_date || '-'}
-
협상 종료 시간 : {data.nego_end_date || '-'}
+
협상 개시 시간 : {formatKoreanDateTime(data.nego_start_date)}
+
협상 종료 시간 : {formatKoreanDateTime(data.nego_end_date)}
- 우선협상 대상자 : {data.supplier_name || '-'} ({data.md_phone_number || '-'}){' '} + 우선협상 대상자 : {data.supplier_name || '-'} ({data.supplier_manager_phone || '-'}){' '} {data.supplier_manager_email || '-'}
협상 상세 내역
@@ -31,7 +31,7 @@ export function Summary({ data }: { data: ChatSummary }) {
- 공급 계약 기간: 협상 완료일로부터 1년 ({data.nego_end_date ? addOneYear(data.nego_end_date) : '-'})까지 + 공급 계약 기간: 협상 완료일로부터 1년 ({addOneYear(data.nego_end_date)})까지
담당 MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'} @@ -42,13 +42,38 @@ export function Summary({ data }: { data: ChatSummary }) { ) } -function addOneYear(dateStr: string): string { - const match = dateStr.match(/(\d{4})년 (\d{2})월 (\d{2})일 (\d{2})시 (\d{2})분/) - if (!match) return '-' - const [, year, month, day, hour, minute] = match - const date = new Date(parseInt(year) + 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute)) - const pad = (n: number) => String(n).padStart(2, '0') - return `${date.getFullYear()}년 ${pad(date.getMonth() + 1)}월 ${pad(date.getDate())}일` +// ISO 시각 → 한국시(KST) 기준 부분값. 백엔드는 UTC(ISO)로 내려주므로 표시 시 KST 로 변환한다. +function kstParts(iso: string): Record | null { + if (!iso) return null + const d = new Date(iso) + if (isNaN(d.getTime())) return null + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(d) + const out: Record = {} + for (const p of parts) out[p.type] = p.value + return out +} + +// "YYYY년 MM월 dd일 HH시 MM분 SS초" (KST) +function formatKoreanDateTime(iso: string): string { + const p = kstParts(iso) + if (!p) return '-' + return `${p.year}년 ${p.month}월 ${p.day}일 ${p.hour}시 ${p.minute}분 ${p.second}초` +} + +// 협상 종료 시각 + 1년 → "YYYY년 MM월 dd일" (공급 계약 만료일) +function addOneYear(iso: string): string { + const p = kstParts(iso) + if (!p) return '-' + return `${parseInt(p.year) + 1}년 ${p.month}월 ${p.day}일` } function DetailText({ title, value }: { title: string; value: string }) { diff --git a/frontend/src/features/chat/types.ts b/frontend/src/features/chat/types.ts index b41f9f0..9b2936a 100644 --- a/frontend/src/features/chat/types.ts +++ b/frontend/src/features/chat/types.ts @@ -27,6 +27,7 @@ export type ChatSummary = { item_delivery_type: string supplier_manager_name: string supplier_manager_email: string + supplier_manager_phone: string delivery_type: string | null }