[feat] negodata/front·agent: 카드 제시 가격 변수 선택 + 종결 전용 토글 와일드카드 한정
- 카드 상세 "협상 전술"의 제시 가격을 표시 전용 → 셀렉트로: 기본 '자동(멘트의 마지막 가격 변수)', 가격 변수가 여럿인 카드만 명시 선택이 의미. 선택지는 멘트에 실제 꽂힌 변수로 제한하고 멘트 수정으로 선택 변수가 사라지면 자동으로 리셋 — 문구≠계산 어긋남 원천 차단. 명시 선택 시에만 tactic.offer_variable 저장(agent build_card_spec 이 파싱보다 우선 적용, 기존 경로). - 종결 전용 토글은 와일드카드 폼에만 노출 — 종결 국면이 와일드카드 목록에서만 카드를 뽑으므로 협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. 협상카드는 저장 시 항상 false. - 퍼즈 하네스 100케이스로 확대(시드 고정) — 100/100 불변식 위반 0(타결 94/결렬 6). - override 우선순위 단위 테스트 추가(자동=마지막 변수, tactic.offer_variable 지정 시 지정 변수). 검증: agent 179 통과 · front tsc+eslint 통과 · 랜덤 협상 100회 완주 위반 0
This commit is contained in:
parent
c56cf8e3af
commit
54f8f7a6a1
@ -26,7 +26,7 @@ from tenancy.config_loader import TenantConfigLoader # noqa: E402
|
||||
from tenancy.registry import TenantEngineRegistry # noqa: E402
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402
|
||||
|
||||
N = 50
|
||||
N = 100
|
||||
SEED = 20260805
|
||||
TARGET = 10_000
|
||||
NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005",
|
||||
|
||||
@ -114,6 +114,15 @@ def test_available_min_round_and_closing_phase():
|
||||
assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만
|
||||
|
||||
|
||||
def test_tactic_offer_variable_overrides_parse():
|
||||
"""검증: tactic.offer_variable 명시 지정(negodata 셀렉트) — 파싱(마지막 변수) 대신 지정 변수 사용.
|
||||
기대결과: 멘트 마지막이 target_price 여도 지정한 anchoring_price 가 제안가 변수가 된다."""
|
||||
script = "적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안 드립니다."
|
||||
assert build_card_spec(script).offer_variable == "target_price" # 자동: 마지막 변수
|
||||
spec = build_card_spec(script, {"offer_variable": "anchoring_price"})
|
||||
assert spec.offer_variable == "anchoring_price" # 명시 지정이 우선
|
||||
|
||||
|
||||
def test_available_requires_context_value():
|
||||
"""검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고,
|
||||
기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀)."""
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@ -35,13 +36,14 @@ const OFFER_VARIABLE_LABEL: Record<string, string> = {
|
||||
middle_price: '절충가 (당사 직전 제안·제시가의 중간)',
|
||||
};
|
||||
|
||||
// 스크립트에서 제안가 변수를 파싱(마지막 매치) — agent parse_offer_variable 미러.
|
||||
function parseOfferLabel(editorScript: Descendant[]): string | null {
|
||||
// 스크립트에 등장하는 제안가 변수들(등장 순서 그대로, 중복 포함) — agent parse_offer_variable 미러.
|
||||
// 자동 모드의 제안가 = 마지막 원소. 셀렉트 선택지는 이 목록(중복 제거)으로 제한한다
|
||||
// (멘트에 없는 변수를 고르면 문구와 계산이 어긋나는 사고가 되살아나므로 원천 차단).
|
||||
function parseOfferVariables(editorScript: Descendant[]): string[] {
|
||||
const marker = serializeToMarker(editorScript);
|
||||
const found = [...marker.matchAll(/\{([a-z_]+)\}/g)]
|
||||
return [...marker.matchAll(/\{([a-z_]+)\}/g)]
|
||||
.map((m) => m[1])
|
||||
.filter((name) => name in OFFER_VARIABLE_LABEL);
|
||||
return found.length ? OFFER_VARIABLE_LABEL[found[found.length - 1]] : null;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
@ -58,6 +60,7 @@ const schema = z.object({
|
||||
memo: z.string(),
|
||||
closing: z.boolean(), // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
|
||||
minRound: z.number({ message: '최소 라운드를 숫자로 입력해 주세요.' }).int().min(1, '최소 라운드는 1 이상이어야 합니다.'),
|
||||
offerVariable: z.string(), // 제시 가격 변수. 'auto'=멘트에서 파싱(기본), 그 외=명시 지정(멘트에 있는 변수만)
|
||||
}).refine(
|
||||
// 조건 전략 칩을 넣었으면 조건 내용도 작성해야 한다(빈 상태로 저장 시 문구가 비어버림).
|
||||
(v) => !hasConditionVariable(v.editorScript) || serializeToText(v.conditionScript).trim().length > 0,
|
||||
@ -98,6 +101,7 @@ function buildDefaults(
|
||||
memo: card.memo || '',
|
||||
closing: card.tactic?.closing ?? false,
|
||||
minRound: card.tactic?.min_round ?? 1,
|
||||
offerVariable: card.tactic?.offer_variable ?? 'auto',
|
||||
};
|
||||
}
|
||||
const wild = activeTab === 'WILD';
|
||||
@ -113,6 +117,7 @@ function buildDefaults(
|
||||
memo: '',
|
||||
closing: false,
|
||||
minRound: 1,
|
||||
offerVariable: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
@ -144,8 +149,19 @@ export function CardFormSheet({
|
||||
const isWildcard = watch('isWildcard');
|
||||
// 본문에 조건 전략 칩이 있으면 조건 내용 입력용 별도 에디터를 노출한다.
|
||||
const showConditionEditor = hasConditionVariable(watch('editorScript') || []);
|
||||
// 제시 가격 = 스크립트 파싱 결과(마지막 제안가 변수) — 입력이 아니라 표시(단일 진실은 스크립트).
|
||||
const offerLabel = parseOfferLabel(watch('editorScript') || []);
|
||||
// 제시 가격 — 기본은 스크립트 파싱(마지막 제안가 변수), 필요 시 멘트에 있는 변수 중에서 명시 선택.
|
||||
const offerVarsRaw = parseOfferVariables(watch('editorScript') || []);
|
||||
const offerVarOptions = [...new Set(offerVarsRaw)]; // 셀렉트 선택지(중복 제거)
|
||||
const autoOfferVar = offerVarsRaw.length ? offerVarsRaw[offerVarsRaw.length - 1] : null;
|
||||
const offerVariable = watch('offerVariable');
|
||||
// 멘트를 고쳐 선택했던 변수가 사라지면 자동으로 되돌린다 — 멘트에 없는 변수 지정은 불가.
|
||||
useEffect(() => {
|
||||
if (offerVariable !== 'auto' && !offerVarOptions.includes(offerVariable)) {
|
||||
setValue('offerVariable', 'auto');
|
||||
}
|
||||
// offerVarOptions 는 매 렌더 새 배열 — 내용 키로만 감지
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [offerVariable, offerVarOptions.join(',')]);
|
||||
|
||||
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
@ -171,8 +187,13 @@ export function CardFormSheet({
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
// 항상 풀 객체로 전송 — 부분 전송(exclude_unset)이면 종결 해제가 DB 에 안 남는다.
|
||||
tactic: { min_round: v.minRound, closing: v.closing },
|
||||
// 항상 풀 객체로 전송 — 부분 전송이면 해제가 DB에 안 남는다. 자동 모드는 offer_variable 키를 뺀다.
|
||||
// 종결 전용은 와일드카드만(종결 국면이 와일드카드 목록에서만 뽑음) — 협상카드는 항상 false.
|
||||
tactic: {
|
||||
min_round: v.minRound,
|
||||
closing: v.isWildcard ? v.closing : false,
|
||||
...(v.offerVariable !== 'auto' ? { offer_variable: v.offerVariable } : {}),
|
||||
},
|
||||
};
|
||||
const kind = v.isWildcard ? '와일드카드' : '협상카드';
|
||||
try {
|
||||
@ -389,20 +410,50 @@ export function CardFormSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 협상 전술 — 제시 가격은 스크립트의 변수가 정하고(읽기 전용 표시), 운영 규칙만 입력받는다. */}
|
||||
{/* 협상 전술 — 제시 가격(기본=멘트 파싱, 멘트에 있는 변수 중 명시 선택 가능) + 운영 규칙. */}
|
||||
<div className="space-y-3 p-3 bg-muted/40 rounded border border-border">
|
||||
<Typography variant="label" className="font-bold text-[10px] block">협상 전술</Typography>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">제시 가격</Typography>
|
||||
{offerVarOptions.length === 0 ? (
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">
|
||||
{offerLabel ?? '없음 — 설득 전용 (가격 변수를 넣으면 그 값을 제시합니다)'}
|
||||
없음 — 설득 전용 (스크립트에 가격 변수를 넣으면 그 값을 제시합니다)
|
||||
</Typography>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name="offerVariable"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={(v) => field.onChange(v ?? 'auto')}>
|
||||
<SelectTrigger id="form-card-offer-variable" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === 'auto' || !value
|
||||
? `자동 — ${autoOfferVar ? OFFER_VARIABLE_LABEL[autoOfferVar] : ''} (멘트의 마지막 가격 변수)`
|
||||
: OFFER_VARIABLE_LABEL[String(value)] ?? String(value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
자동 — {autoOfferVar ? OFFER_VARIABLE_LABEL[autoOfferVar] : ''} (멘트의 마지막 가격 변수)
|
||||
</SelectItem>
|
||||
{offerVarOptions.map((name) => (
|
||||
<SelectItem key={name} value={name}>{OFFER_VARIABLE_LABEL[name]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
스크립트의 마지막 가격 변수가 협력사에게 제시(수락 시 타결)할 금액입니다.
|
||||
제시 가격이 목표가를 넘거나 협력사 제시가보다 높으면 이 카드는 그 라운드에 발동하지 않습니다.
|
||||
협력사에게 제시(수락 시 타결)할 금액입니다. 멘트에 넣은 가격 변수 중에서만 고를 수 있으며,
|
||||
제시 가격이 타결 상한을 넘거나 협력사 제시가보다 높거나 직전 당사 제안보다 낮으면 그 라운드에 발동하지 않습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* 종결 전용은 와일드카드에만 — 종결 국면은 와일드카드 목록에서만 카드를 뽑으므로
|
||||
협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. */}
|
||||
{isWildcard && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="small" className="font-semibold">종결 전용</Typography>
|
||||
@ -416,6 +467,7 @@ export function CardFormSheet({
|
||||
켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">최소 라운드</Typography>
|
||||
<Input
|
||||
|
||||
@ -27,7 +27,7 @@ export type CardInput = {
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
tactic?: { min_round?: number; closing?: boolean }; // 전술 운영 규칙. 제안가는 스크립트 변수가 정한다
|
||||
tactic?: { min_round?: number; closing?: boolean; offer_variable?: string }; // 전술 운영 규칙. 제안가는 기본 멘트 파싱, offer_variable 로 명시 지정 가능
|
||||
};
|
||||
|
||||
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
|
||||
|
||||
@ -42,6 +42,7 @@ export interface NegotiationCard {
|
||||
export interface CardTactic {
|
||||
min_round?: number; // 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준)
|
||||
closing?: boolean; // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
|
||||
offer_variable?: string; // 제시 가격 변수 명시 지정. 없으면 멘트 파싱(마지막 가격 변수) — 멘트에 있는 변수만 허용
|
||||
}
|
||||
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user