[fix] 재협상 가격협상 흐름 정비 — 무한루프·카드 연출·요약카드

agent 가격협상 무한 루프 수정 + 선행 chat_server 의 가격협상 연출(카드
스크립트 + 협상지표 게이지) 복원, 버짓 확인 입력가 표시 버그, 요약카드
공급사 담당자/날짜 표시 정비.

- agent: 가격협상 루프를 라운드 상한(MAX_ROUNDS)으로 종료(카드선택 실패해도
  종료 보장 — 기존엔 카드소진에만 의존해 무한 입력). chat_server iteration>=3 동작 복원.
- agent: 가격협상 턴에 선택 카드 스크립트(scripts_cards.json)를 script 로 렌더 +
  협상지표(indicator.py, 1~99/PZ) 산출해 indicator_value/bot_chat_type 전달.
  Res_Chat 에 indicator/bot_chat_type, Req_Chat 에 item_price 추가.
- agent: 가격협상_확인 멘트에 할인율(discount_rate) 표시,
  가격협상_확인_버짓이 target 이 아니라 입력가(input_price)를 표시하도록 수정.
- backend: item_price(기존 공급가) 를 agent 로 전달. 요약카드 공급사 담당자
  정보를 supplier_users(빈 이메일) 대신 partner.suppliers.manager_* 에서 조회,
  ChatSummary.supplier_manager_phone 추가.
- frontend: 요약카드 협상 개시/종료 시각을 "YYYY년 MM월 dd일 HH시 MM분 SS초"(KST)
  로, 공급 계약 만료일(+1년) ISO 파싱, 우선협상 대상자에 공급사 연락처/이메일 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-24 11:52:34 +09:00
parent 1630b3d20c
commit 1d4e5816b2
12 changed files with 176 additions and 41 deletions

View File

@ -144,8 +144,13 @@ class ChatEngine:
ok = False # 공급사 유형 미보유 (PoC 단순화) ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하 elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
ok = anchor > 0 and price <= anchor ok = anchor > 0 and price <= anchor
elif cond == "check_iteration_limit": # = 카드 소진 elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진
ok = cards_total > 0 and cards_used >= cards_total # 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": elif cond == "default":
ok = True ok = True
if ok: if ok:
@ -167,17 +172,29 @@ class ChatEngine:
return "wild_card_budget" return "wild_card_budget"
# ---- render -------------------------------------------------------- # ---- render --------------------------------------------------------
def _vars(self, session: ChatSession) -> Dict[str, Any]: def vars_for(self, session: ChatSession) -> Dict[str, Any]:
"""스크립트 치환 변수. 가격협상_확인 멘트(할인율)·카드 스크립트가 공유한다."""
ctx = session.context ctx = session.context
out = {} out = {}
if "input_price" in ctx: if "input_price" in ctx:
out["input_price"] = int(ctx["input_price"]) out["input_price"] = int(ctx["input_price"])
if "target_price" in ctx: if "target_price" in ctx:
out["target"] = int(ctx["target_price"]) out["target"] = int(ctx["target_price"])
if "anchor_price" in ctx:
out["anchor"] = int(ctx["anchor_price"])
if "offer_1pct" in ctx: if "offer_1pct" in ctx:
out["offer_1pct"] = int(ctx["offer_1pct"]) 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 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: def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
if not step_key or step_key not in self.scripts: if not step_key or step_key not in self.scripts:
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")

View File

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

View File

@ -64,6 +64,17 @@ class ScriptRepository:
def wildcard_scripts(self) -> dict: def wildcard_scripts(self) -> dict:
return self._load_json("scripts_wildcard.json") 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: def client_step_mapping(self) -> dict:
return self._load_json("client_step_mapping.json") return self._load_json("client_step_mapping.json")

View File

@ -25,6 +25,8 @@ class Req_Chat(Req_WebPacketProtocol):
# 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01). # 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01).
target_price: int = 10000 # KT 목표 매입가 target_price: int = 10000 # KT 목표 매입가
anchor_price: int = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 anchor_price: int = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
# 기존 공급가(품목 기준가). 가격협상_확인 멘트의 인하율(discount_rate) 산출용. 0 이면 인하율 미표시.
item_price: int = 0
class Res_Chat(Res_WebPacketProtocol): class Res_Chat(Res_WebPacketProtocol):
@ -40,6 +42,11 @@ class Res_Chat(Res_WebPacketProtocol):
desynced: bool = False desynced: bool = False
# 가격협상 턴에서 선택된 협상 카드 + 학습 메타 # 가격협상 턴에서 선택된 협상 카드 + 학습 메타
card_id: Optional[str] = None 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 policy: Optional[str] = None
q_value: Optional[float] = None q_value: Optional[float] = None
updated_q: Optional[float] = None updated_q: Optional[float] = None

View File

