[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:
parent
91b8dc77a0
commit
71ef28110b
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": [],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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). 한 줄 = 담당자 한 명")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 가 없어도 이 회사로 보이게 한다
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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) => (
|
||||||
협상 개시는
|
<Bullet key={notice}>{notice}</Bullet>
|
||||||
<span className="font-semibold text-neutral-90">
|
))}
|
||||||
Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작
|
|
||||||
</span>
|
|
||||||
됩니다.
|
|
||||||
</Bullet>
|
|
||||||
|
|
||||||
<Bullet>
|
|
||||||
부여된 협상 시간에
|
|
||||||
<span className="font-semibold text-neutral-90">
|
|
||||||
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
|
|
||||||
</span>
|
|
||||||
될 수 있습니다.
|
|
||||||
</Bullet>
|
|
||||||
|
|
||||||
<Bullet>
|
|
||||||
협상에 입력되는 모든 가격은
|
|
||||||
<span className="font-semibold text-negative">
|
|
||||||
{vat} 및 {deliveryFee}
|
|
||||||
</span>
|
|
||||||
기준이며,
|
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
38
negodata/backend/common/nego_baseline.py
Normal file
38
negodata/backend/common/nego_baseline.py
Normal 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)
|
||||||
@ -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("/")
|
||||||
|
|||||||
111
negodata/docs/imk-0803-requests.md
Normal file
111
negodata/docs/imk-0803-requests.md
Normal 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 노출 금지).
|
||||||
251
negodata/docs/nego-baseline-verification.md
Normal file
251
negodata/docs/nego-baseline-verification.md
Normal 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% 높은`.
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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: '숫자',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user