[feat] negodata·agent·negosium: 협상 기준가 회사별 선택(공급가/매입가) + 공급사 포털 안내 회사설정화

어느 가격이 "공급사에 지불하는 단가"인지는 회사마다 달라, 컬럼을 합치는 대신 회사가 고르게 했다.
판정은 settings.features.nego_baseline_field 1순위, 미설정 회사는 price 만 숨겼으면 purchase_price 폴백.
판정식 정본은 negodata/backend/common/nego_baseline.py (agent·negosium backend 가 같은 규칙 미러).

- negodata front: 회사 설정에 협상 기준가 라디오(선택지마다 실제 나갈 문장 미리보기 + 학습 경고),
  '공급사 포털 안내' 탭 신설(협상 유의사항·헬프데스크 연락처), 용어 카탈로그에 target_price·supplier 추가.
  인터넷 최저가는 숨김 대상에서 제외(신규 견적의 유일한 목표가 후보).
  상품 등록 기본값에서 개발용 더미 제거(price 1,000,000·PROD-BAT-###·800,000·대한민국·10 EA·14).
- agent: get_item_price → get_item_baseline (기준가·호칭·회사 용어사전을 한 쿼리로),
  "기존 공급가 대비" 하드코딩을 회사 용어로 치환 + 받침 기준 조사 자동 보정,
  협상 스크립트 4종의 협력사·목표가·배송형태 용어를 {label_*} 토큰화, input_options 도 변수 치환 적용.
- negosium: 협상 화면 기준 단가·배송형태 라벨을 회사 설정 기준으로, 유의사항 본문과 헬프데스크 연락처를
  하드코딩에서 회사 설정으로(미등록 시 영역 숨김). 유의사항의 VAT·배송비 문구 삭제(IMK 0803 ⑥).
- negodata backend: LPS 검색 가격 힌트를 items.price 고정에서 기준가 규칙으로.

검증: 기준가 설정 3 × 숨김 4 = 12조합에서 협상 멘트·포털 표시·LPS 힌트가 전부 일치.
agent 테스트 176건 통과. 보고서 negodata/docs/nego-baseline-verification.md
This commit is contained in:
Mina Choi 2026-08-05 11:47:06 +09:00
parent 91b8dc77a0
commit 71ef28110b
25 changed files with 1059 additions and 172 deletions

View File

@ -33,8 +33,32 @@ _QUOTATION_SETTINGS = table(
column("qt_setting_id"), column("card_count"), column("deleted"), column("qt_setting_id"), column("card_count"), column("deleted"),
schema="quotation", schema="quotation",
) )
_ITEMS = table("items", column("item_id"), column("name"), column("price"), _ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
column("internet_lowest_price"), column("deleted"), schema="partner") 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") _SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table( _QUOTATIONS = table(
"quotations", "quotations",
@ -53,12 +77,12 @@ _VERSION_WILD_CARDS = table(
) )
_NEGO_CARDS = table( _NEGO_CARDS = table(
"nego_cards", "nego_cards",
column("nego_card_id"), column("number"), column("deleted"), column("nego_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
schema="card", schema="card",
) )
_WILD_CARDS = table( _WILD_CARDS = table(
"wild_cards", "wild_cards",
column("wild_card_id"), column("number"), column("deleted"), column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
schema="card", schema="card",
) )
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType). # 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
@ -76,8 +100,12 @@ class INegoContextCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
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]]:
"""품목 기준가(items.price). 없으면 0.""" """협상 기준가·그 호칭·회사 용어 사전 (가격, 호칭, labels).
어느 컬럼을 기준가로 쓰는지는 회사 설정(features.nego_baseline_field)이 정한다.
labels 는 companies.settings.labels 원본 — 협상 스크립트의 용어 토큰 치환에 쓴다.
값이 없으면 (0, 호칭, {})."""
pass pass
@abstractmethod @abstractmethod
@ -124,8 +152,9 @@ class INegoContextCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
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]]]:
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록.""" """견적 version_id 에 연결된 (일반카드 행 목록, 와일드카드 행 목록). 없으면 빈 목록.
행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 카드 전술(CardSpec)을 만든다."""
pass pass
@ -146,20 +175,30 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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: try:
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
query = ( 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 .where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
.limit(1) .limit(1)
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False) 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 or not rows[0]: if err_type != ErrorType.SUCCESS or not rows:
return err_type, 0 return err_type, _fallback
return ErrorType.SUCCESS, int(rows[0]) # 컬럼이 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: except Exception as ex:
LOG.e_no_callstack(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]]: async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
try: try:
@ -289,7 +328,7 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def get_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: try:
version_q = ( version_q = (
select(_QUOTATIONS.c.version_id) select(_QUOTATIONS.c.version_id)
@ -304,7 +343,7 @@ class NegoContextCRUD(INegoContextCRUD):
version_id = rows[0] version_id = rows[0]
nego_q = ( nego_q = (
select(_NEGO_CARDS.c.number) select(_NEGO_CARDS.c.number, _NEGO_CARDS.c.script, _NEGO_CARDS.c.tactic)
.select_from( .select_from(
_VERSION_NEGO_CARDS.join( _VERSION_NEGO_CARDS.join(
_NEGO_CARDS, _NEGO_CARDS,
@ -323,7 +362,7 @@ class NegoContextCRUD(INegoContextCRUD):
return n_err, ([], []) return n_err, ([], [])
wild_q = ( wild_q = (
select(_WILD_CARDS.c.number) select(_WILD_CARDS.c.number, _WILD_CARDS.c.script, _WILD_CARDS.c.tactic)
.select_from( .select_from(
_VERSION_WILD_CARDS.join( _VERSION_WILD_CARDS.join(
_WILD_CARDS, _WILD_CARDS,
@ -342,8 +381,8 @@ class NegoContextCRUD(INegoContextCRUD):
return w_err, ([], []) return w_err, ([], [])
return ErrorType.SUCCESS, ( return ErrorType.SUCCESS, (
[str(r) for r in n_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) for r in w_rows if r 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: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)

View File

@ -10,7 +10,7 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional 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 from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds) 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 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 @dataclass
class ChatSession: class ChatSession:
session_id: str session_id: str
@ -169,6 +202,15 @@ class ChatEngine:
and price <= anchor * self.rules.wildcard_entry_ratio) 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": elif cond == "check_is_supplier_type_c":
ok = False # 공급사 유형 미보유 (PoC 단순화) ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하 elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
@ -181,7 +223,19 @@ class ChatEngine:
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결, # ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합. # 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
counter_rounds = max(0, ctx.get("round", 0) - 1) 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: if exhausted:
target = ctx.get("target_price", 0) target = ctx.get("target_price", 0)
if not ctx.get("closing_played"): if not ctx.get("closing_played"):
@ -207,22 +261,36 @@ class ChatEngine:
ctx = session.context ctx = session.context
price = ctx.get("input_price", 0) price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_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: if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
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% 카드까지 억제된다. # 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
ctx["wildcard_used"] = True ctx["wildcard_used"] = True
ctx["offer_1pct"] = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 (멘트 변수) — 10원 반올림(앵커·카운터와 통일) ctx["offer_1pct"] = offer_1pct
ctx["pending_counter_price"] = ctx["offer_1pct"] # 수락 시 이 가격으로 타결 ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
return "wild_card_1pct" return "wild_card_1pct"
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시. # 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.) # (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio: if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
for number in (ctx.get("selected_wild_card_numbers") or []): for number in (ctx.get("selected_wild_card_numbers") or []):
counter = compute_counter(tactic_for(str(number)), ctx) number = str(number)
if counter is not None: 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["wildcard_used"] = True
ctx["pending_counter_price"] = counter ctx["pending_counter_price"] = offer
ctx["active_wild_card_number"] = str(number) ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
ctx["active_wild_card_number"] = number
mark_played(ctx, number)
return "wild_card_dynamic" return "wild_card_dynamic"
return "가격협상" return "가격협상"
@ -240,6 +308,14 @@ class ChatEngine:
if "anchor_price" in ctx: if "anchor_price" in ctx:
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기. # anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["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) 치환. # 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
if ctx.get("partner_name"): if ctx.get("partner_name"):
out["partner_name"] = str(ctx["partner_name"]) out["partner_name"] = str(ctx["partner_name"])
@ -253,7 +329,7 @@ class ChatEngine:
ilp = ctx.get("internet_lowest_price") or 0 ilp = ctx.get("internet_lowest_price") or 0
if ilp > 0: if ilp > 0:
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp) out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의. # 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0 anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0 target = ctx.get("target_price") or 0
if "input_price" in ctx: if "input_price" in ctx:
@ -267,7 +343,7 @@ class ChatEngine:
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2)) out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
if ctx.get("pending_counter_price"): if ctx.get("pending_counter_price"):
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다. # 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(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 이 보이는데 실제로는 # target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로 # 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지). # 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
@ -275,21 +351,23 @@ class ChatEngine:
out["counter_price"] = pending_i out["counter_price"] = pending_i
out["middle_price"] = pending_i out["middle_price"] = pending_i
out["target_mid_price"] = pending_i out["target_mid_price"] = pending_i
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0). # 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은 # 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한 # 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용). # 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
# 기준가 호칭(공급가/매입가/회사 라벨)은 회사 설정에서 온다 — loader 가 박제한 값.
base = ctx.get("item_price") or 0 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: if base > 1 and "input_price" in ctx:
rate = ((base - ctx["input_price"]) / base) * 100 rate = ((base - ctx["input_price"]) / base) * 100
out["discount_rate"] = f"{max(0.0, rate):.1f}" out["discount_rate"] = f"{max(0.0, rate):.1f}"
if rate >= 0.05: if rate >= 0.05:
out["discount_phrase"] = f"기존 공급가 대비 약 **{rate:.1f}%** 인하된 금액입니다. " out["discount_phrase"] = f"기존 {label} 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
elif rate <= -0.05: elif rate <= -0.05:
out["discount_phrase"] = ( out["discount_phrase"] = (
f"기존 공급가(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ") f"기존 {label}(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
else: else:
out["discount_phrase"] = "기존 공급가와 동일한 수준의 금액입니다. " out["discount_phrase"] = f"기존 {_josa(label, '와')} 동일한 수준의 금액입니다. "
else: else:
out["discount_rate"] = "0.0" out["discount_rate"] = "0.0"
out["discount_phrase"] = "" out["discount_phrase"] = ""
@ -326,11 +404,14 @@ class ChatEngine:
elif step_key in _FAILURE_STEPS: elif step_key in _FAILURE_STEPS:
session.context["final_outcome"] = "failure" session.context["final_outcome"] = "failure"
outcome = session.context.get("final_outcome") if chat_end else None outcome = session.context.get("final_outcome") if chat_end else None
# 선택지도 스크립트와 같은 변수 치환을 태운다 — 배송형태 보기가 회사 용어({label_delivery_type_1} 등)라
# 치환을 건너뛰면 사용자에게 토큰 원문이 그대로 보인다.
step_vars = self._vars(session)
return StepView( return StepView(
step=step_key, 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_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")), chat_end=bool(node.get("chat_end")),
client_step=self.step_map.get(step_key, step_key), client_step=self.step_map.get(step_key, step_key),
needs_card_selection=(step_key == "가격협상"), needs_card_selection=(step_key == "가격협상"),
@ -339,9 +420,13 @@ class ChatEngine:
) )
def _error(self, session: ChatSession, msg: str) -> StepView: def _error(self, session: ChatSession, msg: str) -> StepView:
# 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
# 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
node = self.scripts.get(session.step, {}) node = self.scripts.get(session.step, {})
step_vars = self._vars(session)
return StepView( return StepView(
step=session.step, script=node.get("script", ""), step=session.step, 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_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, chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
) )

View File

@ -19,6 +19,7 @@ from typing import Optional
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG 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.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
from negotiation.qtable.domain.model.snapshot import PartnerType from negotiation.qtable.domain.model.snapshot import PartnerType
@ -38,7 +39,9 @@ class NegotiationDbContext:
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별 rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
target_price: int # 목표 매입가(원) — sessions.target_price target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백) anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0 internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
@ -48,6 +51,9 @@ class NegotiationDbContext:
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number) selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number) selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용 card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
# 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
# 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
card_specs: dict
class NegotiationContextLoader: class NegotiationContextLoader:
@ -85,8 +91,9 @@ class NegotiationContextLoader:
# 매핑이 없거나 미지정이면 None → 호출부 기본값. # 매핑이 없거나 미지정이면 None → 호출부 기본값.
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id) _, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시). # 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
_, item_price = await self.crud.get_item_price(s, item_id) # 없으면 0(인하율 멘트 미표시).
_, (item_price, item_price_label, labels) = await self.crud.get_item_baseline(s, item_id)
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환). # 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id) _, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
@ -106,7 +113,19 @@ class NegotiationContextLoader:
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다. # 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다. # 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id) _, 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 → 상한 미적용(선택 카드 수로만 캡). # 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
_, card_count = await self.crud.get_card_count(s, sid) _, card_count = await self.crud.get_card_count(s, sid)
@ -116,6 +135,8 @@ class NegotiationContextLoader:
target_price=target, target_price=target,
anchor_price=anchor, anchor_price=anchor,
item_price=item_price, item_price=item_price,
item_price_label=item_price_label,
labels=labels,
internet_lowest_price=internet_lowest_price, internet_lowest_price=internet_lowest_price,
partner_name=partner_name, partner_name=partner_name,
product_name=product_name, product_name=product_name,
@ -125,6 +146,7 @@ class NegotiationContextLoader:
selected_nego_card_numbers=selected_nego_cards, selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards, selected_wild_card_numbers=selected_wild_cards,
card_count=card_count, card_count=card_count,
card_specs=card_specs,
) )
try: try:

View File

@ -121,7 +121,7 @@ class ScriptNaturalizer:
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]: def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심). """세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 기존 공급가 대비. 가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 협상 기준가 대비.
""" """
out: Dict[str, Any] = {} out: Dict[str, Any] = {}
rnd = context.get("round") or 0 rnd = context.get("round") or 0