@ -12,6 +12,7 @@ from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG from common.logger import LOG
from config.server_configs import agent_config from config.server_configs import agent_config
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView 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.chat_session_repository import ChatSessionRepository
from negotiation.chat.service.script_repository import ScriptRepository from negotiation.chat.service.script_repository import ScriptRepository
from negotiation.policies.base import EpisodeState, PolicyContext, Transition from negotiation.policies.base import EpisodeState, PolicyContext, Transition
@ -59,20 +60,15 @@ class ChatService:
"partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio, "partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio,
# 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)). # 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)).
"anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0, "anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
"item_price": req.item_price,
}, },
) )
view = chat_engine.start(session) view = chat_engine.start(session)
else: else:
view = chat_engine.advance(session, req.user_input) view = chat_engine.advance(session, req.user_input)
# 2) 학습 결합 (가격협상 카드선택 / 종료 보상) # 2) 응답 기본 채움 (학습 블록이 가격협상 턴에서 script/indicator 를 덮어쓸 수 있어 먼저 채운다)
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) 응답
res.session_id = session.session_id res.session_id = session.session_id
res.step = view.step res.step = view.step
res.client_step = view.client_step res.client_step = view.client_step
@ -82,6 +78,14 @@ class ChatService:
res.chat_end = view.chat_end res.chat_end = view.chat_end
res.outcome = view.outcome res.outcome = view.outcome
res.desynced = desynced 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: if view.error:
res.result.SetResult(ErrorType.NEGO_INVALID_STEP) res.result.SetResult(ErrorType.NEGO_INVALID_STEP)
res.msg = view.error res.msg = view.error
@ -117,7 +121,8 @@ class ChatService:
target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome, 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) snap = self._snapshot(session, NegotiationOutcome.ONGOING)
try: try:
idx = state_index(snap, engine.config.state) 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.visit_count = int(policy.qtable.visits[idx, decision.action_id])
res.reward_total = reward.total 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): async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
snap = self._snapshot(session, oc) snap = self._snapshot(session, oc)

View File

@ -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": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
}

View File

