diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py
index 65b08c8..d9a9b83 100644
--- a/agent/negotiation/chat/infra/repository/nego_context_crud.py
+++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py
@@ -33,8 +33,32 @@ _QUOTATION_SETTINGS = table(
column("qt_setting_id"), column("card_count"), column("deleted"),
schema="quotation",
)
-_ITEMS = table("items", column("item_id"), column("name"), column("price"),
- column("internet_lowest_price"), column("deleted"), schema="partner")
+_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
+ column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
+# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼과 그 호칭을 여기서 정한다.
+_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
+
+# 협상 기준가 후보: items 컬럼 ↔ 용어 카탈로그 키 ↔ 용어 미설정 시 기본값.
+# 기본값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 한다 — 화면 라벨과
+# 협상 멘트 호칭이 갈리지 않도록. 문장이 어색하면 회사가 용어 탭에서 바꾼다.
+_BASELINE_PRICE = ("price", "item.price", "상품 단가")
+_BASELINE_PURCHASE = ("purchase_price", "item.purchase_price", "매입가")
+_BASELINE_BY_FIELD = {"price": _BASELINE_PRICE, "purchase_price": _BASELINE_PURCHASE}
+
+
+def _resolve_baseline(settings: dict) -> tuple:
+ """회사 설정 → 협상 기준가로 쓸 (컬럼, 라벨키, 호칭 폴백).
+
+ 1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
+ 미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
+ 않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
+ chosen = (settings.get("features") or {}).get("nego_baseline_field")
+ if chosen in _BASELINE_BY_FIELD:
+ return _BASELINE_BY_FIELD[chosen]
+ hidden = set(settings.get("hidden_fields") or [])
+ if "price" in hidden and "purchase_price" not in hidden:
+ return _BASELINE_PURCHASE
+ return _BASELINE_PRICE
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table(
"quotations",
@@ -53,12 +77,12 @@ _VERSION_WILD_CARDS = table(
)
_NEGO_CARDS = table(
"nego_cards",
- column("nego_card_id"), column("number"), column("deleted"),
+ column("nego_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
schema="card",
)
_WILD_CARDS = table(
"wild_cards",
- column("wild_card_id"), column("number"), column("deleted"),
+ column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
schema="card",
)
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
@@ -76,8 +100,12 @@ class INegoContextCRUD(ABC):
pass
@abstractmethod
- async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
- """품목 기준가(items.price). 없으면 0."""
+ async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
+ """협상 기준가·그 호칭·회사 용어 사전 (가격, 호칭, labels).
+
+ 어느 컬럼을 기준가로 쓰는지는 회사 설정(features.nego_baseline_field)이 정한다.
+ labels 는 companies.settings.labels 원본 — 협상 스크립트의 용어 토큰 치환에 쓴다.
+ 값이 없으면 (0, 호칭, {})."""
pass
@abstractmethod
@@ -124,8 +152,9 @@ class INegoContextCRUD(ABC):
pass
@abstractmethod
- async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
- """견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록."""
+ async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
+ """견적 version_id 에 연결된 (일반카드 행 목록, 와일드카드 행 목록). 없으면 빈 목록.
+ 행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 카드 전술(CardSpec)을 만든다."""
pass
@@ -146,20 +175,30 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
- async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
+ async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
+ _fallback = (0, _BASELINE_PRICE[2], {})
try:
+ # 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
query = (
- select(_ITEMS.c.price)
+ select(_ITEMS.c.price, _ITEMS.c.purchase_price, _COMPANIES.c.settings)
+ .select_from(_ITEMS.outerjoin(_COMPANIES, _ITEMS.c.company_id == _COMPANIES.c.company_id))
.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_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])
+ err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_baseline failed.", raise_error=False)
+ if err_type != ErrorType.SUCCESS or not rows:
+ return err_type, _fallback
+ # 컬럼이 2개 이상이면 execute 가 행 리스트를 준다(1개일 때만 스칼라 리스트).
+ price, purchase_price, settings = rows[0]
+ settings = settings if isinstance(settings, dict) else {}
+ labels = settings.get("labels") or {}
+ field, label_key, label_fallback = _resolve_baseline(settings)
+ label = labels.get(label_key) or label_fallback
+ value = purchase_price if field == "purchase_price" else price
+ return ErrorType.SUCCESS, (int(value or 0), label, labels)
except Exception as ex:
LOG.e_no_callstack(ex)
- return ErrorType.DB_RUN_FAILED, 0
+ return ErrorType.DB_RUN_FAILED, _fallback
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
try:
@@ -289,7 +328,7 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
- async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
+ async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
try:
version_q = (
select(_QUOTATIONS.c.version_id)
@@ -304,7 +343,7 @@ class NegoContextCRUD(INegoContextCRUD):
version_id = rows[0]
nego_q = (
- select(_NEGO_CARDS.c.number)
+ select(_NEGO_CARDS.c.number, _NEGO_CARDS.c.script, _NEGO_CARDS.c.tactic)
.select_from(
_VERSION_NEGO_CARDS.join(
_NEGO_CARDS,
@@ -323,7 +362,7 @@ class NegoContextCRUD(INegoContextCRUD):
return n_err, ([], [])
wild_q = (
- select(_WILD_CARDS.c.number)
+ select(_WILD_CARDS.c.number, _WILD_CARDS.c.script, _WILD_CARDS.c.tactic)
.select_from(
_VERSION_WILD_CARDS.join(
_WILD_CARDS,
@@ -342,8 +381,8 @@ class NegoContextCRUD(INegoContextCRUD):
return w_err, ([], [])
return ErrorType.SUCCESS, (
- [str(r) for r in n_rows if r is not None],
- [str(r) for r in w_rows if r is not None],
+ [(str(r[0]), r[1], r[2]) for r in n_rows if r[0] is not None],
+ [(str(r[0]), r[1], r[2]) for r in w_rows if r[0] is not None],
)
except Exception as ex:
LOG.e_no_callstack(ex)
diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py
index 7dd4cfa..12ec178 100644
--- a/agent/negotiation/chat/service/chat_engine.py
+++ b/agent/negotiation/chat/service/chat_engine.py
@@ -10,7 +10,7 @@ import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
-from negotiation.cards.domain.tactics import compute_counter, tactic_for
+from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context
from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
@@ -43,6 +43,39 @@ def _parse_price(user_input: Any) -> Optional[float]:
return price if price > 0 else None
+# 협상 스크립트가 쓰는 용어 토큰: {label_*} = 회사 용어(없으면 기본값).
+# 값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 화면·멘트 표기가 갈리지 않는다.
+_SCRIPT_LABELS = {
+ "label_supplier": ("supplier", "협력사"),
+ "label_target_price": ("target_price", "목표가"),
+ "label_delivery_type": ("item.delivery_type", "배송 형태"),
+ "label_delivery_type_1": ("delivery_type.1", "협력사배송"),
+ "label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
+ "label_delivery_type_3": ("delivery_type.3", "픽업배송"),
+ "label_product": ("item.name", "상품명"),
+ # 협상 기준가 호칭의 최후 폴백. 실제 값은 loader 가 회사 설정에서 정해 컨텍스트에 박제하고,
+ # 이 값은 DB 컨텍스트가 없는 데모/직접호출 경로에서만 쓰인다.
+ "label_item_price": ("item.price", "상품 단가"),
+}
+# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
+# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
+_JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"),
+ "을": ("을", "를"), "를": ("을", "를"), "과": ("과", "와"), "와": ("과", "와")}
+
+
+def _has_batchim(word: str) -> bool:
+ last = word[-1] if word else ""
+ return "가" <= last <= "힣" and (ord(last) - 0xAC00) % 28 != 0
+
+
+def _josa(word: str, form: str) -> str:
+ """단어 + 받침에 맞는 조사. form 은 대표형('를'·'는'·'가'·'와')."""
+ pair = _JOSA.get(form)
+ if not pair:
+ return word
+ return word + (pair[0] if _has_batchim(word) else pair[1])
+
+
@dataclass
class ChatSession:
session_id: str
@@ -169,6 +202,15 @@ class ChatEngine:
and price <= anchor * self.rules.wildcard_entry_ratio)
)
)
+ # 구간에 들어와도 실제로 낼 카드가 없으면(전부 종결 전용·사용됨·유효조건 미달) 이 조건은
+ # 불충족으로 두고 다음 조건(우선협상·소진 판정)을 평가한다 — 여기서 매칭돼 버리면
+ # 카드 소진 판정이 영영 돌지 않아, 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상이 된다.
+ if ok:
+ probe = ChatSession(
+ session_id=session.session_id, tenant_id=session.tenant_id,
+ company_id=session.company_id, context=dict(ctx),
+ )
+ ok = self._pick_wildcard(probe) != "가격협상"
elif cond == "check_is_supplier_type_c":
ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
@@ -181,7 +223,19 @@ class ChatEngine:
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
counter_rounds = max(0, ctx.get("round", 0) - 1)
- exhausted = counter_rounds >= self.rules.max_counter_rounds or (cards_total > 0 and cards_used >= cards_total)
+ # 담은 협상카드 중 지금 낼 수 있는 게 하나도 없으면(사용됨·발동조건 미달 — 예:
+ # 시장가 인용 카드인데 최저가 결측) 장수와 무관하게 소진으로 본다 — 안 그러면
+ # 선택 마스크가 전부 막힌 채 폴백이 부적합 카드를 억지로 꺼낸다(토큰 노출).
+ selected = ctx.get("selected_nego_card_numbers") or []
+ none_playable = bool(selected) and not any(
+ not is_played(ctx, n) and playable(spec_from_context(ctx, n), ctx)
+ for n in selected
+ )
+ exhausted = (
+ counter_rounds >= self.rules.max_counter_rounds
+ or (cards_total > 0 and cards_used >= cards_total)
+ or none_playable
+ )
if exhausted:
target = ctx.get("target_price", 0)
if not ctx.get("closing_played"):
@@ -207,22 +261,36 @@ class ChatEngine:
ctx = session.context
price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_price", 0)
+ target = ctx.get("target_price", 0)
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
- # 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
- # 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
- ctx["wildcard_used"] = True
- ctx["offer_1pct"] = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 (멘트 변수) — 10원 반올림(앵커·카운터와 통일)
- ctx["pending_counter_price"] = ctx["offer_1pct"] # 수락 시 이 가격으로 타결
- return "wild_card_1pct"
+ offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
+ # 제안가 공통 유효조건(≤목표가 · <제시가)은 시스템 1% 카드에도 동일하게 건다.
+ # 기본 앵커 밴드에선 수학적으로 항상 통과하지만, 앵커율 0 등 극단 데이터를 방어한다.
+ if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target):
+ # 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
+ # 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
+ ctx["wildcard_used"] = True
+ ctx["offer_1pct"] = offer_1pct
+ ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
+ ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
+ return "wild_card_1pct"
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
for number in (ctx.get("selected_wild_card_numbers") or []):
- counter = compute_counter(tactic_for(str(number)), ctx)
- if counter is not None:
+ number = str(number)
+ spec = spec_from_context(ctx, number)
+ # 종결 전용 카드(최종 통보·중간값 절충)는 여기서 안 꺼낸다 — 종결 국면의 마지막 한 방으로 예약.
+ # 이미 쓴 카드도 제외(같은 멘트 반복 방지).
+ if not available(spec, ctx) or is_played(ctx, number):
+ continue
+ offer = compute_offer(spec, ctx)
+ if offer is not None:
ctx["wildcard_used"] = True
- ctx["pending_counter_price"] = counter
- ctx["active_wild_card_number"] = str(number)
+ ctx["pending_counter_price"] = offer
+ ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
+ ctx["active_wild_card_number"] = number
+ mark_played(ctx, number)
return "wild_card_dynamic"
return "가격협상"
@@ -240,6 +308,14 @@ class ChatEngine:
if "anchor_price" in ctx:
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
+ # 용어 토큰 — 회사 용어 사전(labels)이 있으면 그 단어, 없으면 카탈로그 기본값.
+ # 조사가 붙는 자리를 위해 {label_supplier_를} 같은 파생 키도 함께 만든다.
+ labels = ctx.get("labels") or {}
+ for token, (label_key, fallback) in _SCRIPT_LABELS.items():
+ word = labels.get(label_key) or fallback
+ out[token] = word
+ for form in ("는", "가", "를", "와"):
+ out[f"{token}_{form}"] = _josa(word, form)
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
if ctx.get("partner_name"):
out["partner_name"] = str(ctx["partner_name"])
@@ -253,7 +329,7 @@ class ChatEngine:
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.OFFER_VARIABLES 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0
if "input_price" in ctx:
@@ -267,7 +343,7 @@ class ChatEngine:
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
if ctx.get("pending_counter_price"):
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다.
- # 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_counter 의
+ # 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_offer 의
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
@@ -275,21 +351,23 @@ class ChatEngine:
out["counter_price"] = pending_i
out["middle_price"] = pending_i
out["target_mid_price"] = pending_i
- # 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
- # 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
+ # 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
+ # 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
+ # 기준가 호칭(공급가/매입가/회사 라벨)은 회사 설정에서 온다 — loader 가 박제한 값.
base = ctx.get("item_price") or 0
+ label = ctx.get("item_price_label") or _SCRIPT_LABELS["label_item_price"][1]
if base > 1 and "input_price" in ctx:
rate = ((base - ctx["input_price"]) / base) * 100
out["discount_rate"] = f"{max(0.0, rate):.1f}"
if rate >= 0.05:
- out["discount_phrase"] = f"기존 공급가 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
+ out["discount_phrase"] = f"기존 {label} 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
elif rate <= -0.05:
out["discount_phrase"] = (
- f"기존 공급가(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
+ f"기존 {label}(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
else:
- out["discount_phrase"] = "기존 공급가와 동일한 수준의 금액입니다. "
+ out["discount_phrase"] = f"기존 {_josa(label, '와')} 동일한 수준의 금액입니다. "
else:
out["discount_rate"] = "0.0"
out["discount_phrase"] = ""
@@ -326,11 +404,14 @@ class ChatEngine:
elif step_key in _FAILURE_STEPS:
session.context["final_outcome"] = "failure"
outcome = session.context.get("final_outcome") if chat_end else None
+ # 선택지도 스크립트와 같은 변수 치환을 태운다 — 배송형태 보기가 회사 용어({label_delivery_type_1} 등)라
+ # 치환을 건너뛰면 사용자에게 토큰 원문이 그대로 보인다.
+ step_vars = self._vars(session)
return StepView(
step=step_key,
- script=self.repo.format_script(node.get("script", ""), self._vars(session)),
+ script=self.repo.format_script(node.get("script", ""), step_vars),
input_mode=node.get("next_input_mode", "null"),
- input_options=node.get("input_options", []),
+ input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
chat_end=bool(node.get("chat_end")),
client_step=self.step_map.get(step_key, step_key),
needs_card_selection=(step_key == "가격협상"),
@@ -339,9 +420,13 @@ class ChatEngine:
)
def _error(self, session: ChatSession, msg: str) -> StepView:
+ # 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
+ # 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
node = self.scripts.get(session.step, {})
+ step_vars = self._vars(session)
return StepView(
- step=session.step, script=node.get("script", ""),
- input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []),
+ step=session.step, script=self.repo.format_script(node.get("script", ""), step_vars),
+ input_mode=node.get("next_input_mode", "null"),
+ input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
)
diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py
index 81f7627..8f140e6 100644
--- a/agent/negotiation/chat/service/negotiation_context_loader.py
+++ b/agent/negotiation/chat/service/negotiation_context_loader.py
@@ -19,6 +19,7 @@ from typing import Optional
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG
+from negotiation.cards.domain.tactics import build_card_spec
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
from negotiation.qtable.domain.model.snapshot import PartnerType
@@ -38,7 +39,9 @@ class NegotiationDbContext:
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
- item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
+ item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
+ item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
+ labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
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
@@ -48,6 +51,9 @@ class NegotiationDbContext:
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
+ # 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
+ # 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
+ card_specs: dict
class NegotiationContextLoader:
@@ -85,8 +91,9 @@ class NegotiationContextLoader:
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
- # 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
- _, item_price = await self.crud.get_item_price(s, item_id)
+ # 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
+ # 없으면 0(인하율 멘트 미표시).
+ _, (item_price, item_price_label, labels) = await self.crud.get_item_baseline(s, item_id)
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
@@ -106,7 +113,19 @@ class NegotiationContextLoader:
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
- selected_nego_cards, selected_wild_cards = selected_cards
+ nego_rows, wild_rows = selected_cards
+ selected_nego_cards = [number for number, _script, _tactic in nego_rows]
+ selected_wild_cards = [number for number, _script, _tactic in wild_rows]
+ # 카드 전술 확정 — "스크립트에 꽂힌 변수가 곧 전술"(제안가 파싱) + tactic JSONB(min_round·closing).
+ card_specs = {}
+ for number, script, tactic in [*nego_rows, *wild_rows]:
+ spec = build_card_spec(script, tactic if isinstance(tactic, dict) else None)
+ card_specs[number] = {
+ "offer_variable": spec.offer_variable,
+ "min_round": spec.min_round,
+ "closing": spec.closing,
+ "requires": list(spec.requires), # 세션-의존 변수 결측 시 미발동(available)
+ }
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
_, card_count = await self.crud.get_card_count(s, sid)
@@ -116,6 +135,8 @@ class NegotiationContextLoader:
target_price=target,
anchor_price=anchor,
item_price=item_price,
+ item_price_label=item_price_label,
+ labels=labels,
internet_lowest_price=internet_lowest_price,
partner_name=partner_name,
product_name=product_name,
@@ -125,6 +146,7 @@ class NegotiationContextLoader:
selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards,
card_count=card_count,
+ card_specs=card_specs,
)
try:
diff --git a/agent/negotiation/chat/service/script_naturalizer.py b/agent/negotiation/chat/service/script_naturalizer.py
index 8134c82..1a91b3a 100644
--- a/agent/negotiation/chat/service/script_naturalizer.py
+++ b/agent/negotiation/chat/service/script_naturalizer.py
@@ -121,7 +121,7 @@ class ScriptNaturalizer:
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
- 가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 기존 공급가 대비.
+ 가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 협상 기준가 대비.
"""
out: Dict[str, Any] = {}
rnd = context.get("round") or 0
diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py
index 540b9c0..b792686 100644
--- a/agent/services/chat_service.py
+++ b/agent/services/chat_service.py
@@ -13,7 +13,7 @@ from common.enums import DBType, ErrorType
from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from config.server_configs import agent_config
-from negotiation.cards.domain.tactics import compute_counter, tactic_available, tactic_for
+from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context
from negotiation.chat.service.chat_engine import (
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
)
@@ -41,6 +41,8 @@ _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_reven
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
+_DEFAULT_ITEM_PRICE_LABEL = "상품 단가" # 협상 기준가 호칭 — DB 컨텍스트 없는 데모/직접호출 경로 폴백
+ # (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
class ChatService:
@@ -112,8 +114,12 @@ class ChatService:
"partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME),
"product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),
"round": 0,
- # 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
+ # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 가격협상_확인 인하율 산출용.
+ # 호칭은 회사 설정 라벨을 따른다("기존 {label} 대비 …" 멘트).
"item_price": db_ctx.item_price if db_ctx else 0,
+ "item_price_label": db_ctx.item_price_label if db_ctx else _DEFAULT_ITEM_PRICE_LABEL,
+ # 회사 용어 사전 — 스크립트의 {label_*} 토큰(협력사·목표가·배송형태 등) 치환용.
+ "labels": (db_ctx.labels if db_ctx else {}),
# 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용.
# 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지).
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0,
@@ -123,6 +129,9 @@ class ChatService:
"db_context_loaded": db_ctx is not None,
"selected_nego_card_numbers": selected_nego_cards,
"selected_wild_card_numbers": selected_wild_cards,
+ # 카드번호 → 전술 {offer_variable, min_round, closing}. 시작 시 1회 박제(loader) —
+ # 이후 카드 멘트가 바뀌어도 이 협상은 시작 시점 전술로 끝까지 간다.
+ "card_specs": (db_ctx.card_specs if db_ctx else {}),
"allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards),
},
)
@@ -290,14 +299,17 @@ class ChatService:
decision = policy.select(ctx)
session.used_action_ids.add(decision.action_id)
card_id = self._card_id_for_action(engine, session, decision.action_id)
- # 전술 실행(재설계): 카드의 가격 행동 — 카운터 제시가를 계산해 세션에 적재한다.
+ # 카드번호 공용 이력 — 와일드/종결 경로와 같은 목록을 본다("한 협상 한 카드 1회" 단일 판정).
+ mark_played(session.context, card_id)
+ # 전술 실행: 카드가 제시할 금액(스크립트 파싱 결과)을 계산해 세션에 적재한다.
# pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고,
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
- spec = tactic_for(card_id)
- counter = compute_counter(spec, session.context) if tactic_available(spec, session.context) else None
+ # 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
+ spec = spec_from_context(session.context, card_id)
+ counter = compute_offer(spec, session.context) if available(spec, session.context) else None
if counter is not None:
session.context["pending_counter_price"] = counter
- session.context["prev_customer_price"] = counter # 갑의 최신 포지션(middle_price 기준)
+ session.context["prev_customer_price"] = counter # 갑의 최신 포지션(절충가 계산 기준)
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
@@ -352,18 +364,26 @@ class ChatService:
규칙층의 강제 결정이므로 RL 선택/학습을 우회한다."""
ctx = session.context
ctx["closing_played"] = True
- # 선택 와일드카드 중 종결 전술 (WC-05/WC-03) — 순서대로 첫 매치.
- closing_number = next(
- (str(n) for n in (ctx.get("selected_wild_card_numbers") or []) if tactic_for(str(n)).closing),
- None,
- )
- counter = compute_counter(tactic_for(closing_number), ctx) if closing_number else None
+ # 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
+ # 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
+ closing_number, counter = None, None
+ for n in (ctx.get("selected_wild_card_numbers") or []):
+ n = str(n)
+ spec = spec_from_context(ctx, n)
+ if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
+ continue
+ offer = compute_offer(spec, ctx)
+ if offer is not None:
+ closing_number, counter = n, offer
+ break
if counter is None:
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
+ closing_number = None
target = int(ctx.get("target_price") or 0)
counter = target if 0 < target < ctx.get("input_price", 0) else None
if counter is None:
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
+ mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
ctx["pending_counter_price"] = counter
ctx["prev_customer_price"] = counter
@@ -441,10 +461,12 @@ class ChatService:
@staticmethod
def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
- """전술 발동조건(min_round·가격구간)을 만족하는 action 만 True. HOLD(설득)는 항상 True.
+ """지금 플레이 가능한 action 만 True. HOLD(설득)는 발동조건만, 금액 카드는 제안가 유효까지
+ 본다(playable) — 무효 금액(역행·목표가 초과 등)이 멘트 글자로 나가는 것 자체를 막는다.
전부 True 면 None(마스크 불필요)."""
+ ctx = session.context
mask = np.array(
- [tactic_available(tactic_for(engine.mapper.get_card_id(a)), session.context)
+ [playable(spec_from_context(ctx, engine.mapper.get_card_id(a)), ctx)
for a in range(engine.action_space_size)],
dtype=bool,
)
diff --git a/agent/tenants/_base/resources/scripts_cards.json b/agent/tenants/_base/resources/scripts_cards.json
index acdc323..a631035 100644
--- a/agent/tenants/_base/resources/scripts_cards.json
+++ b/agent/tenants/_base/resources/scripts_cards.json
@@ -1,12 +1,12 @@
{
- "_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
+ "_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
- "1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
- "2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
+ "1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
+ "2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
- "4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
+ "4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
- "7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.",
+ "7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
}
diff --git a/agent/tenants/_base/resources/scripts_renegotiation.json b/agent/tenants/_base/resources/scripts_renegotiation.json
index 17c741b..e5715de 100644
--- a/agent/tenants/_base/resources/scripts_renegotiation.json
+++ b/agent/tenants/_base/resources/scripts_renegotiation.json
@@ -12,7 +12,7 @@
"chat_end": false
},
"서비스안내": {
- "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
+ "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내",
"next_input_mode": "confirm",
"input_options": [
@@ -25,7 +25,7 @@
"chat_end": false
},
"담당자확인": {
- "script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
+ "script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인",
"next_input_mode": "yes_no",
"input_options": [
@@ -55,7 +55,7 @@
"chat_end": false
},
"정보변경_완료": {
- "script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
+ "script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
"editor_script_id": "정보변경_완료",
"next_input_mode": "null",
"input_options": [],
diff --git a/agent/tenants/_base/resources/scripts_requote.json b/agent/tenants/_base/resources/scripts_requote.json
index 678e1b7..fcbd3e2 100644
--- a/agent/tenants/_base/resources/scripts_requote.json
+++ b/agent/tenants/_base/resources/scripts_requote.json
@@ -10,7 +10,7 @@
"chat_end": false
},
"서비스안내": {
- "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
+ "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내",
"next_input_mode": "confirm",
"input_options": ["확인"],
@@ -19,7 +19,7 @@
"chat_end": false
},
"담당자확인": {
- "script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
+ "script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
@@ -37,7 +37,7 @@
"chat_end": false
},
"정보변경_완료": {
- "script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
+ "script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
"editor_script_id": "정보변경_완료",
"next_input_mode": "null",
"input_options": [],
@@ -46,7 +46,7 @@
"chat_end": true
},
"협상품목안내": {
- "script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
+ "script": "{company_name}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
"editor_script_id": "협상품목안내",
"next_input_mode": "confirm",
"input_options": ["네, 알겠습니다."],
@@ -73,10 +73,10 @@
"chat_end": false
},
"배송형태선택": {
- "script": "배송 형태를 선택해 주세요.",
+ "script": "{label_delivery_type_를} 선택해 주세요.",
"editor_script_id": "배송형태선택",
"next_input_mode": "delivery_type",
- "input_options": ["협력사배송", "지정택배배송", "픽업배송"],
+ "input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
"next_step": { "default": "가격협상_입력" },
"type": "text",
"chat_end": false
diff --git a/agent/tenants/_base/resources/scripts_wildcard.json b/agent/tenants/_base/resources/scripts_wildcard.json
index 4b5c6ea..24f7bfb 100644
--- a/agent/tenants/_base/resources/scripts_wildcard.json
+++ b/agent/tenants/_base/resources/scripts_wildcard.json
@@ -15,7 +15,7 @@
"editor_script_id": "wild_card_1pct"
},
"wild_card_budget": {
- "script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
+ "script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
"type": "text",
"chat_end": false,
"next_input_mode": "price",
diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py
index 9de37c8..cdb729d 100644
--- a/agent/tests/test_context_loader.py
+++ b/agent/tests/test_context_loader.py
@@ -189,8 +189,9 @@ async def test_loader_with_crud_double(db_engine):
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
- async def get_item_price(self, cdb, item_id):
- return ErrorType.SUCCESS, 7000
+ async def get_item_baseline(self, cdb, item_id):
+ # 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
+ return ErrorType.SUCCESS, (7000, "매입가", {"supplier": "공급업체"})
async def get_item_lowest_price(self, cdb, item_id):
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
@@ -217,7 +218,12 @@ async def test_loader_with_crud_double(db_engine):
return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
async def get_quotation_card_numbers(self, cdb, quotation_id):
- return ErrorType.SUCCESS, (["NGC-003", "NGC-008"], ["WC-02"]) # 견적 선택 카드
+ # 행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 card_specs 를 만든다
+ return ErrorType.SUCCESS, (
+ [("NGC-003", "설득 멘트(가격 변수 없음)", None),
+ ("NGC-008", "시장가 {internet_lowest_price}원 인용(읽기 전용 변수)", None)],
+ [("WC-02", "이에 당사는 {target_mid_price}원을 역으로 제안 드립니다.", None)],
+ )
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
assert ctx is not None
@@ -225,6 +231,8 @@ 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.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
+ assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
assert ctx.partner_name == "테스트협력사"
@@ -234,6 +242,14 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.partner_type is PartnerType.NONE
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
assert ctx.selected_wild_card_numbers == ["WC-02"]
+ # 카드 전술 확정 — 설득 카드/읽기 전용 변수는 제안가 없음, WC-02 는 스크립트 파싱으로 중간가.
+ assert ctx.card_specs["NGC-003"]["offer_variable"] is None
+ assert ctx.card_specs["NGC-008"]["offer_variable"] is None # 인터넷 최저가는 읽어주기 변수 — 제안가 아님
+ # 시장가 인용 카드는 최저가 결측 세션에서 미발동하도록 requires 로 표시된다(토큰 노출 방지).
+ assert ctx.card_specs["NGC-008"]["requires"] == ["internet_lowest_price"]
+ assert ctx.card_specs["WC-02"] == {
+ "offer_variable": "target_mid_price", "min_round": 1, "closing": False, "requires": [],
+ }
@pytest.mark.asyncio
diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py
index f56476a..a4d3d21 100644
--- a/backend/router/v1/auth/protocol.py
+++ b/backend/router/v1/auth/protocol.py
@@ -50,6 +50,7 @@ class Res_Me(Res_WebPacketProtocol):
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
+ guide_notices: list = Field(default_factory=list, description="협상 유의사항 항목(companies.settings.guide_notices). 빈 값이면 포털 기본 문구")
class Res_Logout(Res_WebPacketProtocol):
@@ -72,3 +73,4 @@ class Res_SessionBranding(Res_WebPacketProtocol):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)")
+ helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")
diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py
index 3233e33..4862af8 100644
--- a/backend/services/auth_service.py
+++ b/backend/services/auth_service.py
@@ -260,6 +260,7 @@ class AuthService:
settings = settings or {}
res.branding = settings.get("branding") or {}
res.session_fields = settings.get("session_fields") or []
+ res.guide_notices = settings.get("guide_notices") or []
return res
async def session_branding(self, session_id: str) -> Res_SessionBranding:
@@ -279,6 +280,7 @@ class AuthService:
res.service_name = branding.get("service_name") or ""
res.logo_url = branding.get("logo_url") or ""
res.primary_color = branding.get("primary_color") or ""
+ res.helpdesk = branding.get("helpdesk") or []
return res
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py
index 0b30355..2f3f106 100644
--- a/backend/services/chat_service.py
+++ b/backend/services/chat_service.py
@@ -266,6 +266,16 @@ class ChatService:
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
if "vat_yn" in _hidden:
res.item_vat_yn = None
+
+ # 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
+ # agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
+ _features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
+ _baseline = _features.get("nego_baseline_field")
+ if _baseline not in ("price", "purchase_price"):
+ # 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.
+ _baseline = "purchase_price" if ("price" in _hidden and "purchase_price" not in _hidden) else "price"
+ if _baseline == "purchase_price":
+ res.item_price = item.purchase_price or 0
return res
async def _ensure_in_progress(self, sess, quote) -> None:
@@ -452,24 +462,40 @@ class ChatService:
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
- # 카드 번호(turn.card_id) → UUID 변환. step 으로 nego/wild 갈라 각 테이블 조회(번호가 겹칠 수 있어 종류로 구분).
+ # 카드 번호(turn.card_id) → UUID 변환. 번호 정본 표기(NGC-/WC- prefix)로 종류를 가르고,
+ # prefix 없는 구번호는 step 휴리스틱 폴백. 1차 조회가 비면 반대 테이블 재조회 —
+ # 종결 전술의 와일드카드는 step 이 '가격협상_카운터'(wild 미시작)라 step 만으론 카드가
+ # 영영 null 로 남았다(사용 카드 통계·화면 누락 원인).
# 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공)
card_uuid = None
card_type = None
if turn.card_id:
- is_wild = bool(turn.step and turn.step.startswith("wild"))
- if is_wild:
- card_uuid = await DB_SESSION_MNG.execute_lambda(
- chats.DBType(), DBWRType.DB_READ.value,
- lambda s: self.chat_crud.get_wild_card_id_by_number(s, str(turn.card_id)),
- )
- card_type = 2
+ number = str(turn.card_id)
+ if number.startswith("WC"):
+ wild_first = True
+ elif number.startswith("NGC"):
+ wild_first = False
else:
- card_uuid = await DB_SESSION_MNG.execute_lambda(
+ wild_first = bool(turn.step and turn.step.startswith("wild"))
+
+ async def _lookup(wild: bool):
+ if wild:
+ found = await DB_SESSION_MNG.execute_lambda(
+ chats.DBType(), DBWRType.DB_READ.value,
+ lambda s: self.chat_crud.get_wild_card_id_by_number(s, number),
+ )
+ return found, 2
+ found = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
- lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
+ lambda s: self.chat_crud.get_nego_card_id_by_number(s, number),
)
- card_type = 1
+ return found, 1
+
+ card_uuid, card_type = await _lookup(wild_first)
+ if card_uuid is None:
+ card_uuid, card_type = await _lookup(not wild_first)
+ if card_uuid is None:
+ card_type = None
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type)
@@ -610,7 +636,17 @@ class ChatService:
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
- item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
+ # 회사가 배송유형 보기를 자기 용어로 바꿨으면(settings.labels['delivery_type.N']) 그 단어를 쓴다 —
+ # 협상 중 공급사가 고른 보기와 요약 표기가 갈리지 않도록.
+ item_delivery_label = ""
+ if item and item.delivery_type is not None:
+ _e2, _settings = await DB_SESSION_MNG.execute_lambda(
+ suppliers.DBType(), DBWRType.DB_READ.value,
+ lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
+ )
+ _labels = (_settings.get("labels") or {}) if _e2 == ErrorType.SUCCESS and _settings else {}
+ item_delivery_label = (_labels.get(f"delivery_type.{item.delivery_type}")
+ or DeliveryType.label_of(item.delivery_type))
def _iso(dt):
if dt is None:
diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts
index b77cf17..571dce4 100644
--- a/frontend/src/apis/auth/auth.type.ts
+++ b/frontend/src/apis/auth/auth.type.ts
@@ -59,6 +59,7 @@ export interface Branding {
logo_url?: string
primary_color?: string
email_header?: string
+ helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
}
// 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요
@@ -67,6 +68,7 @@ export interface SessionBrandingResponse {
service_name: string
logo_url: string
primary_color: string
+ helpdesk?: string[]
}
// 협상완료 부가정보 필드 정의(companies.settings.session_fields)
@@ -87,6 +89,7 @@ export interface MeResponse {
role: number
branding?: Branding
session_fields?: SessionField[]
+ guide_notices?: string[]
}
// --- 로그아웃 -------------------------------------------------------------
@@ -121,6 +124,8 @@ export interface AuthUser {
role: number
branding: Branding
sessionFields: SessionField[]
+ /** 협상 유의사항 항목(회사 설정). 비면 포털 기본 문구를 쓴다 */
+ guideNotices: string[]
}
export function toAuthUser(res: MeResponse): AuthUser {
@@ -133,5 +138,6 @@ export function toAuthUser(res: MeResponse): AuthUser {
role: res.role,
branding: res.branding ?? {},
sessionFields: res.session_fields ?? [],
+ guideNotices: res.guide_notices ?? [],
}
}
diff --git a/frontend/src/features/auth/hooks/usePreLoginBranding.ts b/frontend/src/features/auth/hooks/usePreLoginBranding.ts
index 01b80d6..cc37cf9 100644
--- a/frontend/src/features/auth/hooks/usePreLoginBranding.ts
+++ b/frontend/src/features/auth/hooks/usePreLoginBranding.ts
@@ -44,8 +44,9 @@ export function usePreLoginBranding(): Branding | null {
service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined,
primary_color: res.primary_color || undefined,
+ helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
}
- if (!next.service_name && !next.logo_url) return
+ if (!next.service_name && !next.logo_url && !next.helpdesk) return
setBranding(next)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
})
diff --git a/frontend/src/features/chat/components/menu/Contact.tsx b/frontend/src/features/chat/components/menu/Contact.tsx
index 7b2f38a..6108356 100644
--- a/frontend/src/features/chat/components/menu/Contact.tsx
+++ b/frontend/src/features/chat/components/menu/Contact.tsx
@@ -1,13 +1,21 @@
+import { useMeQuery } from '@/apis'
+
// 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
+// 연락처는 회사 설정(companies.settings.branding.helpdesk)에서 온다. 미등록이면 섹션 자체를 숨긴다.
export function Contact() {
+ const { data: user } = useMeQuery()
+ const helpdesk = user?.branding?.helpdesk ?? []
+ if (helpdesk.length === 0) return null
+
return (
- 문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr -
+ {(branding?.helpdesk ?? []).map((line) => ( ++ 문의: {line} +
+ ))}© {new Date().getFullYear()} negotium · Made by AI O2O
diff --git a/negodata/backend/common/nego_baseline.py b/negodata/backend/common/nego_baseline.py new file mode 100644 index 0000000..3c35ece --- /dev/null +++ b/negodata/backend/common/nego_baseline.py @@ -0,0 +1,38 @@ +"""협상 기준가 판정 — 회사 설정에서 '이 회사가 관리하는 지불 단가' 컬럼을 고른다. + +이 값은 협상 인하율 멘트의 분모이자 RL 가격 수용률의 기준가이고, 인터넷 최저가 검색의 +가격 힌트로도 나간다. 회사마다 다르다 — 매입해서 되파는 곳은 공급가(items.price)가, +매입만 하는 곳은 매입가(items.purchase_price)가 실제 지불 단가다. + +같은 규칙을 agent(negotiation/chat/infra/repository/nego_context_crud.py `_resolve_baseline`)와 +루트 backend(services/chat_service.py)도 쓴다. 세 곳이 어긋나면 화면값과 협상 멘트가 갈리므로 +판정식을 바꿀 때는 반드시 같이 고친다. +""" + +from typing import Optional + +NEGO_BASELINE_FIELDS = ("price", "purchase_price") +DEFAULT_NEGO_BASELINE_FIELD = "price" + + +def resolve_baseline_field(settings: Optional[dict]) -> str: + """회사 설정 → 협상 기준가로 쓸 items 컬럼명. + + 1순위는 관리자가 설정 화면에서 고른 값(features.nego_baseline_field). + 미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면(hidden_fields) 그 회사는 + 공급가를 관리하지 않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 + 만들어진 회사를 위한 안전망. + """ + settings = settings or {} + chosen = (settings.get("features") or {}).get("nego_baseline_field") + if chosen in NEGO_BASELINE_FIELDS: + return chosen + hidden = set(settings.get("hidden_fields") or []) + if "price" in hidden and "purchase_price" not in hidden: + return "purchase_price" + return DEFAULT_NEGO_BASELINE_FIELD + + +def resolve_baseline_price(item, settings: Optional[dict]) -> int: + """상품의 협상 기준가(원). 값이 없으면 0.""" + return int(getattr(item, resolve_baseline_field(settings), None) or 0) diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index 9553cd7..4559ffd 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -20,13 +20,16 @@ import asyncio import uuid from collections import Counter from datetime import timedelta, timezone +from typing import Optional import httpx +from sqlalchemy import select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import item_internet_lowest_prices +from common.database.model.models import companies, item_internet_lowest_prices from common.enums import DBType, DBWRType, ErrorType, LowestPriceWebsite from common.logger import LOG +from common.nego_baseline import resolve_baseline_price from config.server_configs import web_server_config from crud.lps_sync_crud import ILpsSyncCRUD, LpsSyncCRUD @@ -56,14 +59,36 @@ class LpsSyncService: """LPS 연동 활성 여부 — lps_db 가 등록된 환경에서만 배치가 돈다.""" return DB_SESSION_MNG.is_registered(DBType.LPS.value) + async def _company_settings(self, company_id) -> dict: + """상품이 속한 회사의 settings(JSONB). 조회 실패/미설정이면 빈 dict.""" + if not company_id: + return {} + + async def _q(s): + query = select(companies.settings).where( + companies.company_id == company_id, companies.deleted == False, # noqa: E712 + ).limit(1) + return await DB_SESSION_MNG.execute(s, query, "lps company settings failed.", raise_error=False) + + err_type, rows = await DB_SESSION_MNG.execute_lambda(companies.DBType(), DBWRType.DB_READ.value, _q) + if err_type != ErrorType.SUCCESS or not rows: + return {} + return rows[0] if isinstance(rows[0], dict) else {} + # ---- 단건 즉시 요청 (lowest-price 트리거 API 용) --------------------- - async def request_search_for_item(self, item, force: bool = False) -> tuple: + async def request_search_for_item(self, item, force: bool = False, settings: Optional[dict] = None) -> tuple: """상품 1건을 즉시 LPS 에 검색 요청(수동 트리거 — job_type=manual, 배치보다 높은 우선순위). force=True 면 LPS 의 네거티브 캐시(24h not_found)를 무시하고 실제로 재검색한다 (사용자가 '다시 검색'을 누른 경우. 상품명·모델을 고쳐 재시도하는 흐름에 필요). + settings 는 회사 설정(companies.settings) — 검색 힌트로 보낼 가격 컬럼을 여기서 정한다. 반환: (status, message) — queued | duplicated | unavailable.""" if not self.available(): return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)" + # 가격 힌트는 이 회사가 관리하는 지불 단가로 보낸다 — 협상 기준가와 같은 규칙. + # 호출부가 안 넘기면 상품의 소속 회사 설정을 직접 읽는다(실패해도 검색은 진행). + if settings is None: + settings = await self._company_settings(item.company_id) + baseline = resolve_baseline_price(item, settings) payload = { "product_code": str(item.item_id), "product_name": item.name, @@ -71,7 +96,7 @@ class LpsSyncService: "model": item.model_name or "", "specification": item.spec or "", "company": item.manufacturer or "", - "price": str(item.price) if item.price else "", + "price": str(baseline) if baseline else "", "force": force, } base = web_server_config.lps_base_url.rstrip("/") diff --git a/negodata/docs/imk-0803-requests.md b/negodata/docs/imk-0803-requests.md new file mode 100644 index 0000000..603fe23 --- /dev/null +++ b/negodata/docs/imk-0803-requests.md @@ -0,0 +1,111 @@ +# IMK 0803 가격협상 요청 정리 + +원본: `0803_가격협상 우선 적용 및 논의 정리.xlsx` (12건). 시트의 `우선/논의` 구분 대신 +**스펙이 확정돼 바로 착수 가능한 것 / 결정이 있어야 착수 가능한 것**으로 다시 갈랐다. +시트와 다른 3건 — ⑪은 수정으로, ③은 논의로, ④는 코드가 아니라 회사설정으로. + +--- + +## 수정 (스펙 확정) + +### ④ 부가세 입력칸 — 코드 문제 아님 +`vat_yn` 은 상품폼(`ProductFormSheet.tsx`)·업로드 양식(`ExcelUploadModal.tsx`) 둘 다 이미 있다. +IMK 회사설정이 `hidden_fields = [made_in, delivery_fee_yn, selling_price, vat_yn]` 라 화면·양식에서 +같이 빠진 것. 설정에서 체크 해제하면 끝. + +### ⑥ 유의사항 문구 / 헬프데스크 +- 유의사항의 VAT·배송비 문구는 `GuideContent.tsx` 에서 상품별 동적 표기로 이미 바뀐 상태 → 요청대로 삭제. +- 헬프데스크 연락처 실사용처 3곳, 전부 placeholder였음: + `frontend/src/features/chat/components/menu/Contact.tsx`, + `frontend/src/features/chat/components/popup/GuideContent.tsx`, + `frontend/src/pages/LoginPage.tsx`. 초청 메일엔 없음. +- **반영**: `companies.settings.branding.helpdesk: string[]` 로 회사별 설정화. 아래 "헬프데스크 배선" 참조. + +### ⑦ 공급가–매입가 일원화 +목표가 후보엔 이미 공급가가 없다(internet·purchase·selling — `pricing.py`). 잔여 2건: +1. 화면의 공급가(`item.price`, IMK 라벨 "공급가") 노출 제거 +2. 신규 견적이 아직 인터넷최저가만 씀 → 매입가 후보 추가 + +⚠️ **`items.price` 를 hidden 으로 감추면 안 된다.** `hidden_fields` 는 목록·등록폼·엑셀양식 3곳을 +동시에 감추므로 신규 상품의 `price` 가 NULL 로 쌓이고, 협상 멘트의 인하율이 통째로 빠진다. +`items.price` 소비처 → `items-price-is-nego-baseline` 메모 참조. + +### ⑫ 시장가 산식 `(상품가+배송비)/1.1` +인터넷 최저가는 몰 판매가(VAT 포함·배송비 별도)로 수집되는데 우리 매입가·공급사 견적가는 VAT 별도라 +축이 어긋난 채 비교 중. 배송비를 더해 실구매 총액을 만들고 1.1로 나눠 VAT를 벗긴다(약 9% 낮아짐). + +미결 2개: +- 배송비를 대부분 모른다(네이버 쇼핑 API 미제공, 실측 39/39 null). 0으로 칠지 / 미상이면 환산 스킵할지 +- 적용 범위 — 카드 멘트 인용값만인지, 목표가 후보(`internet × (1−수수료율)`)에도 거는지. + 후자면 인터넷 기준 목표가가 9% 내려간다. + +### ⑪ 인터넷최저가 ≥ 목표가 → 시장가 카드 차단 (절반 완료) +견적생성 시 선택 게이팅은 반영됨(`useCardGating.tsx`). 잔여 = 협상 진행 중 런타임 차단. + +### ⑤ 상품 일괄등록 유효성 오류 +재현 케이스(어느 필드가 오탐인지) 확보 후 수정. 엑셀 원본을 받는 게 빠름. + +### ② 목표가 초과 낙찰 허용 +상한 그릇은 들어감(`sessions.done_ceiling_price`, 커밋 91b8dc77). +**협상 엔진이 아직 안 읽는다** — `agent/`·루트 `backend/` 어디에도 `done_ceiling` 참조 없음. +타결 판정에 배선하면 EST-202607-973E 케이스 해소. +조건: 기존 단가 > 견적가 > 목표가 > 앵커링가면 타결. 여기서 "기존 단가" = `items.price`. + +--- + +## 논의 (결정 필요) + +### ③ 중간값 로직 +②의 짝. "목표가 초과 제시 안 함" 상한을 푸는 건 맞는데 **대신 무엇을 상한으로 쓸지** — +`done_ceiling_price`(목표가×(1+율))인지 기존 단가인지. +IMK 예시(목표 39,800 / 필요 40,200)는 +1.0% 수준이고 현 기본율은 +5%. + +### ⑧ 세팅 횟수만큼 카드 소진 +목표가에 이미 근접했는데도 남은 카드를 다 태울지. 라운드가 늘면 결렬 위험·시간도 는다. +"최소 사용 횟수 보장" vs "조기 타결 우선" 중 택. + +### ⑨ 앵커 제안가 단조성 +후속 카드 제안가가 앞 카드보다 낮아지는 건(16,980 → 16,810) 카드마다 고정 인하율을 쓰기 때문. +제안가를 카드가 아니라 **라운드에 종속**시켜 단조 상향으로 바꾸는 구조 변경 → 이 중 유일하게 범위가 큼. + +### ⑩ 와일드카드 + 최종제안 중복 +종결 국면은 현재 **필수 관문**이다(`chat_engine.py` `check_iteration_limit`): +1. `closing_played` false → `force_closing` → 종결 전용 와일드카드 또는 폴백 최후통첩(목표가 제시) +2. `closing_played` true → 제시가 ≤ 목표가면 타결, 초과면 결렬 + +스킵하면 마지막 우리 카운터가 안 나가고 공급사 마지막 제시가로 즉시 판정 → **결렬률 상승**. +구현 시 `closing_played = True` 는 반드시 세워야 무한루프를 피한다. + +단 중복 방지는 이미 양쪽에 걸려 있다 — 진입 단계(`chat_engine.py`)도 종결 단계(`chat_service.py`)도 +`is_played` 로 쓴 카드를 건너뛴다. 코드상 같은 카드가 2회 나올 수 없으므로 IMK가 본 것이 +진짜 동일 카드인지 확인이 먼저(dev DB엔 EST-202607-5947 없음 — 운영 데이터). + +### ⑬ SG별 앵커링 +**SG = `items.category`** (IMK 라벨 "SG명"). 현재 SG는 앵커링에도 목표가 산정에도 안 들어간다. + +앵커링 축은 3개 — `(company_id, supplier_type, price_range_index)`: + +| 축 | 값 | +|---|---| +| `company_id` | 회사 | +| `supplier_type` | 1 유통 / 2 제조 / 3 총판 — 개별 협력사가 아니라 **유형** | +| `price_range_index` | 목표가 기준 46개 자릿수 구간 | + +SG 축을 추가하면 셀이 46×3×SG수로 쪼개져 셀당 표본 10건(`SAMPLE_THRESHOLD`)을 못 채우고 +평가가 스킵·이월되어 **학습이 사실상 멈춘다**. 이게 실제 결정 포인트. + +--- + +## 헬프데스크 배선 (⑥ 반영분) + +`companies.settings.branding.helpdesk: string[]` — 한 줄 = 담당자 한 명, 자유 문자열. + +| 경로 | 파일 | +|---|---| +| 편집 UI | `negodata/front/src/features/settings/SettingsView.tsx` (브랜딩 탭) | +| 타입 | `negodata/front/src/features/settings/catalog.ts` | +| 로그인 후 전달 | `backend/services/auth_service.py` `me()` — `branding` dict 통째로 내려 자동 포함 | +| 로그인 전 전달 | `backend/services/auth_service.py` `session_branding()` + `protocol.py` `Res_SessionBranding` | +| 표시 | `frontend/` 의 `Contact.tsx` · `GuideContent.tsx` · `LoginPage.tsx` | + +값이 비면 각 표시부는 연락처 줄을 렌더하지 않는다(placeholder 노출 금지). diff --git a/negodata/docs/nego-baseline-verification.md b/negodata/docs/nego-baseline-verification.md new file mode 100644 index 0000000..d855f36 --- /dev/null +++ b/negodata/docs/nego-baseline-verification.md @@ -0,0 +1,251 @@ +# 협상 기준가 회사별 선택 — 변경 내역과 검증 보고서 + +작성 2026-08-05. 대상 = IMK 0803 요청 ⑥(유의사항·헬프데스크)·⑦(공급가–매입가 일원화). + +핵심은 **협상 기준가**(인하율 멘트의 분모이자 RL 가격 수용률의 기준가)를 회사가 고르게 한 것이다. +매입해서 되파는 회사는 공급가(`items.price`)가, 매입만 하는 회사는 매입가(`items.purchase_price`)가 +실제 지불 단가이므로, 컬럼을 합치는 대신 **어느 컬럼을 쓸지 회사 설정으로 지정**한다. + +--- + +## 1. 판정 규칙 + +``` +1순위 settings.features.nego_baseline_field ('price' | 'purchase_price') +2순위 price 만 hidden_fields 에 있으면 → purchase_price (설정 화면 이전 회사 안전망) +기본 → price +``` + +같은 규칙을 세 앱이 쓴다. **판정식을 바꿀 때는 반드시 세 곳을 같이 고친다.** + +| 앱 | 위치 | +|---|---| +| negodata backend | `common/nego_baseline.py` `resolve_baseline_field` (정본) | +| agent | `negotiation/chat/infra/repository/nego_context_crud.py` `_resolve_baseline` | +| negosium backend | `services/chat_service.py` `chat_init` 내 인라인 판정 | + +--- + +## 2. 회사 유형별 설정 방법 + +어느 가격이 "우리가 공급사에 지불하는 단가"인지는 **회사마다 다르다.** 그래서 컬럼을 합치지 않고 +회사가 고르게 했다. 설정 위치는 전부 **negodata → 회사 설정(`/settings`, 최고관리자 전용)**. + +### 유형 A — 매입만 하는 회사 (사서 쓰고, 되팔지 않음) + +관리하는 가격이 매입가 하나뿐인 회사. + +| 탭 | 설정 | +|---|---| +| 커스텀 필드 → 협상 기준가 | **매입가** 선택 | +| 커스텀 필드 → 상품 필드 숨김 | `상품 단가(공급가)`·`판매가` 체크 | +| 용어(라벨) | 필요하면 `item.purchase_price` 를 자기 용어로(예: 구매단가) | + +결과 — 상품 등록·엑셀 양식에 매입가 칸만 남고, 협상 멘트는 "기존 매입가 대비 N% 인하", +인터넷 최저가 검색 힌트도 매입가로 나간다. 목표가는 인터넷최저가·매입가 후보로 산정된다. + +### 유형 B — 매입해서 되파는 회사 (유통·구매대행) + +공급사에서 사서(매입가) 고객사에 넘기는(공급가) 회사. **기본값이라 아무것도 안 해도 된다.** + +| 탭 | 설정 | +|---|---| +| 커스텀 필드 → 협상 기준가 | **상품 단가** 선택 (미설정 시 기본값) | +| 커스텀 필드 → 상품 필드 숨김 | 안 씀 | +| 용어(라벨) | 필요하면 `item.price` 를 자기 용어로(IMK 는 "공급가") | + +결과 — 공급가가 협상 출발점, 매입가는 재견적 목표가 후보로 계속 쓰인다. + +### 공통 주의 + +- **기준가로 고른 필드는 숨기지 말 것.** 숨기면 신규 상품 등록 화면에 그 칸이 없어 값이 비고, + 인하율 멘트가 통째로 사라진다. 설정 화면이 그 조합을 고르면 경고를 띄운다. +- **인터넷 최저가는 숨길 수 없다.** 신규 견적의 유일한 목표가 후보라 숨김 목록에서 제외했다. +- **협상 이력이 쌓인 뒤에는 바꾸지 말 것.** 기준가는 RL 가격 수용률의 분모라 학습 상태 인덱스에 + 들어간다. 바꾸면 Q테이블에 두 기준이 섞이고 되돌려도 복구되지 않는다. 회사 온보딩 때 정한다. +- **용어를 바꾸면 협상 멘트 호칭도 같이 바뀐다.** 용어 탭의 `item.price`/`item.purchase_price` + 라벨이 그대로 공급사에게 나가는 문장에 쓰인다(조사는 받침에 맞춰 자동 보정). + +### 현재 IMK 설정 + +``` +features.nego_baseline_field = "purchase_price" → 매입가 기준 (유형 A) +hidden_fields = [made_in, delivery_fee_yn, selling_price, vat_yn, price] +labels = { "item.price": "공급가", … } +``` + +--- + +## 3. 수정한 곳 + +### agent (협상 엔진) + +| 파일 | 내용 | +|---|---| +| `negotiation/chat/infra/repository/nego_context_crud.py` | `_ITEMS` 에 `purchase_price`·`company_id` 추가, `_COMPANIES` 테이블 신설. `get_item_price` → **`get_item_baseline`** 로 교체 — items⋈companies 한 쿼리로 `(기준가, 호칭, 회사 용어사전)` 반환. `_resolve_baseline` 판정 함수 | +| `negotiation/chat/service/negotiation_context_loader.py` | `NegotiationDbContext` 에 `item_price_label`·`labels` 추가 (세션 시작 시 박제) | +| `services/chat_service.py` | 세션 컨텍스트에 `item_price_label`·`labels` 적재. 데모 경로 폴백 `_DEFAULT_ITEM_PRICE_LABEL = "상품 단가"` | +| `negotiation/chat/service/chat_engine.py` | `discount_phrase` 3분기의 `"공급가"` 하드코딩 → 컨텍스트 호칭. `_SCRIPT_LABELS` 용어 토큰, `_josa`/`_has_batchim` 조사 자동 보정. **`input_options` 도 변수 치환을 타게 수정**(안 고쳤으면 `{label_delivery_type_1}` 토큰이 사용자에게 노출) | +| `negotiation/chat/service/script_naturalizer.py` | docstring 문구 | +| `tenants/_base/resources/scripts_renegotiation.json` | "협력사 간"·"협력사 포털"·"협력사 관리 시스템" → `{label_supplier}` | +| `tenants/_base/resources/scripts_requote.json` | 위 + "신규 공급사를 선정"·"공급사 선정에 반영"·"배송 형태를 선택"·배송 보기 3개 | +| `tenants/_base/resources/scripts_cards.json` | "목표 매입가" ×2 → `{label_target_price}`, "다른 협력사들의" → `{label_supplier}`. 4번 카드의 `{discount_rate}%` 수치 인용 제거(기준가 없을 때 "인하율 약 0.0%는 의미 있는 진전" 모순 방지) | +| `tenants/_base/resources/scripts_wildcard.json` | "목표 매입가는" → `{label_target_price}` | +| `tests/test_context_loader.py` | 더블·단언을 새 시그니처로 | + +### negosium backend (공급사 포털 API) + +| 파일 | 내용 | +|---|---| +| `services/chat_service.py` | 협상 화면 `item_price` 를 기준가 규칙으로. 요약의 배송형태 라벨을 `DeliveryType.label_of()` 하드코딩 대신 회사 용어 우선 | +| `services/auth_service.py` | `me()`·`session_branding()` 에 헬프데스크·유의사항 전달 | +| `router/v1/auth/protocol.py` | `Res_Me.guide_notices`, `Res_SessionBranding.helpdesk` | + +### negodata backend + +| 파일 | 내용 | +|---|---| +| `common/nego_baseline.py` | **신규.** 기준가 판정 정본 (`resolve_baseline_field` / `resolve_baseline_price`) | +| `services/lps_sync_service.py` | 인터넷 최저가 검색의 가격 힌트를 `items.price` 고정 → 기준가 규칙. 호출부가 설정을 안 넘기면 상품의 소속 회사 설정을 직접 조회 | + +### negodata front (어드민) + +| 파일 | 내용 | +|---|---| +| `features/settings/catalog.ts` | `features.nego_baseline_field` 타입·선택지, `branding.helpdesk`, `guide_notices`, `DEFAULT_GUIDE_NOTICES`. 용어 카탈로그에 `target_price`·`supplier` 추가. **숨김 가능 목록에서 `internet_lowest_price` 제외**(신규 견적의 유일한 목표가 후보) | +| `features/settings/SettingsView.tsx` | **공급사 포털 안내** 탭 신설(협상 유의사항·헬프데스크). 커스텀 필드 탭 최상단에 **협상 기준가** 라디오(`NegoBaselinePicker`) — 선택지마다 실제로 나갈 문장 미리보기 + 학습 데이터 경고. `LineListEditor`. 저장·JSON 병합 경로에 `features`·`guide_notices` 반영 | +| `features/products/components/ProductFormSheet.tsx` | 신규 등록 기본값에서 개발용 더미 제거 — `code: PROD-BAT-###`·`price: 1,000,000`·`minPrice: 800,000`·`origin: 대한민국`·`moq: 10 EA`·`leadTime: 14` → 빈 값/0. 선택형(단위·배송형태·부가세)만 유지 | + +### negosium front (공급사 포털 화면) + +| 파일 | 내용 | +|---|---| +| `apis/auth/auth.type.ts` | `Branding.helpdesk`, `MeResponse.guide_notices`, `AuthUser.guideNotices` | +| `features/auth/hooks/usePreLoginBranding.ts` | 로그인 전 헬프데스크 수신 | +| `features/chat/components/menu/Contact.tsx` | 하드코딩 연락처 → 회사 설정, 미등록이면 섹션 숨김 | +| `features/chat/components/popup/GuideContent.tsx` | 유의사항 불릿 5개 하드코딩 → 회사 설정(미설정 시 기본 문구). **VAT·배송비 불릿 삭제**(IMK ⑥ 요청) | +| `pages/LoginPage.tsx` | 하드코딩 연락처 → 회사 설정 | + +--- + +## 4. 경우의 수 검증 — 12조합 × 3경로 + +상품 = 산업용 베어링 6204 (공급가 8,900 / 매입가 7,200), 공급사 제시가 8,000. +agent·negosium backend·negodata backend 를 **각각 실제로 호출**해 측정. + +| 기준가 설정 | 숨김 | 포털 표시 | LPS 가격 힌트 | 협상 멘트 | +|---|---|---|---|---| +| 미설정 | 없음 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| 미설정 | `price` | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 | +| 미설정 | `purchase_price` | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| 미설정 | 둘 다 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| `=price` | 없음 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| `=price` | `price` | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| `=price` | `purchase_price` | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| `=price` | 둘 다 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 | +| `=purchase` | 없음 | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 | +| `=purchase` | `price` | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 | +| `=purchase` | `purchase_price` | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 | +| `=purchase` | 둘 다 | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 | + +**12/12 세 경로가 같은 값을 쓴다. 어긋나는 조합 없음.** + +- 명시 설정이 항상 이긴다 — `=price` 인데 `price` 를 숨겨도 8,900 +- 숨김 폴백은 `price` 만 숨겼을 때만 발동 +- 둘 다 숨겨도 `price` 로 폴백해 협상이 안 깨진다 + +### 용어·조사 검증 + +| 조건 | 출력 | +|---|---| +| 용어 미설정 + `price` | 기존 **상품 단가** 대비 약 3.2% 인하된 금액입니다 | +| `item.price="공급가"` | 기존 **공급가** 대비 … | +| `item.purchase_price="기준매입단가"` | 기존 **기준매입단가** 대비 약 4.2% 인하된 금액입니다 | +| `item.purchase_price="기준값"`(받침) + 제시가=기준가 | 기존 **기준값과** 동일한 수준의 금액입니다 | +| 기준가 컬럼 NULL | 인하율 문장 **생략**(0원 대비 계산 안 나감) | + +용어를 전부 바꾼 회사(`supplier=공급업체`, `target_price=목표단가`)로 전 흐름 실행: + +``` +"본 서비스는 아이마켓코리아와 공급업체 간 물품 공급 가격 협상을 위한 것으로…" +"본 안내는 공급업체 포털에 등록된 담당자에게 발송되었습니다." +"아이마켓코리아는 아래 상품에 대해 신규 공급업체를 선정하고 있으며…" +배송형태선택 → options: ['직납', 'IMK물류(배송)', 'IMK물류(집배송)'] +``` + +### 목표가 산정 (숨김 축, 기준가 설정과 무관) + +인터넷최저가 8,500 / 매입가 7,200 / 판매가 9,800 · 수수료 7.8% · 네고율 12% + +| 숨김 | 신규 | 재견적 | +|---|---|---| +| 없음 | 7,840 | 6,340 | +| `price` | 7,840 | 6,340 | +| `purchase_price` | 7,840 | **7,840** (가장 싼 후보가 빠짐) | +| 둘 다 | 7,840 | **7,840** | + +`price` 는 원래 목표가 후보가 아니라 숨겨도 무영향. + +### 포털 API 실측 (`GET /v1/negotiation/sessions/{id}/chat/init`) + +`global` 계정 로그인 후 4조합 확인 — 위 표의 포털 열이 그 결과. 회사 용어 12개, 배송형태 라벨(`직납`) 정상 전달. + +### 자동 테스트 + +`agent` 176건 전부 통과. (테스트가 `learning` 스키마를 TRUNCATE 하므로 백업 후 실행·복원) + +--- + +## 5. negodata / negosium 영향 — 문제 있는 곳 + +### negodata (어드민) + +| 화면 | 설정에 따라 달라지는 것 | 문제 | +|---|---|---| +| 회사 설정 | 협상 기준가 라디오·상품 필드 숨김·용어·포털 안내 탭 | 없음 | +| 상품 목록·등록·엑셀 양식 | 숨긴 필드가 세 곳에서 동시에 빠짐 | 없음 | +| 엑셀 업로드 | 숨긴 열은 파일에 있어도 **읽지 않고 무시**(양식 생성과 파싱이 같은 컬럼 정의 공유) | 없음 | +| 견적 생성 | 목표가 후보에서 숨긴 가격 제외. 후보가 없으면 프론트가 생성 차단 | 없음 | +| 인터넷 최저가 검색 | 가격 힌트가 기준가 규칙을 따름 | 없음 | +| 통계 | 목표가·낙찰가·앵커가 기반이라 무관 | 없음 | + +### negosium (공급사 포털·챗) + +| 화면 | 설정에 따라 달라지는 것 | 문제 | +|---|---|---| +| 협상 챗 멘트 | "기존 OO 대비 N% 인하" 의 분모와 호칭 | 없음 | +| 협상 화면 상단 | 기준 단가 표시값 | 없음 | +| 협상 카드 멘트 | 회사 용어(`{label_supplier}`·`{label_target_price}` 등) 치환 | 없음 | +| 배송형태 선택지 | 회사 용어로 치환(IMK: 직납·IMK물류) | 없음 | +| 유의사항 팝업 | 회사 설정 항목, 미설정 시 기본 문구 | 없음 | +| 헬프데스크 | 회사 설정 연락처, 미등록 시 영역 숨김 | 없음 | +| 로그인 화면 | 로그인 전에도 회사 헬프데스크 노출 | 없음 | + +### 안 바뀌는 곳 + +목표가 산정(기준가 설정과 무관) · 앵커링가 · 앵커링 학습(축 = 회사·협력사유형·가격대) · +통계 전부 · 진행 중인 협상(세션 시작 시 기준가를 박제하므로 라운드 중간에 안 바뀜). + +### 유일하게 되돌릴 수 없는 것 + +**RL 가격 수용률** — 학습 상태 인덱스에 들어가므로, 협상 이력이 쌓인 뒤 기준가를 바꾸면 +Q테이블에 두 기준이 섞이고 설정을 되돌려도 복구되지 않는다. 온보딩 때 정한다. + +--- + +## 6. 확인하지 않은 것 + +- **설정 화면 렌더를 눈으로 보지 않았다.** tsc·eslint 통과. 다만 관리자가 그 화면에서 저장한 값이 + DB 에 정상 반영된 것으로 렌더·저장 경로는 실증됐다(`features`·`hidden_fields`·`guide_notices`). +- **negodata backend 테스트 미실행.** 컨테이너에 pytest 미설치이고 `negosium_test_db` 가 없어 dev DB 를 + truncate 할 위험이 있어 돌리지 않았다. 다만 이번에 바꾼 `common/nego_baseline.py`(신규)· + `lps_sync_service.py` 를 호출하는 기존 테스트는 없다(`test_scheduler.py` 가 잡 이름만 확인). +- `global` 계정 비밀번호를 dev DB 에서 `1234` 로 리셋했다. + +### 오해였던 것 (기록) + +- 검증 중 `learning` 스키마 행이 늘어 "테스트 협상이 학습을 오염시켰다"고 봤으나, 실제로는 **pytest 산출물**이었다. + 학습 데이터의 `company_id` 는 테스트마다 만든 랜덤 UUID 42개이고 **IMK 스코프는 0건**이다. +- "IMK 매입가에 원가가 들어 있어 협상 멘트가 전부 인상으로 나온다"고 봤으나, 테스트 제시가를 매입가보다 + 높게 넣어서 생긴 착시였다. 매입가 이하를 제시하면 정상적으로 인하율이 나온다: + `7,000 → 2.8% 인하` · `6,500 → 9.7% 인하` · `8,000 → 11.1% 높은`. diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 181ed34..3742d3e 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -84,21 +84,26 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa sellingPrice: product.selling_price || 0, }; } + // 신규 등록 기본값 — 금액·코드·수량처럼 상품마다 다른 값은 비워 둔다. + // (개발용 더미였던 code=PROD-BAT-###·price=1,000,000·minPrice=800,000·moq='10 EA'·leadTime=14· + // origin='대한민국' 이 그대로 저장돼, 숨긴 필드는 화면에 안 보인 채 임의값이 DB 에 들어갔다. + // price 는 회사 설정에 따라 협상 기준가로 쓰여 인하율 멘트의 분모가 되므로 특히 위험하다.) + // 값을 채워 두는 건 선택형 필드뿐 — 보기 중 하나를 반드시 골라야 하는 항목들이다. return { name: '', - code: `PROD-BAT-${Math.floor(100 + Math.random() * 900)}`, + code: '', category: '', - price: 1000000, - minPrice: 800000, + price: 0, + minPrice: 0, modelName: '', specification: '', manufacturer: '', - origin: '대한민국', + origin: '', unit: 'EA', shippingType: 1, imageUrl: '', - moq: '10 EA', - leadTime: 14, + moq: '', + leadTime: 0, vatYn: true, deliveryFeeYn: false, internetLowestPriceYn: true, diff --git a/negodata/front/src/features/settings/SettingsView.tsx b/negodata/front/src/features/settings/SettingsView.tsx index 99bdefe..fe21c00 100644 --- a/negodata/front/src/features/settings/SettingsView.tsx +++ b/negodata/front/src/features/settings/SettingsView.tsx @@ -1,6 +1,6 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; -import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react'; +import { Palette, Tags, ListPlus, MessageSquareText, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -13,18 +13,24 @@ import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { LABEL_CATALOG, + LABEL_DEFAULTS, HIDEABLE_ITEM_FIELDS, CUSTOM_FIELD_TYPE_LABEL, + DEFAULT_GUIDE_NOTICES, + NEGO_BASELINE_OPTIONS, + DEFAULT_NEGO_BASELINE_FIELD, type CompanySettings, type CustomFieldDef, type CustomFieldType, + type NegoBaselineField, } from './catalog'; import { useCompanySettings } from './useCompanySettings'; -export const SETTINGS_TABS = ['branding', 'labels', 'fields'] as const; +export const SETTINGS_TABS = ['branding', 'portal', 'labels', 'fields'] as const; export type SettingsTab = (typeof SETTINGS_TABS)[number]; export const SETTINGS_TAB_LABEL: Record