[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:
parent
1630b3d20c
commit
1d4e5816b2
@ -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}")
|
||||
|
||||
42
agent/negotiation/chat/service/indicator.py
Normal file
42
agent/negotiation/chat/service/indicator.py
Normal 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"
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
12
agent/tenants/_base/resources/scripts_cards.json
Normal file
12
agent/tenants/_base/resources/scripts_cards.json
Normal 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": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
||||
}
|
||||
@ -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": ["예", "아니오"],
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ export function Summary({ data }: { data: ChatSummary }) {
|
||||
수정 변경은 불가함을 안내 드립니다.
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="body-1-read-r">협상 개시 시간 : {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_start_date)}</div>
|
||||
<div className="body-1-read-r">협상 종료 시간 : {formatKoreanDateTime(data.nego_end_date)}</div>
|
||||
<div className="body-1-read-r">
|
||||
우선협상 대상자 : {data.supplier_name || '-'} ({data.md_phone_number || '-'}){' '}
|
||||
우선협상 대상자 : {data.supplier_name || '-'} ({data.supplier_manager_phone || '-'}){' '}
|
||||
{data.supplier_manager_email || '-'}
|
||||
</div>
|
||||
<div className="body-1-read-r">협상 상세 내역</div>
|
||||
@ -31,7 +31,7 @@ export function Summary({ data }: { data: ChatSummary }) {
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="body-1-read-r">
|
||||
공급 계약 기간: 협상 완료일로부터 1년 ({data.nego_end_date ? addOneYear(data.nego_end_date) : '-'})까지
|
||||
공급 계약 기간: 협상 완료일로부터 1년 ({addOneYear(data.nego_end_date)})까지
|
||||
</div>
|
||||
<div className="body-1-read-r">
|
||||
담당 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<string, string> | 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<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 }) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user