@ -73,7 +73,7 @@
"chat_end": false "chat_end": false
}, },
"가격협상_확인": { "가격협상_확인": {
"script": "{input_price}원으로 제안하시겠습니까?", "script": "제시하신 가격은 {input_price}원으로, 기존 공급가 대비 약 {discount_rate}% 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
"editor_script_id": "가격협상_확인", "editor_script_id": "가격협상_확인",
"next_input_mode": "yes_no", "next_input_mode": "yes_no",
"input_options": ["예", "아니오"], "input_options": ["예", "아니오"],
@ -91,7 +91,7 @@
"chat_end": false "chat_end": false
}, },
"가격협상_확인_버짓": { "가격협상_확인_버짓": {
"script": "{target}원으로 제안하시겠습니까?", "script": "제시하신 금액은 {input_price}원입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
"editor_script_id": "가격협상_확인_버짓", "editor_script_id": "가격협상_확인_버짓",
"next_input_mode": "yes_no", "next_input_mode": "yes_no",
"input_options": ["예", "아니오"], "input_options": ["예", "아니오"],

View File

@ -34,6 +34,7 @@ class ChatSummary(WebPacketProtocol):
supplier_name: str = "" supplier_name: str = ""
supplier_manager_name: str = "" supplier_manager_name: str = ""
supplier_manager_email: str = "" supplier_manager_email: str = ""
supplier_manager_phone: str = ""
delivery_type: Optional[str] = None delivery_type: Optional[str] = None

View File

@ -44,6 +44,7 @@ class AgentChatContext:
rq_type: str = "재협상" # 재협상 | 재견적 rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원) target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산. anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산.
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력. # 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어 # partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
# 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④). # 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④).
@ -75,6 +76,7 @@ class HttpAgentClient(IAgentClient):
"user_input": user_input, "user_input": user_input,
"target_price": ctx.target_price, "target_price": ctx.target_price,
"anchor_price": ctx.anchor_price, "anchor_price": ctx.anchor_price,
"item_price": ctx.item_price,
# 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값. # 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값.
"revenue_amount": ctx.revenue_amount, "revenue_amount": ctx.revenue_amount,
"distribution_code": ctx.distribution_code, "distribution_code": ctx.distribution_code,

View File

@ -18,7 +18,7 @@ from fastapi import Depends
from sqlalchemy import func, select from sqlalchemy import func, select
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 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.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
@ -369,10 +369,12 @@ class ChatService:
partner_count = await self._count_partners(sess) partner_count = await self._count_partners(sess)
# revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어 # revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어
# AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요). # AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요).
# 기존 공급가(품목 기준가) — agent 가격협상_확인 인하율(discount_rate) 산출 입력.
item_price = int(item.price) if item is not None and item.price else 0
return AgentChatContext( return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type, tenant_id=tenant_id, rq_type=rq_type,
target_price=target_price, anchor_price=anchor, partner_count=partner_count, 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: 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: async def _build_summary(self, sess, quote, item, final_price: Optional[int], last_price: Optional[int]) -> dict:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가). """협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
종료 스텝에서 1회만 호출. item/last_price 는 호출부(send)에서 1회 조회해 넘겨준다(중복 조회 제거).""" 종료 스텝에서 1회만 호출. item/last_price 는 호출부(send)에서 1회 조회해 넘겨준다(중복 조회 제거)."""
def _supplier_name(s): # 우선협상 대상자(공급사) + 담당자 정보. 담당자명/이메일/연락처는 partner.suppliers 에 영속된 값을 쓴다
stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1) # (supplier_users 는 로그인 계정이라 이메일이 비어 있을 수 있어, 요약 카드엔 공급사 담당자 정보를 사용).
return DB_SESSION_MNG.execute(s, stmt) def _supplier(s):
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):
stmt = ( stmt = (
select(supplier_users.name, supplier_users.email) select(suppliers.name, suppliers.manager_name, suppliers.manager_email, suppliers.manager_contact_number)
.where(supplier_users.supplier_id == sess.supplier_id, supplier_users.deleted == False) # noqa: E712 .where(suppliers.supplier_id == sess.supplier_id).limit(1)
.limit(1)
) )
return DB_SESSION_MNG.execute(s, stmt) 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) err_type, rows = await DB_SESSION_MNG.execute_lambda(suppliers.DBType(), DBWRType.DB_READ.value, _supplier)
sup_mgr_name, sup_mgr_email = (su_rows[0][0], su_rows[0][1]) if err_type == ErrorType.SUCCESS and su_rows else ("", "") 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 는 호출부에서 전달. # 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백). last_price 는 호출부에서 전달.
resolved_price = int(last_price if last_price else (final_price or 0)) 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_name=supplier_name or "",
supplier_manager_name=sup_mgr_name or "", supplier_manager_name=sup_mgr_name or "",
supplier_manager_email=sup_mgr_email or "", supplier_manager_email=sup_mgr_email or "",
supplier_manager_phone=sup_mgr_phone or "",
delivery_type=delivery_label, delivery_type=delivery_label,
).model_dump() ).model_dump()

View File

@ -11,10 +11,10 @@ export function Summary({ data }: { data: ChatSummary }) {
수정 변경은 불가함을 안내 드립니다. 수정 변경은 불가함을 안내 드립니다.
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="body-1-read-r">협상 개시 시간 : {data.nego_start_date || '-'}</div> <div className="body-1-read-r">협상 개시 시간 : {formatKoreanDateTime(data.nego_start_date)}</div>
<div className="body-1-read-r">협상 종료 시간 : {data.nego_end_date || '-'}</div> <div className="body-1-read-r">협상 종료 시간 : {formatKoreanDateTime(data.nego_end_date)}</div>
<div className="body-1-read-r"> <div className="body-1-read-r">
우선협상 대상자 : {data.supplier_name || '-'} ({data.md_phone_number || '-'}){' '} 우선협상 대상자 : {data.supplier_name || '-'} ({data.supplier_manager_phone || '-'}){' '}
{data.supplier_manager_email || '-'} {data.supplier_manager_email || '-'}
</div> </div>
<div className="body-1-read-r">협상 상세 내역</div> <div className="body-1-read-r">협상 상세 내역</div>
@ -31,7 +31,7 @@ export function Summary({ data }: { data: ChatSummary }) {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="body-1-read-r"> <div className="body-1-read-r">
공급 계약 기간: 협상 완료일로부터 1년 ({data.nego_end_date ? addOneYear(data.nego_end_date) : '-'})까지 공급 계약 기간: 협상 완료일로부터 1년 ({addOneYear(data.nego_end_date)})까지
</div> </div>
<div className="body-1-read-r"> <div className="body-1-read-r">
담당 MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'} 담당 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 { // ISO 시각 → 한국시(KST) 기준 부분값. 백엔드는 UTC(ISO)로 내려주므로 표시 시 KST 로 변환한다.
const match = dateStr.match(/(\d{4})년 (\d{2})월 (\d{2})일 (\d{2})시 (\d{2})분/) function kstParts(iso: string): Record<string, string> | null {
if (!match) return '-' if (!iso) return null
const [, year, month, day, hour, minute] = match const d = new Date(iso)
const date = new Date(parseInt(year) + 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute)) if (isNaN(d.getTime())) return null
const pad = (n: number) => String(n).padStart(2, '0') const parts = new Intl.DateTimeFormat('en-CA', {
return `${date.getFullYear()}년 ${pad(date.getMonth() + 1)}월 ${pad(date.getDate())}일` 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<string, string> = {}
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 }) { function DetailText({ title, value }: { title: string; value: string }) {

View File

@ -27,6 +27,7 @@ export type ChatSummary = {
item_delivery_type: string item_delivery_type: string
supplier_manager_name: string supplier_manager_name: string
supplier_manager_email: string supplier_manager_email: string
supplier_manager_phone: string
delivery_type: string | null delivery_type: string | null
} }