[fix] agent·negodata: 협상카드 변수 치환 누락 수정 + 상품 업로드 협력사 목록 422 수정

- agent: 인터넷 최저가(items.internet_lowest_price)를 협상 파이프라인에 연결
  (nego_context_crud 조회 → context loader → session.context → chat_engine.vars_for).
  값 있을 때만 {internet_lowest_price}/internet_min_price 치환, 미수집(0)이면 원형 유지(허위 시장가 방지)
- negodata: 카드 에디터 변수 카탈로그에 '인터넷 최저가' 추가
- negodata: 견적 생성 카드 선택 게이팅 — 조건전략 미작성({customer_condition} 잔존)·
  최저가 미수집 상품의 {internet_lowest_price} 인용 카드는 선택 불가(상품 변경 시 자동 해제 포함)
- negodata: 상품 엑셀 업로드의 협력사 목록 size 1000→100
  (PageParams le=100 초과 시 422 → 목록 비어 전 행 '미등록 협력사' 오탐)
- seed: WC-01 의 데이터 소스 없는 {customer_reference} 제거 → 고정 문구로 대체

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-07-24 10:36:24 +09:00
parent 4f9789c08a
commit ce551090b3
9 changed files with 102 additions and 26 deletions

View File

@ -26,7 +26,8 @@ _SESSIONS = table(
column("deleted"), column("deleted"),
schema="negotiation", schema="negotiation",
) )
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("deleted"), schema="partner") _ITEMS = table("items", column("item_id"), column("name"), column("price"),
column("internet_lowest_price"), column("deleted"), schema="partner")
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner") _SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table( _QUOTATIONS = table(
"quotations", "quotations",
@ -72,6 +73,12 @@ class INegoContextCRUD(ABC):
"""품목 기준가(items.price). 없으면 0.""" """품목 기준가(items.price). 없으면 0."""
pass pass
@abstractmethod
async def get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""상품 인터넷 최저가(items.internet_lowest_price — LPS 수집 대표값). 미수집이면 0.
카드 스크립트 {internet_lowest_price} 치환용(NGC-008 시장가 인용 카드)."""
pass
@abstractmethod @abstractmethod
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]: async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0.""" """협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
@ -141,6 +148,21 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def get_item_lowest_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
try:
query = (
select(_ITEMS.c.internet_lowest_price)
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_lowest_price failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return err_type, 0
return ErrorType.SUCCESS, int(rows[0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]: async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
try: try:
query = ( query = (

View File

@ -247,6 +247,12 @@ class ChatEngine:
out["product_name"] = str(ctx["product_name"]) out["product_name"] = str(ctx["product_name"])
if "offer_1pct" in ctx: if "offer_1pct" in ctx:
out["offer_1pct"] = int(ctx["offer_1pct"]) out["offer_1pct"] = int(ctx["offer_1pct"])
# 인터넷 최저가: LPS 대표값(items.internet_lowest_price). 카드는 {internet_lowest_price},
# 라벨 매핑(variable_mapping.json)은 internet_min_price 를 쓰므로 target/anchor 처럼 양쪽 이름 모두 채운다.
# 미수집(0/없음)이면 키를 만들지 않는다 — 원형 유지 → 허위 시장가 인용 방지(NGC-008 은 값 있을 때만 유효).
ilp = ctx.get("internet_lowest_price") or 0
if ilp > 0:
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의. # 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0 anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0 target = ctx.get("target_price") or 0

View File

@ -39,6 +39,7 @@ class NegotiationDbContext:
target_price: int # 목표 매입가(원) — sessions.target_price target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백) anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
@ -86,6 +87,9 @@ class NegotiationContextLoader:
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시). # 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
_, item_price = await self.crud.get_item_price(s, item_id) _, item_price = await self.crud.get_item_price(s, item_id)
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
# 카드 스크립트 치환용 이름 — 협력사명/상품명. 없으면 None(호출부 기본값 폴백). # 카드 스크립트 치환용 이름 — 협력사명/상품명. 없으면 None(호출부 기본값 폴백).
_, partner_name = await self.crud.get_supplier_name(s, supplier_id) _, partner_name = await self.crud.get_supplier_name(s, supplier_id)
_, product_name = await self.crud.get_item_name(s, item_id) _, product_name = await self.crud.get_item_name(s, item_id)
@ -108,6 +112,7 @@ class NegotiationContextLoader:
target_price=target, target_price=target,
anchor_price=anchor, anchor_price=anchor,
item_price=item_price, item_price=item_price,
internet_lowest_price=internet_lowest_price,
partner_name=partner_name, partner_name=partner_name,
product_name=product_name, product_name=product_name,
partner_type=PartnerType.from_count(supplier_count), partner_type=PartnerType.from_count(supplier_count),

View File

@ -110,6 +110,9 @@ class ChatService:
"round": 0, "round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용. # 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
"item_price": db_ctx.item_price if db_ctx else 0, "item_price": db_ctx.item_price if db_ctx else 0,
# 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용.
# 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지).
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0,
# 견적 생성 시 선택한 카드. 일반카드는 action_id 0..N-1 에 그대로 매핑한다. # 견적 생성 시 선택한 카드. 일반카드는 action_id 0..N-1 에 그대로 매핑한다.
# 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는 # 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는
# 선택된 와일드카드가 있을 때만 허용한다. # 선택된 와일드카드가 있을 때만 허용한다.

View File

@ -192,6 +192,9 @@ async def test_loader_with_crud_double(db_engine):
async def get_item_price(self, cdb, item_id): async def get_item_price(self, cdb, item_id):
return ErrorType.SUCCESS, 7000 return ErrorType.SUCCESS, 7000
async def get_item_lowest_price(self, cdb, item_id):
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
async def get_supplier_total_revenue(self, cdb, supplier_id): async def get_supplier_total_revenue(self, cdb, supplier_id):
return ErrorType.SUCCESS, 12_000_000.0 return ErrorType.SUCCESS, 12_000_000.0
@ -219,6 +222,7 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.target_price == 50000 assert ctx.target_price == 50000
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.item_price == 7000 assert ctx.item_price == 7000
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
assert ctx.partner_name == "테스트협력사" assert ctx.partner_name == "테스트협력사"
assert ctx.product_name == "테스트상품" assert ctx.product_name == "테스트상품"
assert ctx.revenue_amount == 12_000_000.0 assert ctx.revenue_amount == 12_000_000.0

View File

@ -19,6 +19,7 @@ export const CARD_VARIABLES: CardVariable[] = [
{ name: 'target_mid_price', label: '중간가' }, // 앵커·목표 중간값(역제안용) { name: 'target_mid_price', label: '중간가' }, // 앵커·목표 중간값(역제안용)
{ name: 'middle_price', label: '절충가' }, // 당사 직전가·협력사 제시가의 절충값 { name: 'middle_price', label: '절충가' }, // 당사 직전가·협력사 제시가의 절충값
{ name: 'discount_rate', label: '인하율' }, // 기존 공급가 대비 인하율(%) { name: 'discount_rate', label: '인하율' }, // 기존 공급가 대비 인하율(%)
{ name: 'internet_lowest_price', label: '인터넷 최저가' }, // 상품 LPS 대표 최저가(items.internet_lowest_price) — 미수집 상품은 치환 안 됨
]; ];
// 조건 전략(customer_condition)은 특수 변수 — 카드 본문엔 인라인 칩으로 위치만 두고, // 조건 전략(customer_condition)은 특수 변수 — 카드 본문엔 인라인 칩으로 위치만 두고,

View File

@ -278,7 +278,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
const columns = useMemo(() => buildColumns(label, itemFields, isHidden), [label, itemFields, isHidden]); const columns = useMemo(() => buildColumns(label, itemFields, isHidden), [label, itemFields, isHidden]);
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]); const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
// 공급사 컬럼 검증·매핑용 협력사 전체 목록(이름 → id). // 공급사 컬럼 검증·매핑용 협력사 전체 목록(이름 → id).
const supplierList = useListSuppliers({ size: 1000 }); const supplierList = useListSuppliers({ size: 100 }); // size 상한은 백엔드 PageParams le=100 — 초과 시 422로 목록이 비어 전 행 '미등록' 오탐
const supplierIdByName = useMemo(() => { const supplierIdByName = useMemo(() => {
const m = new Map<string, string>(); const m = new Map<string, string>();
(supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id)); (supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id));

View File

@ -6,6 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card'; import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types'; import { mapCardData } from '@/features/cards/types';
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
import { useLabels, useCompanySettings } from '@/features/settings/useCompanySettings'; import { useLabels, useCompanySettings } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography'; import { Typography, typographyVariants } from '@/components/ui/typography';
@ -162,35 +163,57 @@ export function QuotationCreateModal({
}); });
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards; const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
// ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ──
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
const conditionUnfilled = (c: NegotiationCard) =>
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
// (2) 인터넷 최저가({internet_lowest_price}) 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
const lowestPriceLeak = (c: NegotiationCard) =>
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
const blockReason = (c: NegotiationCard): string | null =>
conditionUnfilled(c) ? `${CONDITION_LABEL} 미작성` : lowestPriceLeak(c) ? '인터넷 최저가 미수집' : null;
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다. // 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
const rankedCards = cardRows const rankedCards = cardRows
.filter((c) => !c.isWildcard || c.status === 'ACTIVE') .filter((c) => !c.isWildcard || c.status === 'ACTIVE')
.slice() .slice()
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount); .sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
const cardOptions: ComboOption[] = rankedCards const cardOptions: ComboOption[] = rankedCards
.map((card, i) => ({ .map((card, i) => {
id: card.id, const reason = blockReason(card);
label: card.title, return {
node: ( id: card.id,
<div> label: card.title,
<div className="flex items-center gap-1.5"> disabled: !!reason,
{card.usedCount > 0 && ( node: (
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}> <div className={reason ? 'opacity-60' : undefined}>
{i + 1} · {Math.round(card.successRate * 100)}% <div className="flex items-center gap-1.5">
{card.usedCount > 0 && (
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
{i + 1} · {Math.round(card.successRate * 100)}%
</span>
)}
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{card.isWildcard ? '와일드' : '협상'}
</span> </span>
)} {reason && (
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography> <span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}> {reason}
{card.isWildcard ? '와일드' : '협상'} </span>
</span> )}
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div> </div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography> ),
</div> };
), });
}));
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. // 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
const topRankedCards = rankedCards.slice(0, 3).filter((c) => c.usedCount > 0); const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
const topRankedKey = topRankedCards.map((c) => c.id).join(','); const topRankedKey = topRankedCards.map((c) => c.id).join(',');
const autoSelectedRef = useRef(false); const autoSelectedRef = useRef(false);
@ -212,6 +235,19 @@ export function QuotationCreateModal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, topRankedKey]); }, [open, topRankedKey]);
// 이미 고른 카드 중, 상품을 최저가 미수집 상품으로 바꾸면 {internet_lowest_price} 인용 카드는 자동 해제.
// (picklist disabled 는 신규 선택만 막으므로, 상품 변경 후 잔존 선택분을 여기서 정리해 노출을 막는다.)
useEffect(() => {
if (!lowestUnavailable) return;
const leakIds = new Set(
cardRows.filter((c) => (c.scriptPreview ?? '').includes('{internet_lowest_price}')).map((c) => c.id),
);
if (leakIds.size === 0) return;
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
// productId 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lowestUnavailable, productId]);
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다. // 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
const selectedCardRows = selectedCardIds.map((id) => { const selectedCardRows = selectedCardIds.map((id) => {
const d = cardDetails.get(id); const d = cardDetails.get(id);

View File

@ -45,8 +45,7 @@ WHERE NOT EXISTS (
-- {target_mid_price} 역제안가 = (anchoring_price + target_price) / 2 -- {target_mid_price} 역제안가 = (anchoring_price + target_price) / 2
-- {middle_price} 절충가 = (prev_customer_price + prev_partner_price) / 2 -- {middle_price} 절충가 = (prev_customer_price + prev_partner_price) / 2
-- 조건/근거: -- 조건/근거:
-- {customer_condition} 고객사 교환·요구 조건 -- {customer_condition} 고객사 교환·요구 조건 (조건 전략 내용이 있을 때만 카드 선택 가능 — 미작성 시 토큰 잔존)
-- {customer_reference} 고객사 가격 산정 근거
-- ============================================================ -- ============================================================
-- 일반 카드 (nego_cards) -- 일반 카드 (nego_cards)
@ -171,10 +170,10 @@ SELECT * FROM (VALUES
(NULL::uuid, '목표가 선제안', 'WC-01', (NULL::uuid, '목표가 선제안', 'WC-01',
'안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다. '안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다.
{customer_reference}() , {target_price}(VAT별도) . , {target_price}(VAT별도) .
, . .', , . .',
'[{"type": "paragraph", "children": [{"text": "안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 "}, {"type": "variable", "name": "customer_reference", "label": "고객사 가격 산정 근거", "children": [{"text": ""}], "style": {"bold": true}}, {"text": "을(를) 종합적으로 검토하여 합리적인 목표 가격을 산정하였으며, 이에 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 제안은 명확한 산정 기준에 근거한 것으로, 귀사께서도 이를 바탕으로 건설적인 협의가 가능할 것으로 기대합니다. 검토 후 의견 주시기 바랍니다."}]}]'::jsonb, '[{"type": "paragraph", "children": [{"text": "안녕하십니까. 금번 협상에 참여해 주셔서 감사합니다."}]}, {"type": "paragraph", "children": [{"text": "당사는 "}, {"text": "시장 상황과 거래 조건", "bold": true}, {"text": "을 종합적으로 검토하여 합리적인 목표 가격을 산정하였으며, 이에 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 제안은 명확한 산정 기준에 근거한 것으로, 귀사께서도 이를 바탕으로 건설적인 협의가 가능할 것으로 기대합니다. 검토 후 의견 주시기 바랍니다."}]}]'::jsonb,
1, NULL::varchar, TRUE, NULL::varchar, 5, 5), 1, NULL::varchar, TRUE, NULL::varchar, 5, 5),
-- 2. 역제안가 제시 — tone 5(단호) · strategy 5(선점) -- 2. 역제안가 제시 — tone 5(단호) · strategy 5(선점)