구매 담당자 모드 공급사 모드와 같은 문제가 있었다. simulateBuyerRun 의 에이전트 첫 수가 marketLow 만 읊어서, 목표가를 얼마로 잡든 같은 문장이 나왔다 — 위임한 기준이 반영되는지 확인할 길이 없었다. 이제 목표가를 받았다고 명시하고, 시장 최저가 대비 위치에 따라 태도를 달리한다(근거부터 깔겠다 / 무리 없이 접근하겠다). 여는 말(협력사 정가 제시)은 아직 목표가를 모르는 시점이라 상수로 둔다. 무대 위 드래그 선택색 루트의 selection:text-primary 는 라이트 본문 기준이다. 그 값(#0101F3)을 무대(#070E24) 위에 얹으면 명암비 2.0:1 — app.css 의 --color-primary-on-stage 주석이 경고하는 바로 그 상황이다. 무대 섹션에서 텍스트를 드래그하면 글자가 사라졌다. .stage-bg / [data-stage-hero] 안에서는 무대용 액센트로 갈아끼운다. 플레이 화면 유리 패널 컨트롤과 대화 로그가 섹션 배경 위에 그냥 얹혀 있어 무대와 같은 색이라 경계가 없었고, 조작 가능한 영역인지 읽히지 않았다. 재질은 무대 위 2차 CTA 와 같게 맞췄다 — 화면 안에 재질이 두 종류면 따로 논다. 테두리는 white/12 로 얕게: 면이지 조작 요소가 아니라 버튼만큼 세우면 카드가 버튼처럼 보인다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
7.9 KiB
TypeScript
199 lines
7.9 KiB
TypeScript
/**
|
|
* 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용).
|
|
*
|
|
* 실제 negotium 엔진(agent 서비스)의 판정 규칙을 그대로 옮겼다. 데모가 제품과 다른
|
|
* 규칙으로 돌면 상담 자리에서 거짓말이 되기 때문에, 단순화는 하되 규칙은 바꾸지 않는다.
|
|
*
|
|
* - 앵커링가(anchor) 이하 제시는 무조건 수락한다.
|
|
* - 앵커 위면 협상 카드를 한 장 쓰고 역제안한다. 카드는 3장.
|
|
* - 카드를 다 쓰고도 앵커 위면 개찰(낙찰자 미정 마감)이다. 결렬이 아니다.
|
|
*
|
|
* React 를 모른다. 나중에 서버의 실엔진(/api/demo/negotiate)으로 갈아끼울 때
|
|
* 이 파일의 함수 시그니처만 유지하면 컴포넌트는 손대지 않아도 된다.
|
|
*/
|
|
|
|
export type Role = "buyer" | "seller"
|
|
|
|
export type DemoItem = {
|
|
id: string
|
|
name: string
|
|
spec: string
|
|
/** 협력사 최초 제시가 */
|
|
listPrice: number
|
|
/** LPS 인터넷 최저가 — 에이전트가 근거로 인용한다 */
|
|
marketLow: number
|
|
/** 앵커링가. 이 이하면 무조건 낙찰 */
|
|
anchor: number
|
|
/** 견적 생성 때 정한 목표가 */
|
|
target: number
|
|
}
|
|
|
|
export const MAX_CARDS = 3
|
|
|
|
export const DEMO_ITEMS: DemoItem[] = [
|
|
{
|
|
id: "glove",
|
|
name: "니트릴 코팅 안전장갑",
|
|
spec: "1,000켤레 · 월 정기",
|
|
listPrice: 1_200_000,
|
|
marketLow: 1_110_000,
|
|
anchor: 1_020_000,
|
|
target: 1_080_000,
|
|
},
|
|
{
|
|
id: "paper",
|
|
name: "A4 복사용지 80g",
|
|
spec: "500박스 · 분기 발주",
|
|
listPrice: 8_750_000,
|
|
marketLow: 8_200_000,
|
|
anchor: 7_900_000,
|
|
target: 8_100_000,
|
|
},
|
|
{
|
|
id: "oil",
|
|
name: "산업용 윤활유 20L",
|
|
spec: "300통 · 반기 발주",
|
|
listPrice: 5_400_000,
|
|
marketLow: 5_050_000,
|
|
anchor: 4_850_000,
|
|
target: 4_980_000,
|
|
},
|
|
]
|
|
|
|
export type Turn = {
|
|
id: number
|
|
side: "agent" | "counterpart"
|
|
text: string
|
|
/** 이 턴에서 테이블에 올라온 가격 */
|
|
price?: number
|
|
}
|
|
|
|
export type Outcome = "running" | "award" | "open"
|
|
|
|
/** 협상에서 실제로 오가는 조건들. 가격만 깎는 게 아니라는 걸 보여주는 장치. */
|
|
const LEVERS = ["연간 물량 보증", "납기 2주 연장", "대금 지급일 15일 단축"] as const
|
|
|
|
/** 10원 단위 반올림 — 실제 엔진의 앵커링 정돈 규칙과 같다. */
|
|
export const round10 = (n: number) => Math.round(n / 10) * 10
|
|
|
|
export const won = (n: number) => n.toLocaleString("ko-KR")
|
|
|
|
/**
|
|
* 에이전트의 양보 곡선 — 이 데모의 성격을 결정하는 단 하나의 숫자 뭉치다.
|
|
*
|
|
* 카드를 쓸수록 앵커에서 상대 제시가 쪽으로 얼마나 올라올지를 정한다.
|
|
* 0 이면 끝까지 앵커를 고수하고, 1 이면 즉시 상대 제시가를 받는다.
|
|
* 앞 카드를 낮게 잡을수록 강하게 버티는 에이전트가 된다.
|
|
*/
|
|
const CONCESSION = [0, 0.34, 0.62]
|
|
|
|
/** 협력사(사용자) 제시가에 대한 에이전트의 판정. seller 모드의 핵심. */
|
|
export function respondToOffer(
|
|
item: DemoItem,
|
|
offer: number,
|
|
cardsUsed: number,
|
|
): { outcome: Outcome; price: number; text: string } {
|
|
if (offer <= item.anchor) {
|
|
return {
|
|
outcome: "award",
|
|
price: offer,
|
|
text: `${won(offer)}원으로 확정하겠습니다. 목표가 ${won(item.target)}원 대비 ${won(
|
|
item.target - offer,
|
|
)}원 낮습니다. 주고받은 제안은 전부 기록에 남습니다.`,
|
|
}
|
|
}
|
|
|
|
if (cardsUsed >= MAX_CARDS) {
|
|
return {
|
|
outcome: "open",
|
|
price: offer,
|
|
text: `${won(offer)}원은 이번 견적의 낙찰 기준을 넘습니다. 협상 카드를 다 썼으니 이 건은 개찰로 마감하고 담당자에게 넘기겠습니다.`,
|
|
}
|
|
}
|
|
|
|
const ask = round10(item.anchor + (offer - item.anchor) * CONCESSION[cardsUsed])
|
|
const lever = LEVERS[cardsUsed]
|
|
|
|
/* 근거는 반드시 상대가 제시한 값을 물고 있어야 한다.
|
|
예전엔 첫 카드에서 marketLow 만 읊어서, 얼마를 제시하든 같은 문장이 나왔다.
|
|
CONCESSION[0] = 0 이라 부르는 값(ask)까지 앵커로 고정이라, 슬라이더를 움직여도
|
|
화면이 전혀 반응하지 않는 것처럼 보였다. 값은 고수하되(그게 이 에이전트의 성격이다)
|
|
무엇에 대한 응수인지는 매번 달라져야 한다. */
|
|
const gap = offer - ask
|
|
const reason =
|
|
cardsUsed > 0
|
|
? `${won(offer)}원과 저희 기준 사이가 아직 ${won(gap)}원 남았습니다.`
|
|
: offer >= item.listPrice
|
|
? `정가 그대로는 검토가 어렵습니다. 같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다.`
|
|
: offer > item.marketLow
|
|
? `${won(offer)}원은 같은 사양 인터넷 최저가 ${won(item.marketLow)}원보다 ${won(
|
|
offer - item.marketLow,
|
|
)}원 높습니다.`
|
|
: `${won(offer)}원이면 시장 최저가 선까지 내려오셨습니다. 다만 이번 견적 기준까지 ${won(gap)}원 남았습니다.`
|
|
|
|
return {
|
|
outcome: "running",
|
|
price: ask,
|
|
text: `${reason} ${won(ask)}원까지 맞춰주시면 ${lever}으로 보전해 드리겠습니다.`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* buyer 모드 — 사용자가 목표가만 정하면 에이전트가 협력사와 알아서 붙는다.
|
|
* 전체 턴을 미리 만들어 두고 화면에서 하나씩 재생한다.
|
|
*/
|
|
export function simulateBuyerRun(item: DemoItem, target: number): { turns: Turn[]; outcome: Outcome; finalPrice: number } {
|
|
const turns: Turn[] = []
|
|
let id = 0
|
|
const push = (side: Turn["side"], text: string, price?: number) => turns.push({ id: id++, side, text, price })
|
|
|
|
push("counterpart", `원자재가 올라서 이번 분기는 ${won(item.listPrice)}원이 최선입니다.`, item.listPrice)
|
|
|
|
// 목표가가 앵커보다 낮으면 협력사가 받아들일 수 없는 구간이다.
|
|
// 에이전트는 기준 밖으로 나가지 않으므로 개찰로 끝난다 — 제품의 안전장치를 보여주는 케이스다.
|
|
const reachable = target >= item.anchor
|
|
|
|
/* 에이전트의 첫 수는 사용자가 준 목표가를 물고 있어야 한다. 예전엔 marketLow 만 읊어서
|
|
목표가를 얼마로 잡든 같은 문장이 나왔다 — 위임한 기준이 반영되는지 확인할 수가 없었다.
|
|
여는 말(협력사 정가 제시)은 아직 목표가를 모르는 시점이라 상수로 둔다. */
|
|
const stance =
|
|
target < item.marketLow
|
|
? `시장 최저가보다 낮은 목표라 근거부터 깔겠습니다.`
|
|
: `시장 최저가 선이라 무리 없이 접근하겠습니다.`
|
|
push(
|
|
"agent",
|
|
`목표가 ${won(target)}원 받았습니다. ${stance} 같은 사양 인터넷 최저가가 ${won(
|
|
item.marketLow,
|
|
)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`,
|
|
item.marketLow,
|
|
)
|
|
|
|
const mid = round10((item.listPrice + Math.max(target, item.anchor)) / 2)
|
|
push("counterpart", `공정상 한 번에 내리긴 어렵고, ${won(mid)}원까지는 조정하겠습니다.`, mid)
|
|
|
|
if (!reachable) {
|
|
push(
|
|
"agent",
|
|
`목표가 ${won(target)}원은 협력사가 받아들일 수 있는 선 아래입니다. 무리하게 밀지 않고 이 건은 개찰로 마감하겠습니다.`,
|
|
mid,
|
|
)
|
|
return { turns, outcome: "open", finalPrice: mid }
|
|
}
|
|
|
|
push("agent", `${won(target)}원이면 연 12회 정기 발주로 확정하겠습니다. 대신 납기는 2주 여유를 드리겠습니다.`, target)
|
|
push("counterpart", `정기 발주와 결제 조건을 지켜주신다면 ${won(target)}원으로 맞추겠습니다.`, target)
|
|
push(
|
|
"agent",
|
|
`${won(target)}원으로 확정했습니다. 최초 제시가 대비 ${won(item.listPrice - target)}원 절감입니다.`,
|
|
target,
|
|
)
|
|
|
|
return { turns, outcome: "award", finalPrice: target }
|
|
}
|
|
|
|
/** 절감액·절감률. 결과 화면과 상담 신청 연결에 쓴다. */
|
|
export function savings(item: DemoItem, finalPrice: number) {
|
|
const amount = Math.max(0, item.listPrice - finalPrice)
|
|
return { amount, rate: (amount / item.listPrice) * 100 }
|
|
}
|