View File

@ -13,7 +13,7 @@ from common.enums import DBType, ErrorType
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG from common.logger import LOG
from config.server_configs import agent_config 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 ( from negotiation.chat.service.chat_engine import (
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView, _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_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name}) _DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name}) _DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
_DEFAULT_ITEM_PRICE_LABEL = "상품 단가" # 협상 기준가 호칭 — DB 컨텍스트 없는 데모/직접호출 경로 폴백
# (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
class ChatService: 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), "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), "product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),
"round": 0, "round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용. # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 가격협상_확인 인하율 산출용.
# 호칭은 회사 설정 라벨을 따른다("기존 {label} 대비 …" 멘트).
"item_price": db_ctx.item_price if db_ctx else 0, "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} 치환용. # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용.
# 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지). # 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지).
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0, "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, "db_context_loaded": db_ctx is not None,
"selected_nego_card_numbers": selected_nego_cards, "selected_nego_card_numbers": selected_nego_cards,
"selected_wild_card_numbers": selected_wild_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), "allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards),
}, },
) )
@ -290,14 +299,17 @@ class ChatService:
decision = policy.select(ctx) decision = policy.select(ctx)
session.used_action_ids.add(decision.action_id) session.used_action_ids.add(decision.action_id)
card_id = self._card_id_for_action(engine, session, decision.action_id) card_id = self._card_id_for_action(engine, session, decision.action_id)
# 전술 실행(재설계): 카드의 가격 행동 — 카운터 제시가를 계산해 세션에 적재한다. # 카드번호 공용 이력 — 와일드/종결 경로와 같은 목록을 본다("한 협상 한 카드 1회" 단일 판정).
mark_played(session.context, card_id)
# 전술 실행: 카드가 제시할 금액(스크립트 파싱 결과)을 계산해 세션에 적재한다.
# pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고, # pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고,
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘). # 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
spec = tactic_for(card_id) # 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
counter = compute_counter(spec, session.context) if tactic_available(spec, session.context) else None 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: if counter is not None:
session.context["pending_counter_price"] = counter 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) 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)) 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) await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
@ -352,18 +364,26 @@ class ChatService:
규칙층의 강제 결정이므로 RL 선택/학습을 우회한다.""" 규칙층의 강제 결정이므로 RL 선택/학습을 우회한다."""
ctx = session.context ctx = session.context
ctx["closing_played"] = True ctx["closing_played"] = True
# 선택 와일드카드 중 종결 전술 (WC-05/WC-03) — 순서대로 첫 매치. # 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
closing_number = next( # 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
(str(n) for n in (ctx.get("selected_wild_card_numbers") or []) if tactic_for(str(n)).closing), closing_number, counter = None, None
None, for n in (ctx.get("selected_wild_card_numbers") or []):
) n = str(n)
counter = compute_counter(tactic_for(closing_number), ctx) if closing_number else None 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: if counter is None:
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터). # 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
closing_number = None
target = int(ctx.get("target_price") or 0) target = int(ctx.get("target_price") or 0)
counter = target if 0 < target < ctx.get("input_price", 0) else None counter = target if 0 < target < ctx.get("input_price", 0) else None
if counter is None: if counter is None:
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구) return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
ctx["pending_counter_price"] = counter ctx["pending_counter_price"] = counter
ctx["prev_customer_price"] = counter ctx["prev_customer_price"] = counter
@ -441,10 +461,12 @@ class ChatService:
@staticmethod @staticmethod
def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]: def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
"""전술 발동조건(min_round·가격구간)을 만족하는 action 만 True. HOLD(설득)는 항상 True. """지금 플레이 가능한 action 만 True. HOLD(설득)는 발동조건만, 금액 카드는 제안가 유효까지
본다(playable) — 무효 금액(역행·목표가 초과 등)이 멘트 글자로 나가는 것 자체를 막는다.
전부 True 면 None(마스크 불필요).""" 전부 True 면 None(마스크 불필요)."""
ctx = session.context
mask = np.array( 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)], for a in range(engine.action_space_size)],
dtype=bool, dtype=bool,
) )

