[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:
parent
4f9789c08a
commit
ce551090b3
@ -26,7 +26,8 @@ _SESSIONS = table(
|
||||
column("deleted"),
|
||||
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")
|
||||
_QUOTATIONS = table(
|
||||
"quotations",
|
||||
@ -72,6 +73,12 @@ class INegoContextCRUD(ABC):
|
||||
"""품목 기준가(items.price). 없으면 0."""
|
||||
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
|
||||
async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]:
|
||||
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
|
||||
@ -141,6 +148,21 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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]:
|
||||
try:
|
||||
query = (
|
||||
|
||||
@ -247,6 +247,12 @@ class ChatEngine:
|
||||
out["product_name"] = str(ctx["product_name"])
|
||||
if "offer_1pct" in ctx:
|
||||
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 산식과 동일 정의.
|
||||
anchor = ctx.get("anchor_price") or 0
|
||||
target = ctx.get("target_price") or 0
|
||||
|
||||
@ -39,6 +39,7 @@ class NegotiationDbContext:
|
||||
target_price: int # 목표 매입가(원) — sessions.target_price
|
||||
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
||||
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
|
||||
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
|
||||
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
|
||||
@ -86,6 +87,9 @@ class NegotiationContextLoader:
|
||||
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
||||
_, 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(호출부 기본값 폴백).
|
||||
_, partner_name = await self.crud.get_supplier_name(s, supplier_id)
|
||||
_, product_name = await self.crud.get_item_name(s, item_id)
|
||||
@ -108,6 +112,7 @@ class NegotiationContextLoader:
|
||||
target_price=target,
|
||||
anchor_price=anchor,
|
||||
item_price=item_price,
|
||||
internet_lowest_price=internet_lowest_price,
|
||||
partner_name=partner_name,
|
||||
product_name=product_name,
|
||||
partner_type=PartnerType.from_count(supplier_count),
|
||||
|
||||
@ -110,6 +110,9 @@ class ChatService:
|
||||
"round": 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 에 그대로 매핑한다.
|
||||
# 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는
|
||||
# 선택된 와일드카드가 있을 때만 허용한다.
|
||||
|
||||
@ -192,6 +192,9 @@ async def test_loader_with_crud_double(db_engine):
|
||||
async def get_item_price(self, cdb, item_id):
|
||||
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):
|
||||
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.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
||||
assert ctx.item_price == 7000
|
||||
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
|
||||
assert ctx.partner_name == "테스트협력사"
|
||||
assert ctx.product_name == "테스트상품"
|
||||
assert ctx.revenue_amount == 12_000_000.0
|
||||
|
||||
@ -19,6 +19,7 @@ export const CARD_VARIABLES: CardVariable[] = [
|
||||
{ name: 'target_mid_price', label: '중간가' }, // 앵커·목표 중간값(역제안용)
|
||||
{ name: 'middle_price', label: '절충가' }, // 당사 직전가·협력사 제시가의 절충값
|
||||
{ name: 'discount_rate', label: '인하율' }, // 기존 공급가 대비 인하율(%)
|
||||
{ name: 'internet_lowest_price', label: '인터넷 최저가' }, // 상품 LPS 대표 최저가(items.internet_lowest_price) — 미수집 상품은 치환 안 됨
|
||||
];
|
||||
|
||||
// 조건 전략(customer_condition)은 특수 변수 — 카드 본문엔 인라인 칩으로 위치만 두고,
|
||||
|
||||
@ -278,7 +278,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
const columns = useMemo(() => buildColumns(label, itemFields, isHidden), [label, itemFields, isHidden]);
|
||||
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
|
||||
// 공급사 컬럼 검증·매핑용 협력사 전체 목록(이름 → id).
|
||||
const supplierList = useListSuppliers({ size: 1000 });
|
||||
const supplierList = useListSuppliers({ size: 100 }); // size 상한은 백엔드 PageParams le=100 — 초과 시 422로 목록이 비어 전 행 '미등록' 오탐
|
||||
const supplierIdByName = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
(supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id));
|
||||
|
||||
@ -6,6 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||||
import { useLabels, useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
@ -162,35 +163,57 @@ export function QuotationCreateModal({
|
||||
});
|
||||
|
||||
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위 배지가 붙는다.
|
||||
const rankedCards = cardRows
|
||||
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
|
||||
.slice()
|
||||
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
||||
const cardOptions: ComboOption[] = rankedCards
|
||||
.map((card, i) => ({
|
||||
id: card.id,
|
||||
label: card.title,
|
||||
node: (
|
||||
<div>
|
||||
<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)}%
|
||||
.map((card, i) => {
|
||||
const reason = blockReason(card);
|
||||
return {
|
||||
id: card.id,
|
||||
label: card.title,
|
||||
disabled: !!reason,
|
||||
node: (
|
||||
<div className={reason ? 'opacity-60' : undefined}>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
{reason && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
||||
{reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||||
</div>
|
||||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상.
|
||||
const topRankedCards = rankedCards.slice(0, 3).filter((c) => c.usedCount > 0);
|
||||
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
||||
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
||||
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
||||
const autoSelectedRef = useRef(false);
|
||||
|
||||
@ -212,6 +235,19 @@ export function QuotationCreateModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 d = cardDetails.get(id);
|
||||
|
||||
@ -45,8 +45,7 @@ WHERE NOT EXISTS (
|
||||
-- {target_mid_price} 역제안가 = (anchoring_price + target_price) / 2
|
||||
-- {middle_price} 절충가 = (prev_customer_price + prev_partner_price) / 2
|
||||
-- 조건/근거:
|
||||
-- {customer_condition} 고객사 교환·요구 조건
|
||||
-- {customer_reference} 고객사 가격 산정 근거
|
||||
-- {customer_condition} 고객사 교환·요구 조건 (조건 전략 내용이 있을 때만 카드 선택 가능 — 미작성 시 토큰 잔존)
|
||||
-- ============================================================
|
||||
|
||||
-- 일반 카드 (nego_cards)
|
||||
@ -171,10 +170,10 @@ SELECT * FROM (VALUES
|
||||
(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),
|
||||
|
||||
-- 2. 역제안가 제시 — tone 5(단호) · strategy 5(선점)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user