/** * 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용). * * 실제 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 push( "agent", `같은 사양 인터넷 최저가가 ${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 } }