diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index c99f0ff..53a17aa 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -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 = ( diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 343aa34..921a912 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -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 diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py index 5855c07..7076d56 100644 --- a/agent/negotiation/chat/service/negotiation_context_loader.py +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -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), diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 77719e9..84f8b7c 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는 # 선택된 와일드카드가 있을 때만 허용한다. diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index c72eaa6..c0b7a33 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -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 diff --git a/negodata/front/src/features/cards/editor/variables.ts b/negodata/front/src/features/cards/editor/variables.ts index d067344..2023f62 100644 --- a/negodata/front/src/features/cards/editor/variables.ts +++ b/negodata/front/src/features/cards/editor/variables.ts @@ -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)은 특수 변수 — 카드 본문엔 인라인 칩으로 위치만 두고, diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index a9b1993..aaa1c45 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -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(); (supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id)); diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index e6e37cd..81c1694 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -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: ( -
-
- {card.usedCount > 0 && ( - - {i + 1}위 · 성공률 {Math.round(card.successRate * 100)}% + .map((card, i) => { + const reason = blockReason(card); + return { + id: card.id, + label: card.title, + disabled: !!reason, + node: ( +
+
+ {card.usedCount > 0 && ( + + {i + 1}위 · 성공률 {Math.round(card.successRate * 100)}% + + )} + {card.code} + + {card.isWildcard ? '와일드' : '협상'} - )} - {card.code} - - {card.isWildcard ? '와일드' : '협상'} - + {reason && ( + + {reason} + + )} +
+ {card.title}
- {card.title} -
- ), - })); + ), + }; + }); - // 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); diff --git a/postgres-init/init-data/init-data.sql b/postgres-init/init-data/init-data.sql index 72f6e03..17a2ee7 100644 --- a/postgres-init/init-data/init-data.sql +++ b/postgres-init/init-data/init-data.sql @@ -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(선점)