[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 단순화)
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}")

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:
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")

View File

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

View File

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

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
},
"가격협상_확인": {
"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": ["예", "아니오"],

View File

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

View File

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

View File

@ -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()

View File

@ -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 }) {

View File

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