View File

@ -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}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?", "0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.", "1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.", "2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.", "3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?", "4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.", "5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.", "6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.", "7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?" "8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
} }

View File

@ -12,7 +12,7 @@
"chat_end": false "chat_end": false
}, },
"서비스안내": { "서비스안내": {
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.", "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내", "editor_script_id": "서비스안내",
"next_input_mode": "confirm", "next_input_mode": "confirm",
"input_options": [ "input_options": [
@ -25,7 +25,7 @@
"chat_end": false "chat_end": false
}, },
"담당자확인": { "담당자확인": {
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.", "script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인", "editor_script_id": "담당자확인",
"next_input_mode": "yes_no", "next_input_mode": "yes_no",
"input_options": [ "input_options": [
@ -55,7 +55,7 @@
"chat_end": false "chat_end": false
}, },
"정보변경_완료": { "정보변경_완료": {
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.", "script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
"editor_script_id": "정보변경_완료", "editor_script_id": "정보변경_완료",
"next_input_mode": "null", "next_input_mode": "null",
"input_options": [], "input_options": [],

View File

@ -10,7 +10,7 @@
"chat_end": false "chat_end": false
}, },
"서비스안내": { "서비스안내": {
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.", "script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내", "editor_script_id": "서비스안내",
"next_input_mode": "confirm", "next_input_mode": "confirm",
"input_options": ["확인"], "input_options": ["확인"],
@ -19,7 +19,7 @@
"chat_end": false "chat_end": false
}, },
"담당자확인": { "담당자확인": {
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.", "script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인", "editor_script_id": "담당자확인",
"next_input_mode": "yes_no", "next_input_mode": "yes_no",
"input_options": ["예", "아니오"], "input_options": ["예", "아니오"],
@ -37,7 +37,7 @@
"chat_end": false "chat_end": false
}, },
"정보변경_완료": { "정보변경_완료": {
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.", "script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
"editor_script_id": "정보변경_완료", "editor_script_id": "정보변경_완료",
"next_input_mode": "null", "next_input_mode": "null",
"input_options": [], "input_options": [],
@ -46,7 +46,7 @@
"chat_end": true "chat_end": true
}, },
"협상품목안내": { "협상품목안내": {
"script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.", "script": "{company_name}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
"editor_script_id": "협상품목안내", "editor_script_id": "협상품목안내",
"next_input_mode": "confirm", "next_input_mode": "confirm",
"input_options": ["네, 알겠습니다."], "input_options": ["네, 알겠습니다."],
@ -73,10 +73,10 @@
"chat_end": false "chat_end": false
}, },
"배송형태선택": { "배송형태선택": {
"script": "배송 형태를 선택해 주세요.", "script": "{label_delivery_type_를} 선택해 주세요.",
"editor_script_id": "배송형태선택", "editor_script_id": "배송형태선택",
"next_input_mode": "delivery_type", "next_input_mode": "delivery_type",
"input_options": ["협력사배송", "지정택배배송", "픽업배송"], "input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
"next_step": { "default": "가격협상_입력" }, "next_step": { "default": "가격협상_입력" },
"type": "text", "type": "text",
"chat_end": false "chat_end": false

View File

@ -15,7 +15,7 @@
"editor_script_id": "wild_card_1pct" "editor_script_id": "wild_card_1pct"
}, },
"wild_card_budget": { "wild_card_budget": {
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.", "script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
"type": "text", "type": "text",
"chat_end": false, "chat_end": false,
"next_input_mode": "price", "next_input_mode": "price",

View File

@ -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)·앵커 미박제 # (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
async def get_item_price(self, cdb, item_id): async def get_item_baseline(self, cdb, item_id):
return ErrorType.SUCCESS, 7000 # 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
return ErrorType.SUCCESS, (7000, "매입가", {"supplier": "공급업체"})
async def get_item_lowest_price(self, cdb, item_id): async def get_item_lowest_price(self, cdb, item_id):
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price) 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 return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
async def get_quotation_card_numbers(self, cdb, quotation_id): 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())) ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
assert ctx is not None 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.target_price == 50000
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.item_price == 7000 assert ctx.item_price == 7000
assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인 assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인 assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
assert ctx.partner_name == "테스트협력사" 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.partner_type is PartnerType.NONE
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"] assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
assert ctx.selected_wild_card_numbers == ["WC-02"] 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 @pytest.mark.asyncio

View File

@ -50,6 +50,7 @@ class Res_Me(Res_WebPacketProtocol):
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)") role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색") branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력") 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): class Res_Logout(Res_WebPacketProtocol):
@ -72,3 +73,4 @@ class Res_SessionBranding(Res_WebPacketProtocol):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값") service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL") logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)") primary_color: str = Field("", description="브랜드 색상(hex)")
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")

View File

@ -260,6 +260,7 @@ class AuthService:
settings = settings or {} settings = settings or {}
res.branding = settings.get("branding") or {} res.branding = settings.get("branding") or {}
res.session_fields = settings.get("session_fields") or [] res.session_fields = settings.get("session_fields") or []
res.guide_notices = settings.get("guide_notices") or []
return res return res
async def session_branding(self, session_id: str) -> Res_SessionBranding: 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.service_name = branding.get("service_name") or ""
res.logo_url = branding.get("logo_url") or "" res.logo_url = branding.get("logo_url") or ""
res.primary_color = branding.get("primary_color") or "" res.primary_color = branding.get("primary_color") or ""
res.helpdesk = branding.get("helpdesk") or []
return res return res
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:

View File

@ -266,6 +266,16 @@ class ChatService:
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else [] _hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
if "vat_yn" in _hidden: if "vat_yn" in _hidden:
res.item_vat_yn = None 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 return res
async def _ensure_in_progress(self, sess, quote) -> None: 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) 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 미제공) # 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공)
card_uuid = None card_uuid = None
card_type = None card_type = None
if turn.card_id: if turn.card_id:
is_wild = bool(turn.step and turn.step.startswith("wild")) number = str(turn.card_id)
if is_wild: if number.startswith("WC"):
card_uuid = await DB_SESSION_MNG.execute_lambda( wild_first = True
chats.DBType(), DBWRType.DB_READ.value, elif number.startswith("NGC"):
lambda s: self.chat_crud.get_wild_card_id_by_number(s, str(turn.card_id)), wild_first = False
)
card_type = 2
else: 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, 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_wild_card_id_by_number(s, number),
) )
card_type = 1 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, number),
)
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+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=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) 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. # 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else 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): def _iso(dt):
if dt is None: if dt is None:

View File

@ -59,6 +59,7 @@ export interface Branding {
logo_url?: string logo_url?: string
primary_color?: string primary_color?: string
email_header?: string email_header?: string
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
} }
// 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요 // 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요
@ -67,6 +68,7 @@ export interface SessionBrandingResponse {
service_name: string service_name: string
logo_url: string logo_url: string
primary_color: string primary_color: string
helpdesk?: string[]
} }
// 협상완료 부가정보 필드 정의(companies.settings.session_fields) // 협상완료 부가정보 필드 정의(companies.settings.session_fields)
@ -87,6 +89,7 @@ export interface MeResponse {
role: number role: number
branding?: Branding branding?: Branding
session_fields?: SessionField[] session_fields?: SessionField[]
guide_notices?: string[]
} }
// --- 로그아웃 ------------------------------------------------------------- // --- 로그아웃 -------------------------------------------------------------
@ -121,6 +124,8 @@ export interface AuthUser {
role: number role: number
branding: Branding branding: Branding
sessionFields: SessionField[] sessionFields: SessionField[]
/** 협상 유의사항 항목(회사 설정). 비면 포털 기본 문구를 쓴다 */
guideNotices: string[]
} }
export function toAuthUser(res: MeResponse): AuthUser { export function toAuthUser(res: MeResponse): AuthUser {
@ -133,5 +138,6 @@ export function toAuthUser(res: MeResponse): AuthUser {
role: res.role, role: res.role,
branding: res.branding ?? {}, branding: res.branding ?? {},
sessionFields: res.session_fields ?? [], sessionFields: res.session_fields ?? [],
guideNotices: res.guide_notices ?? [],
} }
} }

View File

@ -44,8 +44,9 @@ export function usePreLoginBranding(): Branding | null {
service_name: res.service_name || undefined, service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined, logo_url: res.logo_url || undefined,
primary_color: res.primary_color || 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) setBranding(next)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다 writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
}) })

View File

@ -1,13 +1,21 @@
import { useMeQuery } from '@/apis'
// 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화) // 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
// 연락처는 회사 설정(companies.settings.branding.helpdesk)에서 온다. 미등록이면 섹션 자체를 숨긴다.
export function Contact() { export function Contact() {
const { data: user } = useMeQuery()
const helpdesk = user?.branding?.helpdesk ?? []
if (helpdesk.length === 0) return null
return ( return (
<section className="w-full pt-4"> <section className="w-full pt-4">
<div className="border-b border-border pb-2"> <div className="border-b border-border pb-2">
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span> <span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
</div> </div>
<div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60"> <div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60">
<span>010-0000-0000</span> {helpdesk.map((line) => (
<span>o2odev@o2o.kr</span> <span key={line}>{line}</span>
))}
</div> </div>
</section> </section>
) )

View File

@ -1,10 +1,21 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { useMeQuery } from '@/apis'
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문. // 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다. // 항목은 회사 설정(companies.settings.guide_notices)에서 오고, 비어 있으면 아래 기본 문구를 쓴다.
const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep' const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep'
// negodata 회사 설정의 "기본 문구 불러오기" 값과 동일해야 한다
// (원본: negodata/front/src/features/settings/catalog.ts DEFAULT_GUIDE_NOTICES).
// VAT·배송비 조건은 상품마다 달라 기본 문구에서 뺐다 — 필요한 회사가 항목으로 직접 넣는다.
const DEFAULT_NOTICES = [
'협상 개시는 협상 참여 버튼을 클릭하는 순간부터 시작됩니다.',
'부여된 협상 시간에 응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행될 수 있습니다.',
'본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.',
'협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.',
'안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.',
]
function Bullet({ children }: { children: ReactNode }) { function Bullet({ children }: { children: ReactNode }) {
return ( return (
<div className="flex items-start gap-2.5 py-1.5"> <div className="flex items-start gap-2.5 py-1.5">
@ -15,10 +26,8 @@ function Bullet({ children }: { children: ReactNode }) {
} }
export function GuideContent() { export function GuideContent() {
const { item_vat_yn, item_delivery_fee_yn } = useChatInitStore() const { data: user } = useMeQuery()
// init 미로드/값 없음 → 보수적 기본값 (KT-NEGOWIZ 와 동일한 폴백 규칙) const notices = user?.guideNotices?.length ? user.guideNotices : DEFAULT_NOTICES
const vat = item_vat_yn || 'VAT별도'
const deliveryFee = item_delivery_fee_yn || '배송비별도'
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
@ -31,59 +40,30 @@ export function GuideContent() {
</div> </div>
<div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1"> <div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1">
<Bullet> {notices.map((notice) => (
협상 개시는&nbsp; <Bullet key={notice}>{notice}</Bullet>
<span className="font-semibold text-neutral-90"> ))}
Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작
</span>
됩니다.
</Bullet>
<Bullet>
부여된 협상 시간에&nbsp;
<span className="font-semibold text-neutral-90">
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
</span>
될 수 있습니다.
</Bullet>
<Bullet>
협상에 입력되는 모든 가격은&nbsp;
<span className="font-semibold text-negative">
{vat} 및 {deliveryFee}
</span>
&nbsp;기준이며,&nbsp;
<span className="font-semibold text-neutral-90">
할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산
</span>
되어 제시됩니다.
</Bullet>
<Bullet>
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에
대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.
</Bullet>
<Bullet>
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.
</Bullet>
<Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet>
</div> </div>
</div> </div>
) )
} }
// 하단 문의 안내 박스 // 하단 문의 안내 박스 — 연락처는 회사 설정(branding.helpdesk). 미등록이면 박스를 통째로 숨긴다.
export function GuideContactBox() { export function GuideContactBox() {
const { data: user } = useMeQuery()
const helpdesk = user?.branding?.helpdesk ?? []
if (helpdesk.length === 0) return null
return ( return (
<div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4"> <div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4">
<div className={`${bodyStyle} text-center`}> <div className={`${bodyStyle} text-center`}>
기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다. 기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다.
</div> </div>
<div className="break-keep text-center text-sm font-bold text-neutral-90"> {helpdesk.map((line) => (
헬프데스크 010-0000-0000 · o2odev@o2o.kr <div key={line} className="break-keep text-center text-sm font-bold text-neutral-90">
{line}
</div> </div>
))}
</div> </div>
) )
} }

View File

@ -31,11 +31,13 @@ export function LoginPage() {
<LoginForm /> <LoginForm />
</div> </div>
{/* 푸터 — 문의처 + 솔루션/제작사 표기(회사 브랜딩과 무관하게 고정) */} {/* 푸터 — 문의처(회사 설정 branding.helpdesk, 미등록이면 생략) + 솔루션/제작사 표기(고정) */}
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<p className="text-xs font-medium text-neutral-60"> {(branding?.helpdesk ?? []).map((line) => (
문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr <p key={line} className="text-xs font-medium text-neutral-60">
문의: {line}
</p> </p>
))}
<p className="text-[11px] font-medium text-neutral-50"> <p className="text-[11px] font-medium text-neutral-50">
© {new Date().getFullYear()} negotium · Made by AI O2O © {new Date().getFullYear()} negotium · Made by AI O2O
</p> </p>

View File

@ -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)

View File

@ -20,13 +20,16 @@ import asyncio
import uuid import uuid
from collections import Counter from collections import Counter
from datetime import timedelta, timezone from datetime import timedelta, timezone
from typing import Optional
import httpx import httpx
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG 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.enums import DBType, DBWRType, ErrorType, LowestPriceWebsite
from common.logger import LOG from common.logger import LOG
from common.nego_baseline import resolve_baseline_price
from config.server_configs import web_server_config from config.server_configs import web_server_config
from crud.lps_sync_crud import ILpsSyncCRUD, LpsSyncCRUD from crud.lps_sync_crud import ILpsSyncCRUD, LpsSyncCRUD
@ -56,14 +59,36 @@ class LpsSyncService:
"""LPS 연동 활성 여부 — lps_db 가 등록된 환경에서만 배치가 돈다.""" """LPS 연동 활성 여부 — lps_db 가 등록된 환경에서만 배치가 돈다."""
return DB_SESSION_MNG.is_registered(DBType.LPS.value) 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 용) --------------------- # ---- 단건 즉시 요청 (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, 배치보다 높은 우선순위). """상품 1건을 즉시 LPS 에 검색 요청(수동 트리거 — job_type=manual, 배치보다 높은 우선순위).
force=True 면 LPS 의 네거티브 캐시(24h not_found)를 무시하고 실제로 재검색한다 force=True 면 LPS 의 네거티브 캐시(24h not_found)를 무시하고 실제로 재검색한다
(사용자가 '다시 검색'을 누른 경우. 상품명·모델을 고쳐 재시도하는 흐름에 필요). (사용자가 '다시 검색'을 누른 경우. 상품명·모델을 고쳐 재시도하는 흐름에 필요).
settings 는 회사 설정(companies.settings) — 검색 힌트로 보낼 가격 컬럼을 여기서 정한다.
반환: (status, message) — queued | duplicated | unavailable.""" 반환: (status, message) — queued | duplicated | unavailable."""
if not self.available(): if not self.available():
return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)" return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)"
# 가격 힌트는 이 회사가 관리하는 지불 단가로 보낸다 — 협상 기준가와 같은 규칙.
# 호출부가 안 넘기면 상품의 소속 회사 설정을 직접 읽는다(실패해도 검색은 진행).
if settings is None:
settings = await self._company_settings(item.company_id)
baseline = resolve_baseline_price(item, settings)
payload = { payload = {
"product_code": str(item.item_id), "product_code": str(item.item_id),
"product_name": item.name, "product_name": item.name,
@ -71,7 +96,7 @@ class LpsSyncService:
"model": item.model_name or "", "model": item.model_name or "",
"specification": item.spec or "", "specification": item.spec or "",
"company": item.manufacturer or "", "company": item.manufacturer or "",
"price": str(item.price) if item.price else "", "price": str(baseline) if baseline else "",
"force": force, "force": force,
} }
base = web_server_config.lps_base_url.rstrip("/") base = web_server_config.lps_base_url.rstrip("/")

View File

@ -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 노출 금지).

View File

@ -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% 높은`.

View File

@ -84,21 +84,26 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa
sellingPrice: product.selling_price || 0, sellingPrice: product.selling_price || 0,
}; };
} }
// 신규 등록 기본값 — 금액·코드·수량처럼 상품마다 다른 값은 비워 둔다.
// (개발용 더미였던 code=PROD-BAT-###·price=1,000,000·minPrice=800,000·moq='10 EA'·leadTime=14·
// origin='대한민국' 이 그대로 저장돼, 숨긴 필드는 화면에 안 보인 채 임의값이 DB 에 들어갔다.
// price 는 회사 설정에 따라 협상 기준가로 쓰여 인하율 멘트의 분모가 되므로 특히 위험하다.)
// 값을 채워 두는 건 선택형 필드뿐 — 보기 중 하나를 반드시 골라야 하는 항목들이다.
return { return {
name: '', name: '',
code: `PROD-BAT-${Math.floor(100 + Math.random() * 900)}`, code: '',
category: '', category: '',
price: 1000000, price: 0,
minPrice: 800000, minPrice: 0,
modelName: '', modelName: '',
specification: '', specification: '',
manufacturer: '', manufacturer: '',
origin: '대한민국', origin: '',
unit: 'EA', unit: 'EA',
shippingType: 1, shippingType: 1,
imageUrl: '', imageUrl: '',
moq: '10 EA', moq: '',
leadTime: 14, leadTime: 0,
vatYn: true, vatYn: true,
deliveryFeeYn: false, deliveryFeeYn: false,
internetLowestPriceYn: true, internetLowestPriceYn: true,

View File

@ -1,6 +1,6 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router'; 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 { showToast } from '@/lib/notify';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; 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 { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { import {
LABEL_CATALOG, LABEL_CATALOG,
LABEL_DEFAULTS,
HIDEABLE_ITEM_FIELDS, HIDEABLE_ITEM_FIELDS,
CUSTOM_FIELD_TYPE_LABEL, CUSTOM_FIELD_TYPE_LABEL,
DEFAULT_GUIDE_NOTICES,
NEGO_BASELINE_OPTIONS,
DEFAULT_NEGO_BASELINE_FIELD,
type CompanySettings, type CompanySettings,
type CustomFieldDef, type CustomFieldDef,
type CustomFieldType, type CustomFieldType,
type NegoBaselineField,
} from './catalog'; } from './catalog';
import { useCompanySettings } from './useCompanySettings'; 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 type SettingsTab = (typeof SETTINGS_TABS)[number];
export const SETTINGS_TAB_LABEL: Record<SettingsTab, string> = { export const SETTINGS_TAB_LABEL: Record<SettingsTab, string> = {
branding: '브랜딩(CI)', branding: '브랜딩(CI)',
portal: '공급사 포털 안내',
labels: '용어(라벨)', labels: '용어(라벨)',
fields: '커스텀 필드', fields: '커스텀 필드',
}; };
@ -58,14 +64,21 @@ export function SettingsView() {
const handleSave = async () => { const handleSave = async () => {
// 빈 문자열 라벨/브랜딩은 "기본값 사용"이므로 저장 전 제거해 문서를 깨끗하게 유지한다. // 빈 문자열 라벨/브랜딩은 "기본값 사용"이므로 저장 전 제거해 문서를 깨끗하게 유지한다.
const labels = Object.fromEntries(Object.entries(draft.labels ?? {}).filter(([, v]) => v.trim())); const labels = Object.fromEntries(Object.entries(draft.labels ?? {}).filter(([, v]) => v.trim()));
const branding = Object.fromEntries(Object.entries(draft.branding ?? {}).filter(([, v]) => (v ?? '').trim())); // 브랜딩은 문자열 값만 trim 검사한다 — helpdesk 는 문자열 배열이라 빈 줄만 걸러 따로 싣는다.
const { helpdesk: helpdeskDraft, ...brandingText } = draft.branding ?? {};
const helpdesk = (helpdeskDraft ?? []).map((l) => l.trim()).filter(Boolean);
const branding: NonNullable<CompanySettings['branding']> =
Object.fromEntries(Object.entries(brandingText).filter(([, v]) => (v ?? '').trim()));
if (helpdesk.length > 0) branding.helpdesk = helpdesk;
const itemFields = (draft.item_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); const itemFields = (draft.item_fields ?? []).filter((f) => f.key.trim() && f.label.trim());
const supplierFields = (draft.supplier_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); const supplierFields = (draft.supplier_fields ?? []).filter((f) => f.key.trim() && f.label.trim());
const sessionFields = (draft.session_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); const sessionFields = (draft.session_fields ?? []).filter((f) => f.key.trim() && f.label.trim());
const hiddenFields = [...new Set((draft.hidden_fields ?? []).filter((k) => k.trim()))]; const hiddenFields = [...new Set((draft.hidden_fields ?? []).filter((k) => k.trim()))];
const guideNotices = (draft.guide_notices ?? []).map((l) => l.trim()).filter(Boolean);
const next: CompanySettings = { const next: CompanySettings = {
...draft, ...draft,
hidden_fields: hiddenFields, hidden_fields: hiddenFields,
guide_notices: guideNotices,
labels, labels,
branding, branding,
item_fields: itemFields, item_fields: itemFields,
@ -115,8 +128,10 @@ export function SettingsView() {
return res.image_url; return res.image_url;
}; };
const setBranding = (key: keyof NonNullable<CompanySettings['branding']>, value: string) => const setBranding = (key: 'service_name' | 'logo_url' | 'primary_color' | 'email_header', value: string) =>
setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } })); setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } }));
const setHelpdesk = (lines: string[]) =>
setDraft((d) => ({ ...d, branding: { ...d.branding, helpdesk: lines } }));
const setLabel = (key: string, value: string) => const setLabel = (key: string, value: string) =>
setDraft((d) => ({ ...d, labels: { ...d.labels, [key]: value } })); setDraft((d) => ({ ...d, labels: { ...d.labels, [key]: value } }));
@ -128,6 +143,9 @@ export function SettingsView() {
<TabsTrigger value="branding" className="gap-1.5 px-3"> <TabsTrigger value="branding" className="gap-1.5 px-3">
<Palette size={13} /> 브랜딩(CI) <Palette size={13} /> 브랜딩(CI)
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="portal" className="gap-1.5 px-3">
<MessageSquareText size={13} /> 공급사 포털 안내
</TabsTrigger>
<TabsTrigger value="labels" className="gap-1.5 px-3"> <TabsTrigger value="labels" className="gap-1.5 px-3">
<Tags size={13} /> 용어(라벨) <Tags size={13} /> 용어(라벨)
</TabsTrigger> </TabsTrigger>
@ -214,6 +232,37 @@ export function SettingsView() {
</SectionCard> </SectionCard>
</TabsContent> </TabsContent>
{/* ---- 공급사 포털 안내 ---- */}
<TabsContent value="portal" className="space-y-4">
<SectionCard
title="협상 유의사항"
desc="공급사가 협상 화면에서 보는 안내 항목입니다. 한 줄에 한 항목씩 적으십시오. 비워두면 기본 문구가 사용됩니다."
>
<LineListEditor
lines={draft.guide_notices ?? []}
onChange={(lines) => setDraft((d) => ({ ...d, guide_notices: lines }))}
placeholder="협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다."
addLabel="유의사항 추가"
emptyHint="등록된 항목이 없습니다. 공급사 포털에 기본 문구가 표시됩니다."
multiline
onFillDefault={() => setDraft((d) => ({ ...d, guide_notices: [...DEFAULT_GUIDE_NOTICES] }))}
/>
</SectionCard>
<SectionCard
title="헬프데스크 연락처"
desc="공급사 포털의 로그인 화면·메뉴·협상 안내 팝업에 그대로 노출됩니다. 한 줄에 담당자 한 명씩 적으십시오. 비워두면 연락처 영역이 표시되지 않습니다."
>
<LineListEditor
lines={draft.branding?.helpdesk ?? []}
onChange={setHelpdesk}
placeholder="김건우P 02-3708-5832 kw086.kim@imarketkorea.com"
addLabel="연락처 추가"
emptyHint="등록된 연락처가 없습니다. 공급사 포털에 연락처 영역이 표시되지 않습니다."
/>
</SectionCard>
</TabsContent>
{/* ---- 용어(라벨) ---- */} {/* ---- 용어(라벨) ---- */}
<TabsContent value="labels" className="space-y-4"> <TabsContent value="labels" className="space-y-4">
<SectionCard <SectionCard
@ -271,6 +320,20 @@ export function SettingsView() {
{/* ---- 커스텀 필드 ---- */} {/* ---- 커스텀 필드 ---- */}
<TabsContent value="fields" className="space-y-4"> <TabsContent value="fields" className="space-y-4">
<SectionCard
title="협상 기준가"
desc="협상 중 '기존 OO 대비 N% 인하' 를 계산하는 기준 가격입니다. 우리 회사가 공급사에 실제로 지불 중인 단가를 고르십시오."
>
<NegoBaselinePicker
value={draft.features?.nego_baseline_field ?? DEFAULT_NEGO_BASELINE_FIELD}
labels={draft.labels ?? {}}
hiddenFields={draft.hidden_fields ?? []}
onChange={(v) =>
setDraft((d) => ({ ...d, features: { ...d.features, nego_baseline_field: v } }))
}
/>
</SectionCard>
<SectionCard <SectionCard
title="상품 필드 숨김" title="상품 필드 숨김"
desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다." desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다."
@ -346,22 +409,156 @@ function mergeSettings(base: CompanySettings, incoming: CompanySettings): Compan
}; };
const pickFields = (b?: CustomFieldDef[], i?: CustomFieldDef[]) => const pickFields = (b?: CustomFieldDef[], i?: CustomFieldDef[]) =>
Array.isArray(i) && i.length > 0 ? i : (b ?? []); Array.isArray(i) && i.length > 0 ? i : (b ?? []);
// 브랜딩의 helpdesk 만 문자열 배열이라 mergeMap(문자열 전용)을 못 탄다 — 비어있지 않을 때만 통째로 교체.
const mergedBranding = mergeMap(
base.branding as Record<string, string> | undefined,
incoming.branding as Record<string, string> | undefined,
) as NonNullable<CompanySettings['branding']>;
const incomingHelpdesk = incoming.branding?.helpdesk;
const helpdesk = Array.isArray(incomingHelpdesk) && incomingHelpdesk.length > 0
? incomingHelpdesk
: base.branding?.helpdesk;
if (helpdesk && helpdesk.length > 0) mergedBranding.helpdesk = helpdesk;
return { return {
...base, ...base,
labels: mergeMap(base.labels, incoming.labels), labels: mergeMap(base.labels, incoming.labels),
branding: mergeMap( branding: mergedBranding,
base.branding as Record<string, string> | undefined,
incoming.branding as Record<string, string> | undefined,
) as CompanySettings['branding'],
hidden_fields: Array.isArray(incoming.hidden_fields) && incoming.hidden_fields.length > 0 hidden_fields: Array.isArray(incoming.hidden_fields) && incoming.hidden_fields.length > 0
? incoming.hidden_fields ? incoming.hidden_fields
: (base.hidden_fields ?? []), : (base.hidden_fields ?? []),
guide_notices: Array.isArray(incoming.guide_notices) && incoming.guide_notices.length > 0
? incoming.guide_notices
: (base.guide_notices ?? []),
features: { ...base.features, ...incoming.features },
item_fields: pickFields(base.item_fields, incoming.item_fields), item_fields: pickFields(base.item_fields, incoming.item_fields),
supplier_fields: pickFields(base.supplier_fields, incoming.supplier_fields), supplier_fields: pickFields(base.supplier_fields, incoming.supplier_fields),
session_fields: pickFields(base.session_fields, incoming.session_fields), session_fields: pickFields(base.session_fields, incoming.session_fields),
}; };
} }
// 협상 기준가 선택. 고른 값이 인하율 멘트의 분모이자 RL 가격 수용률의 기준가가 되므로,
// 선택지마다 실제로 나갈 문장을 미리 보여준다(용어 탭에서 방금 바꾼 라벨도 즉시 반영).
function NegoBaselinePicker({
value,
labels,
hiddenFields,
onChange,
}: {
value: NegoBaselineField;
labels: Record<string, string>;
hiddenFields: string[];
onChange: (v: NegoBaselineField) => void;
}) {
return (
<div className="space-y-2">
{NEGO_BASELINE_OPTIONS.map((opt) => {
const phrase = labels[opt.labelKey] || LABEL_DEFAULTS[opt.labelKey];
const selected = value === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2.5 p-3 rounded-md border cursor-pointer ${
selected ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<input
type="radio"
name="nego_baseline_field"
className="mt-1"
checked={selected}
onChange={() => onChange(opt.value)}
/>
<span className="min-w-0 space-y-1">
<Typography as="span" variant="small" className="block font-semibold">
{phrase}
</Typography>
<Typography as="span" variant="caption" className="block text-muted-foreground">
{opt.desc}
</Typography>
<Typography as="span" variant="caption" className="block text-muted-foreground">
협상 멘트 — 기존 {phrase} 대비 약 3.2% 인하된 금액입니다.
</Typography>
{selected && hiddenFields.includes(opt.value) && (
<Typography as="span" variant="caption" className="block text-amber-600">
⚠ 이 필드를 아래에서 숨김 처리했습니다. 값을 입력할 화면이 없어 신규 상품은 기준가가 비어
인하율 멘트가 표시되지 않습니다.
</Typography>
)}
</span>
</label>
);
})}
<Typography variant="caption" className="block text-amber-600">
⚠ 이미 협상을 진행한 회사가 이 값을 바꾸면 가격 수용률 기준이 달라져, 기존 학습분과 이후 데이터가
섞입니다. 협상 시작 전에 정하십시오. (진행 중인 협상은 시작 시점 기준을 그대로 유지합니다)
</Typography>
</div>
);
}
// 문자열 배열 설정(유의사항·헬프데스크) 줄 편집. 저장 시 빈 줄은 제거된다.
// multiline 은 문장이 긴 유의사항용 — 연락처처럼 짧은 값은 한 줄 입력으로 둔다.
function LineListEditor({
lines,
onChange,
placeholder,
addLabel,
emptyHint,
multiline,
onFillDefault,
}: {
lines: string[];
onChange: (lines: string[]) => void;
placeholder: string;
addLabel: string;
emptyHint: string;
multiline?: boolean;
onFillDefault?: () => void;
}) {
const replaceAt = (i: number, value: string) => onChange(lines.map((l, j) => (j === i ? value : l)));
return (
<div className="space-y-2">
{lines.map((line, i) => (
<div key={i} className="flex items-start gap-2">
<Typography variant="muted" className="text-[10px] pt-2 w-4 shrink-0 text-right">{i + 1}</Typography>
{multiline ? (
<textarea
value={line}
rows={2}
className="w-full p-2 bg-background border border-border rounded text-xs resize-none"
onChange={(e) => replaceAt(i, e.target.value)}
placeholder={placeholder}
/>
) : (
<Input value={line} onChange={(e) => replaceAt(i, e.target.value)} placeholder={placeholder} />
)}
<Button
variant="ghost"
size="sm"
aria-label={`${i + 1}번 항목 삭제`}
onClick={() => onChange(lines.filter((_, j) => j !== i))}
>
<Trash2 size={13} />
</Button>
</div>
))}
{lines.length === 0 && (
<Typography variant="muted" className="text-[10px] block">{emptyHint}</Typography>
)}
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => onChange([...lines, ''])}>
<Plus size={13} /> {addLabel}
</Button>
{onFillDefault && lines.length === 0 && (
<Button variant="ghost" size="sm" onClick={onFillDefault}>
<RotateCcw size={13} /> 기본 문구 불러오기
</Button>
)}
</div>
</div>
);
}
function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
return ( return (
<div className="bg-card border border-border rounded-lg p-5"> <div className="bg-card border border-border rounded-lg p-5">

View File

@ -3,6 +3,8 @@
export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select'; export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select';
export type NegoBaselineField = 'price' | 'purchase_price';
export type CustomFieldDef = { export type CustomFieldDef = {
key: string; // custom JSONB 의 키 (영문 snake_case) key: string; // custom JSONB 의 키 (영문 snake_case)
label: string; // 화면 표시명 label: string; // 화면 표시명
@ -16,9 +18,16 @@ export type CompanySettings = {
logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트 logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트
primary_color?: string; // 브랜드 색 (hex) primary_color?: string; // 브랜드 색 (hex)
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA) email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
helpdesk?: string[]; // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 공급사 포털 3곳(로그인·메뉴·안내팝업)이 그대로 출력
}; };
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값) labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)
features?: Record<string, unknown>; // 회사별 동작 플래그 (현재 사용처 없음 — 새 플래그 추가 시 여기서 읽는다) features?: {
// 협상 기준가로 쓸 상품 가격 컬럼. 인하율 멘트의 분모이자 RL 가격 수용률의 기준가다.
// 미설정이면 공급가(price) — 단 공급가를 숨긴 회사는 매입가로 폴백(협상 엔진).
nego_baseline_field?: NegoBaselineField;
[key: string]: unknown; // 그 밖의 회사별 동작 플래그
};
guide_notices?: string[]; // 공급사 포털 협상 유의사항 항목 — 한 줄 = 안내 한 항목. 비면 기본 문구
item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom
supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom
session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력) session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)
@ -56,19 +65,15 @@ export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
where: '상품 목록·등록, 엑셀 양식', where: '상품 목록·등록, 엑셀 양식',
calcNote: '재견적·재협상 목표가 후보(그대로)', calcNote: '재견적·재협상 목표가 후보(그대로)',
}, },
{
key: 'internet_lowest_price',
label: '인터넷 최저가(최저한도)',
where: '상품 목록·등록, 엑셀 양식',
calcNote: '신규·재 모두의 목표가 후보(최저가 × (1 − 수수료율)) — 숨기면 신규 견적 산정 근거가 사라진다',
},
{ {
key: 'price', key: 'price',
label: '상품 단가(공급가)', label: '상품 단가(공급가)',
where: '상품 목록·등록, 엑셀 양식', where: '상품 목록·등록, 엑셀 양식',
calcNote: '목표가 산식엔 안 쓰이나 상품 등록 필수값 — 숨기면 신규 등록 시 입력 경로가 사라진다', calcNote: '협상 기준가로 고른 경우 숨기지 말 것 — 인하율 멘트의 기준값이 비게 된다',
}, },
]; ];
// internet_lowest_price 는 숨김 대상에서 뺀다 — 신규 견적의 유일한 목표가 후보라
// 숨기면 신규 견적마다 구매담당자가 목표가를 직접 입력해야만 생성된다.
export type LabelCatalogEntry = { export type LabelCatalogEntry = {
key: string; key: string;
@ -120,6 +125,8 @@ export const LABEL_CATALOG: LabelCatalogEntry[] = [
{ key: 'quotation.due_date', base: '마감기한', where: '견적 목록·생성·상세', group: '견적·협상' }, { key: 'quotation.due_date', base: '마감기한', where: '견적 목록·생성·상세', group: '견적·협상' },
{ key: 'quotation.supplier_count', base: '협력사수', where: '견적 목록', group: '견적·협상' }, { key: 'quotation.supplier_count', base: '협력사수', where: '견적 목록', group: '견적·협상' },
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성', group: '견적·협상' }, { key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성', group: '견적·협상' },
{ key: 'target_price', base: '목표가', where: '견적 상세, 협상 멘트', group: '견적·협상' },
{ key: 'supplier', base: '협력사', where: '협상 멘트(공급사에게 보내는 문장에서 거래상대를 부르는 말)', group: '견적·협상' },
{ key: 'creator', base: '작성자', where: '상품·협력사·견적 목록', group: '견적·협상' }, { key: 'creator', base: '작성자', where: '상품·협력사·견적 목록', group: '견적·협상' },
]; ];
@ -127,6 +134,38 @@ export const LABEL_DEFAULTS: Record<string, string> = Object.fromEntries(
LABEL_CATALOG.map((e) => [e.key, e.base]), LABEL_CATALOG.map((e) => [e.key, e.base]),
); );
// 협상 기준가 선택지. 화면 라벨과 협상 멘트 호칭은 같은 용어(labelKey)를 쓴다 —
// 문장이 어색하면 용어 탭에서 바꾸는 게 맞고, 멘트 전용 사전을 따로 두면 두 값이 갈린다.
export const NEGO_BASELINE_OPTIONS: {
value: NegoBaselineField;
labelKey: string;
desc: string;
}[] = [
{
value: 'price',
labelKey: 'item.price',
desc: '매입해서 되파는 회사. 공급사에 지불 중인 단가가 협상의 출발점입니다.',
},
{
value: 'purchase_price',
labelKey: 'item.purchase_price',
desc: '매입만 하는 회사. 공급가를 따로 관리하지 않고 매입가가 곧 지불 단가입니다.',
},
];
export const DEFAULT_NEGO_BASELINE_FIELD: NegoBaselineField = 'price';
// 공급사 포털 협상 유의사항 기본 문구. 설정이 비어 있을 때 포털이 쓰는 값과 같아야 한다
// (포털 사본: frontend/src/features/chat/components/popup/GuideContent.tsx).
// VAT·배송비 조건은 상품마다 달라 기본 문구에서 뺐다 — 필요한 회사가 항목으로 직접 넣는다.
export const DEFAULT_GUIDE_NOTICES: string[] = [
'협상 개시는 협상 참여 버튼을 클릭하는 순간부터 시작됩니다.',
'부여된 협상 시간에 응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행될 수 있습니다.',
'본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.',
'협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.',
'안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.',
];
export const CUSTOM_FIELD_TYPE_LABEL: Record<CustomFieldType, string> = { export const CUSTOM_FIELD_TYPE_LABEL: Record<CustomFieldType, string> = {
text: '텍스트', text: '텍스트',
number: '숫자', number: '숫자',