Merge branch 'feature/negodata'
This commit is contained in:
commit
17a698a7af
@ -1,120 +1,197 @@
|
||||
"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층).
|
||||
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
||||
|
||||
카드 멘트에 이미 설계된 카운터 가격 제시({target_price}·{middle_price} 등)를 시스템 상태로
|
||||
실행한다: 카드가 카운터가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면
|
||||
그 가격으로 즉시 타결된다(기존 wild_card_1pct 의 offer_1pct 패턴을 일반화).
|
||||
카드 멘트가 제시하는 가격({target_price}·{middle_price} 등)을 파싱해 시스템 상태로 실행한다:
|
||||
카드가 제안가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 그 가격으로 타결된다.
|
||||
|
||||
원칙:
|
||||
- 구매자(갑) 대리이므로 카운터는 항상 min(counter, target_price) 클램프 — 목표가 초과 제시 금지.
|
||||
- 협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(HOLD 강등, 순수 설득).
|
||||
- 미등록 카드번호(테넌트 데모 NGC-B*, 회사 커스텀 COMP-* 등)는 HOLD 폴백 → 기존 동작 그대로.
|
||||
세 계층으로 나뉜다.
|
||||
1) 스크립트 파싱 — 이 카드가 부를 금액이 무엇인지 (parse_offer_variable)
|
||||
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
||||
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
||||
|
||||
전술 정본은 이 코드 레지스트리다(v1). negodata 카드 편집은 멘트만 담당하고, 전술을 negodata
|
||||
에서 편집할 필요가 생기면 v2 에서 card.nego_cards 컬럼로 승격해 "DB 우선, 코드 폴백"으로 바꾼다.
|
||||
유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은
|
||||
(직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는
|
||||
카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
# 제안가 변수 — 우리가 새로 부르는 금액. 값은 (계산식, 재료 설명).
|
||||
# 여기 없는 치환 변수({prev_partner_price}·{internet_lowest_price} 등)는 읽어주기 전용이라
|
||||
# 제안가가 되지 않는다 — 과거값·외부값을 협력사에게 "수락하라"고 내밀 수 없기 때문.
|
||||
OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float]]] = {
|
||||
# (target, anchor, price, prev_customer) -> 제안가 | None(재료 없음)
|
||||
"target_price": lambda target, anchor, price, prev: target,
|
||||
"anchoring_price": lambda target, anchor, price, prev: anchor or None,
|
||||
# negodata 카드 에디터 칩 표기(variables.ts) — DB 시드 표기(anchoring_price)와 같은 값의 별칭.
|
||||
"anchor_price": lambda target, anchor, price, prev: anchor or None,
|
||||
"target_mid_price": lambda target, anchor, price, prev: (anchor + target) / 2 if anchor else None,
|
||||
"middle_price": lambda target, anchor, price, prev: (prev + price) / 2 if prev else None,
|
||||
}
|
||||
|
||||
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
||||
|
||||
|
||||
class PriceAction(str, Enum):
|
||||
HOLD = "hold" # 카운터 없음 — 재제안 요구(순수 설득, 기존 동작)
|
||||
COUNTER_TARGET = "counter_target" # 목표가 제시
|
||||
COUNTER_ANCHOR = "counter_anchor" # 앵커가 제시 (예산 상한 프레이밍)
|
||||
COUNTER_TARGET_MID = "counter_target_mid" # (anchor+target)/2 — 시드 {target_mid_price}
|
||||
COUNTER_MID = "counter_mid" # (갑 직전 포지션+협력사 제시가)/2 — 시드 {middle_price}
|
||||
ONE_PCT = "one_pct" # 제시가 1% 인하 (기존 offer_1pct)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TacticSpec:
|
||||
"""카드 1장의 전술 명세.
|
||||
|
||||
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
|
||||
max_price_ratio: input_price ≤ anchor×ratio 일 때만 발동 (None=무제한).
|
||||
closing: 종결 국면(라운드 만료·카드 소진) 우선 전술.
|
||||
"""
|
||||
|
||||
price_action: PriceAction = PriceAction.HOLD
|
||||
min_round: int = 1
|
||||
max_price_ratio: Optional[float] = None
|
||||
closing: bool = False
|
||||
|
||||
|
||||
_DEFAULT = TacticSpec() # HOLD — 미등록 카드 폴백
|
||||
|
||||
# 카드번호 → 전술. 시드(init-data.sql) 멘트의 가격 변수와 1:1 정합.
|
||||
# NGC-001~006: 순수 설득(경쟁 압박/승인 핑계/관계/명분/공정성/TCO) — 가격 변수 없음.
|
||||
# NGC-008: {internet_lowest_price} 인용이나 데이터 소스 미보유 → v1 HOLD (소스 확보 시 승격).
|
||||
_TACTICS: Dict[str, TacticSpec] = {
|
||||
"NGC-007": TacticSpec(PriceAction.COUNTER_ANCHOR), # 예산 상한 안내
|
||||
"NGC-009": TacticSpec(PriceAction.COUNTER_TARGET), # 조건부 가격 조정
|
||||
"NGC-010": TacticSpec(PriceAction.COUNTER_TARGET), # 향후 거래 연계
|
||||
"NGC-011": TacticSpec(PriceAction.COUNTER_TARGET), # 양보 가치 강조
|
||||
"WC-01": TacticSpec(PriceAction.COUNTER_TARGET, min_round=1), # 목표가 선제안
|
||||
"WC-02": TacticSpec(PriceAction.COUNTER_TARGET_MID), # 역제안가 제시
|
||||
"WC-03": TacticSpec(PriceAction.COUNTER_TARGET, closing=True), # 최종 통보(최후통첩)
|
||||
"WC-04": TacticSpec(PriceAction.COUNTER_TARGET, min_round=2), # 단계적 인하 제안
|
||||
"WC-05": TacticSpec(PriceAction.COUNTER_MID, closing=True), # 중간값 절충(종결)
|
||||
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
||||
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
||||
# {internet_lowest_price} 토큰이 원형 노출된다(vars_for 가 미수집이면 키를 안 만드는 것과 짝).
|
||||
# 견적 생성 화면 게이팅(useCardGating)이 1차 방어, 여기가 2차(런타임) 방어다.
|
||||
_CONTEXT_REQUIRED_VARIABLES = {
|
||||
"internet_lowest_price": "internet_lowest_price",
|
||||
"internet_min_price": "internet_lowest_price",
|
||||
}
|
||||
|
||||
|
||||
def tactic_for(card_number: Optional[str]) -> TacticSpec:
|
||||
"""카드번호의 전술. 미등록/None 은 HOLD(기존 동작)."""
|
||||
return _TACTICS.get(str(card_number), _DEFAULT) if card_number else _DEFAULT
|
||||
@dataclass(frozen=True)
|
||||
class CardSpec:
|
||||
"""카드 1장의 전술. 스크립트 파싱 결과 + tactic JSONB 를 합친 값.
|
||||
|
||||
|
||||
def tactic_available(spec: TacticSpec, context: Dict[str, Any]) -> bool:
|
||||
"""발동 조건 평가 — action space 마스킹용. HOLD(설득)는 언제나 가능."""
|
||||
if spec.price_action is PriceAction.HOLD:
|
||||
return True
|
||||
rnd = int(context.get("round") or 0)
|
||||
if rnd < spec.min_round:
|
||||
return False
|
||||
if spec.max_price_ratio is not None:
|
||||
price = float(context.get("input_price") or 0)
|
||||
anchor = float(context.get("anchor_price") or 0)
|
||||
if anchor > 0 and price > anchor * spec.max_price_ratio:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_counter(spec: TacticSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
"""전술의 카운터 제시가 계산 (결정론).
|
||||
|
||||
- 항상 min(counter, target) 클램프 — 구매자는 목표가 초과로 제시하지 않는다.
|
||||
- counter ≥ 협력사 제시가(input_price)면 카운터가 무의미(이미 더 싸게 제시받음) → None.
|
||||
- 필요한 컨텍스트(target/anchor/제시가)가 없으면 None → 호출부가 HOLD 로 강등.
|
||||
offer_variable: 이 카드가 제시할 금액의 변수명. None 이면 순수 설득 카드(HOLD).
|
||||
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
|
||||
closing: 종결 국면 전용 — 라운드 상한·카드 소진 시의 마지막 한 방으로만 쓴다.
|
||||
requires: 스크립트가 인용한 세션-의존 변수의 컨텍스트 키 — 값이 없으면 미발동(토큰 노출 방지).
|
||||
"""
|
||||
action = spec.price_action
|
||||
if action is PriceAction.HOLD:
|
||||
return None
|
||||
|
||||
offer_variable: Optional[str] = None
|
||||
min_round: int = 1
|
||||
closing: bool = False
|
||||
requires: tuple = ()
|
||||
|
||||
|
||||
HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지
|
||||
|
||||
|
||||
def settle_ceiling(context: Dict[str, Any]) -> float:
|
||||
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
|
||||
|
||||
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금
|
||||
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값이다(IMK: 기존 17,500 / 목표 16,980 /
|
||||
최종 17,300 이 결렬되던 케이스). 박제가 없는 옛 세션·데모는 목표가로 폴백 — 종전 동작 유지.
|
||||
"""
|
||||
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
||||
|
||||
|
||||
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||
|
||||
제안가 변수가 여럿이면 **마지막에 등장하는 것**이 제안가다 — 카드 문장은 배경을 먼저 깔고
|
||||
(예: "당초 검토한 적정가는 {anchoring_price}원이었으나") 실제 제안을 마지막에 하기 때문이다
|
||||
(예: "이에 {target_price}원으로 조정하여 제안 드립니다").
|
||||
"""
|
||||
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def build_card_spec(script: Optional[str], tactic: Optional[dict] = None) -> CardSpec:
|
||||
"""스크립트 + tactic JSONB → CardSpec. tactic 이 비어 있으면 전부 기본값."""
|
||||
t = tactic or {}
|
||||
cited = {m.group(1) for m in _TOKEN_RE.finditer(script or "")}
|
||||
return CardSpec(
|
||||
# 파싱이 정본 카드 전부를 맞히므로 offer_variable 은 예외 카드용 override 로만 둔다.
|
||||
offer_variable=t.get("offer_variable") or parse_offer_variable(script),
|
||||
min_round=int(t.get("min_round") or 1),
|
||||
closing=bool(t.get("closing")),
|
||||
requires=tuple(sorted({_CONTEXT_REQUIRED_VARIABLES[v] for v in cited if v in _CONTEXT_REQUIRED_VARIABLES})),
|
||||
)
|
||||
|
||||
|
||||
def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpec:
|
||||
"""세션 컨텍스트에 적재된 카드 스펙(card_specs)에서 꺼낸다. 없으면 HOLD 폴백.
|
||||
|
||||
스펙은 협상 시작 시 1회 적재된다(negotiation_context_loader) — 진행 중인 협상은
|
||||
카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다.
|
||||
"""
|
||||
if not number:
|
||||
return HOLD
|
||||
raw = (context.get("card_specs") or {}).get(str(number))
|
||||
if not raw:
|
||||
return HOLD
|
||||
return CardSpec(
|
||||
offer_variable=raw.get("offer_variable"),
|
||||
min_round=int(raw.get("min_round") or 1),
|
||||
closing=bool(raw.get("closing")),
|
||||
requires=tuple(raw.get("requires") or ()),
|
||||
)
|
||||
|
||||
|
||||
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||
|
||||
변수 공통 유효조건 (전부 만족해야 발동):
|
||||
· 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
|
||||
**미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
|
||||
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
|
||||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||
· 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
|
||||
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||||
"""
|
||||
variable = spec.offer_variable
|
||||
if not variable:
|
||||
return None # 설득 카드 — 제시할 금액 없음
|
||||
calc = OFFER_VARIABLES.get(variable)
|
||||
if calc is None:
|
||||
return None # 미등록 변수(오타·구버전 카드)
|
||||
target = float(context.get("target_price") or 0)
|
||||
anchor = float(context.get("anchor_price") or 0)
|
||||
price = float(context.get("input_price") or 0)
|
||||
# 갑의 직전 포지션. 첫 카운터 전에는 앵커가 갑의 포지션이다.
|
||||
prev_customer = float(context.get("prev_customer_price") or anchor or 0)
|
||||
if target <= 0 or price <= 0:
|
||||
return None
|
||||
return None # 목표가·제시가 없이는 어떤 변수도 판정 불가
|
||||
|
||||
if action is PriceAction.COUNTER_TARGET:
|
||||
counter = target
|
||||
elif action is PriceAction.COUNTER_ANCHOR:
|
||||
counter = anchor
|
||||
elif action is PriceAction.COUNTER_TARGET_MID:
|
||||
counter = (anchor + target) / 2 if anchor > 0 else target
|
||||
elif action is PriceAction.COUNTER_MID:
|
||||
# 갑의 직전 포지션(직전 카운터). 첫 카운터 전에는 앵커가 갑의 포지션이다.
|
||||
prev_customer = float(context.get("prev_customer_price") or anchor or target)
|
||||
counter = (prev_customer + price) / 2
|
||||
elif action is PriceAction.ONE_PCT:
|
||||
counter = price * 0.99
|
||||
else:
|
||||
return None
|
||||
value = calc(target, anchor, price, prev_customer)
|
||||
if not value or value <= 0:
|
||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||
if value > settle_ceiling(context):
|
||||
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
|
||||
if offer >= price:
|
||||
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
||||
if prev_customer and offer < prev_customer:
|
||||
return None # 역행 금지 — 한번 부른 금액 아래로 되돌아가지 않는다(같은 금액 재제시는 허용)
|
||||
return offer
|
||||
|
||||
if counter <= 0:
|
||||
return None
|
||||
counter = min(counter, target) # 목표가 초과 제시 금지 (가드레일)
|
||||
counter_i = int(counter / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일(IMK 요청)
|
||||
if counter_i >= price:
|
||||
return None # 제시가가 이미 카운터 이하 → 카운터 무의미
|
||||
return counter_i
|
||||
|
||||
def available(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
|
||||
"""지금 이 카드를 꺼낼 수 있는지 — 금액과 무관한 조건들.
|
||||
|
||||
· 이미 쓴 카드는 다시 안 나간다(전 카드 공통 규칙 — 협상카드/와일드카드 구분 없음)
|
||||
· 종결 전용 카드는 종결 국면에서만, 종결 국면에선 종결 전용 카드만
|
||||
· min_round 미만이면 아직 이르다
|
||||
· 스크립트가 인용한 세션-의존 변수(인터넷 최저가 등)가 결측이면 미발동 — 토큰 원형 노출 방지
|
||||
"""
|
||||
if spec.closing != closing_phase:
|
||||
return False
|
||||
if int(context.get("round") or 0) < spec.min_round:
|
||||
return False
|
||||
return all(context.get(key) for key in spec.requires)
|
||||
|
||||
|
||||
def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
|
||||
"""이 카드를 지금 실제로 플레이할 수 있는지 — available + (금액 카드는) 제안가 유효까지.
|
||||
|
||||
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||||
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||||
글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관.
|
||||
"""
|
||||
if not available(spec, context, closing_phase=closing_phase):
|
||||
return False
|
||||
if not spec.offer_variable:
|
||||
return True
|
||||
return compute_offer(spec, context) is not None
|
||||
|
||||
|
||||
def is_played(context: Dict[str, Any], number: Optional[str]) -> bool:
|
||||
"""이 카드를 이 협상에서 이미 썼는지. 와일드 진입·종결·협상카드가 같은 이력을 본다."""
|
||||
return bool(number) and str(number) in (context.get("played_card_numbers") or [])
|
||||
|
||||
|
||||
def mark_played(context: Dict[str, Any], number: Optional[str]) -> None:
|
||||
"""카드를 실제로 내보낸 시점에 이력에 남긴다(노출되지 않은 후보는 남기지 않는다)."""
|
||||
if not number:
|
||||
return
|
||||
played = list(context.get("played_card_numbers") or [])
|
||||
if str(number) not in played:
|
||||
played.append(str(number))
|
||||
context["played_card_numbers"] = played
|
||||
|
||||
@ -23,6 +23,7 @@ _SESSIONS = table(
|
||||
"sessions",
|
||||
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
|
||||
column("qt_type"), column("target_price"), column("anchoring_price"),
|
||||
column("done_ceiling_price"), # 타결 상한가 — 견적 생성 시 박제(목표가×(1+타결상한율))
|
||||
column("qt_setting_id"),
|
||||
column("deleted"),
|
||||
schema="negotiation",
|
||||
@ -33,8 +34,32 @@ _QUOTATION_SETTINGS = table(
|
||||
column("qt_setting_id"), column("card_count"), column("deleted"),
|
||||
schema="quotation",
|
||||
)
|
||||
_ITEMS = table("items", column("item_id"), column("name"), column("price"),
|
||||
column("internet_lowest_price"), column("deleted"), schema="partner")
|
||||
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
|
||||
column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
|
||||
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼과 그 호칭을 여기서 정한다.
|
||||
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
|
||||
|
||||
# 협상 기준가 후보: items 컬럼 ↔ 용어 카탈로그 키 ↔ 용어 미설정 시 기본값.
|
||||
# 기본값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 한다 — 화면 라벨과
|
||||
# 협상 멘트 호칭이 갈리지 않도록. 문장이 어색하면 회사가 용어 탭에서 바꾼다.
|
||||
_BASELINE_PRICE = ("price", "item.price", "상품 단가")
|
||||
_BASELINE_PURCHASE = ("purchase_price", "item.purchase_price", "매입가")
|
||||
_BASELINE_BY_FIELD = {"price": _BASELINE_PRICE, "purchase_price": _BASELINE_PURCHASE}
|
||||
|
||||
|
||||
def _resolve_baseline(settings: dict) -> tuple:
|
||||
"""회사 설정 → 협상 기준가로 쓸 (컬럼, 라벨키, 호칭 폴백).
|
||||
|
||||
1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
|
||||
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
|
||||
않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
|
||||
chosen = (settings.get("features") or {}).get("nego_baseline_field")
|
||||
if chosen in _BASELINE_BY_FIELD:
|
||||
return _BASELINE_BY_FIELD[chosen]
|
||||
hidden = set(settings.get("hidden_fields") or [])
|
||||
if "price" in hidden and "purchase_price" not in hidden:
|
||||
return _BASELINE_PURCHASE
|
||||
return _BASELINE_PRICE
|
||||
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
|
||||
_QUOTATIONS = table(
|
||||
"quotations",
|
||||
@ -53,12 +78,12 @@ _VERSION_WILD_CARDS = table(
|
||||
)
|
||||
_NEGO_CARDS = table(
|
||||
"nego_cards",
|
||||
column("nego_card_id"), column("number"), column("deleted"),
|
||||
column("nego_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
||||
schema="card",
|
||||
)
|
||||
_WILD_CARDS = table(
|
||||
"wild_cards",
|
||||
column("wild_card_id"), column("number"), column("deleted"),
|
||||
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
||||
schema="card",
|
||||
)
|
||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||
@ -72,12 +97,16 @@ _SUPPLIER_ITEMS = table(
|
||||
class INegoContextCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||
"""세션 행 (qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
||||
"""세션 행 (qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id). 없으면 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
"""품목 기준가(items.price). 없으면 0."""
|
||||
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
||||
"""협상 기준가·그 호칭·회사 용어 사전 (가격, 호칭, labels).
|
||||
|
||||
어느 컬럼을 기준가로 쓰는지는 회사 설정(features.nego_baseline_field)이 정한다.
|
||||
labels 는 companies.settings.labels 원본 — 협상 스크립트의 용어 토큰 치환에 쓴다.
|
||||
값이 없으면 (0, 호칭, {})."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -124,8 +153,9 @@ class INegoContextCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
|
||||
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록."""
|
||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
|
||||
"""견적 version_id 에 연결된 (일반카드 행 목록, 와일드카드 행 목록). 없으면 빈 목록.
|
||||
행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 카드 전술(CardSpec)을 만든다."""
|
||||
pass
|
||||
|
||||
|
||||
@ -134,6 +164,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
try:
|
||||
query = (
|
||||
select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price,
|
||||
_SESSIONS.c.done_ceiling_price,
|
||||
_SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id)
|
||||
.where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
@ -146,20 +177,30 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
||||
_fallback = (0, _BASELINE_PRICE[2], {})
|
||||
try:
|
||||
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
|
||||
query = (
|
||||
select(_ITEMS.c.price)
|
||||
select(_ITEMS.c.price, _ITEMS.c.purchase_price, _COMPANIES.c.settings)
|
||||
.select_from(_ITEMS.outerjoin(_COMPANIES, _ITEMS.c.company_id == _COMPANIES.c.company_id))
|
||||
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||
return err_type, 0
|
||||
return ErrorType.SUCCESS, int(rows[0])
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_baseline failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows:
|
||||
return err_type, _fallback
|
||||
# 컬럼이 2개 이상이면 execute 가 행 리스트를 준다(1개일 때만 스칼라 리스트).
|
||||
price, purchase_price, settings = rows[0]
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
labels = settings.get("labels") or {}
|
||||
field, label_key, label_fallback = _resolve_baseline(settings)
|
||||
label = labels.get(label_key) or label_fallback
|
||||
value = purchase_price if field == "purchase_price" else price
|
||||
return ErrorType.SUCCESS, (int(value or 0), label, labels)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
return ErrorType.DB_RUN_FAILED, _fallback
|
||||
|
||||
async def get_card_count(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
try:
|
||||
@ -289,7 +330,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
|
||||
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[tuple], list[tuple]]]:
|
||||
try:
|
||||
version_q = (
|
||||
select(_QUOTATIONS.c.version_id)
|
||||
@ -304,7 +345,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
version_id = rows[0]
|
||||
|
||||
nego_q = (
|
||||
select(_NEGO_CARDS.c.number)
|
||||
select(_NEGO_CARDS.c.number, _NEGO_CARDS.c.script, _NEGO_CARDS.c.tactic)
|
||||
.select_from(
|
||||
_VERSION_NEGO_CARDS.join(
|
||||
_NEGO_CARDS,
|
||||
@ -323,7 +364,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
return n_err, ([], [])
|
||||
|
||||
wild_q = (
|
||||
select(_WILD_CARDS.c.number)
|
||||
select(_WILD_CARDS.c.number, _WILD_CARDS.c.script, _WILD_CARDS.c.tactic)
|
||||
.select_from(
|
||||
_VERSION_WILD_CARDS.join(
|
||||
_WILD_CARDS,
|
||||
@ -342,8 +383,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
return w_err, ([], [])
|
||||
|
||||
return ErrorType.SUCCESS, (
|
||||
[str(r) for r in n_rows if r is not None],
|
||||
[str(r) for r in w_rows if r is not None],
|
||||
[(str(r[0]), r[1], r[2]) for r in n_rows if r[0] is not None],
|
||||
[(str(r[0]), r[1], r[2]) for r in w_rows if r[0] is not None],
|
||||
)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -10,7 +10,9 @@ import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from negotiation.cards.domain.tactics import compute_counter, tactic_for
|
||||
from negotiation.cards.domain.tactics import (
|
||||
available, compute_offer, is_played, mark_played, playable, settle_ceiling, spec_from_context,
|
||||
)
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
|
||||
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
||||
@ -43,6 +45,39 @@ def _parse_price(user_input: Any) -> Optional[float]:
|
||||
return price if price > 0 else None
|
||||
|
||||
|
||||
# 협상 스크립트가 쓰는 용어 토큰: {label_*} = 회사 용어(없으면 기본값).
|
||||
# 값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 화면·멘트 표기가 갈리지 않는다.
|
||||
_SCRIPT_LABELS = {
|
||||
"label_supplier": ("supplier", "협력사"),
|
||||
"label_target_price": ("target_price", "목표가"),
|
||||
"label_delivery_type": ("item.delivery_type", "배송 형태"),
|
||||
"label_delivery_type_1": ("delivery_type.1", "협력사배송"),
|
||||
"label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
|
||||
"label_delivery_type_3": ("delivery_type.3", "픽업배송"),
|
||||
"label_product": ("item.name", "상품명"),
|
||||
# 협상 기준가 호칭의 최후 폴백. 실제 값은 loader 가 회사 설정에서 정해 컨텍스트에 박제하고,
|
||||
# 이 값은 DB 컨텍스트가 없는 데모/직접호출 경로에서만 쓰인다.
|
||||
"label_item_price": ("item.price", "상품 단가"),
|
||||
}
|
||||
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
|
||||
# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
|
||||
_JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"),
|
||||
"을": ("을", "를"), "를": ("을", "를"), "과": ("과", "와"), "와": ("과", "와")}
|
||||
|
||||
|
||||
def _has_batchim(word: str) -> bool:
|
||||
last = word[-1] if word else ""
|
||||
return "가" <= last <= "힣" and (ord(last) - 0xAC00) % 28 != 0
|
||||
|
||||
|
||||
def _josa(word: str, form: str) -> str:
|
||||
"""단어 + 받침에 맞는 조사. form 은 대표형('를'·'는'·'가'·'와')."""
|
||||
pair = _JOSA.get(form)
|
||||
if not pair:
|
||||
return word
|
||||
return word + (pair[0] if _has_batchim(word) else pair[1])
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatSession:
|
||||
session_id: str
|
||||
@ -169,6 +204,15 @@ class ChatEngine:
|
||||
and price <= anchor * self.rules.wildcard_entry_ratio)
|
||||
)
|
||||
)
|
||||
# 구간에 들어와도 실제로 낼 카드가 없으면(전부 종결 전용·사용됨·유효조건 미달) 이 조건은
|
||||
# 불충족으로 두고 다음 조건(우선협상·소진 판정)을 평가한다 — 여기서 매칭돼 버리면
|
||||
# 카드 소진 판정이 영영 돌지 않아, 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상이 된다.
|
||||
if ok:
|
||||
probe = ChatSession(
|
||||
session_id=session.session_id, tenant_id=session.tenant_id,
|
||||
company_id=session.company_id, context=dict(ctx),
|
||||
)
|
||||
ok = self._pick_wildcard(probe) != "가격협상"
|
||||
elif cond == "check_is_supplier_type_c":
|
||||
ok = False # 공급사 유형 미보유 (PoC 단순화)
|
||||
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
|
||||
@ -181,13 +225,27 @@ class ChatEngine:
|
||||
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
|
||||
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
|
||||
counter_rounds = max(0, ctx.get("round", 0) - 1)
|
||||
exhausted = counter_rounds >= self.rules.max_counter_rounds or (cards_total > 0 and cards_used >= cards_total)
|
||||
# 담은 협상카드 중 지금 낼 수 있는 게 하나도 없으면(사용됨·발동조건 미달 — 예:
|
||||
# 시장가 인용 카드인데 최저가 결측) 장수와 무관하게 소진으로 본다 — 안 그러면
|
||||
# 선택 마스크가 전부 막힌 채 폴백이 부적합 카드를 억지로 꺼낸다(토큰 노출).
|
||||
selected = ctx.get("selected_nego_card_numbers") or []
|
||||
none_playable = bool(selected) and not any(
|
||||
not is_played(ctx, n) and playable(spec_from_context(ctx, n), ctx)
|
||||
for n in selected
|
||||
)
|
||||
exhausted = (
|
||||
counter_rounds >= self.rules.max_counter_rounds
|
||||
or (cards_total > 0 and cards_used >= cards_total)
|
||||
or none_playable
|
||||
)
|
||||
if exhausted:
|
||||
target = ctx.get("target_price", 0)
|
||||
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
||||
# 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스).
|
||||
ceiling = settle_ceiling(ctx)
|
||||
if not ctx.get("closing_played"):
|
||||
ctx["force_closing"] = True
|
||||
return "가격협상"
|
||||
return "협상완료" if (target > 0 and price <= target) else c.get("next")
|
||||
return "협상완료" if (ceiling > 0 and price <= ceiling) else c.get("next")
|
||||
ok = False
|
||||
elif cond == "default":
|
||||
ok = True
|
||||
@ -207,22 +265,36 @@ class ChatEngine:
|
||||
ctx = session.context
|
||||
price = ctx.get("input_price", 0)
|
||||
anchor = ctx.get("anchor_price", 0)
|
||||
target = ctx.get("target_price", 0)
|
||||
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
|
||||
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["offer_1pct"] = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 (멘트 변수) — 10원 반올림(앵커·카운터와 통일)
|
||||
ctx["pending_counter_price"] = ctx["offer_1pct"] # 수락 시 이 가격으로 타결
|
||||
return "wild_card_1pct"
|
||||
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
|
||||
# 제안가 공통 유효조건(≤목표가 · <제시가)은 시스템 1% 카드에도 동일하게 건다.
|
||||
# 기본 앵커 밴드에선 수학적으로 항상 통과하지만, 앵커율 0 등 극단 데이터를 방어한다.
|
||||
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target):
|
||||
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["offer_1pct"] = offer_1pct
|
||||
ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
|
||||
ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
|
||||
return "wild_card_1pct"
|
||||
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
||||
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
||||
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
|
||||
for number in (ctx.get("selected_wild_card_numbers") or []):
|
||||
counter = compute_counter(tactic_for(str(number)), ctx)
|
||||
if counter is not None:
|
||||
number = str(number)
|
||||
spec = spec_from_context(ctx, number)
|
||||
# 종결 전용 카드(최종 통보·중간값 절충)는 여기서 안 꺼낸다 — 종결 국면의 마지막 한 방으로 예약.
|
||||
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
|
||||
if not available(spec, ctx) or is_played(ctx, number):
|
||||
continue
|
||||
offer = compute_offer(spec, ctx)
|
||||
if offer is not None:
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["pending_counter_price"] = counter
|
||||
ctx["active_wild_card_number"] = str(number)
|
||||
ctx["pending_counter_price"] = offer
|
||||
ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
|
||||
ctx["active_wild_card_number"] = number
|
||||
mark_played(ctx, number)
|
||||
return "wild_card_dynamic"
|
||||
return "가격협상"
|
||||
|
||||
@ -240,6 +312,14 @@ class ChatEngine:
|
||||
if "anchor_price" in ctx:
|
||||
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
|
||||
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
|
||||
# 용어 토큰 — 회사 용어 사전(labels)이 있으면 그 단어, 없으면 카탈로그 기본값.
|
||||
# 조사가 붙는 자리를 위해 {label_supplier_를} 같은 파생 키도 함께 만든다.
|
||||
labels = ctx.get("labels") or {}
|
||||
for token, (label_key, fallback) in _SCRIPT_LABELS.items():
|
||||
word = labels.get(label_key) or fallback
|
||||
out[token] = word
|
||||
for form in ("는", "가", "를", "와"):
|
||||
out[f"{token}_{form}"] = _josa(word, form)
|
||||
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
|
||||
if ctx.get("partner_name"):
|
||||
out["partner_name"] = str(ctx["partner_name"])
|
||||
@ -253,7 +333,7 @@ class ChatEngine:
|
||||
ilp = ctx.get("internet_lowest_price") or 0
|
||||
if ilp > 0:
|
||||
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
|
||||
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의.
|
||||
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
||||
anchor = ctx.get("anchor_price") or 0
|
||||
target = ctx.get("target_price") or 0
|
||||
if "input_price" in ctx:
|
||||
@ -267,7 +347,7 @@ class ChatEngine:
|
||||
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
|
||||
if ctx.get("pending_counter_price"):
|
||||
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다.
|
||||
# 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_counter 의
|
||||
# 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_offer 의
|
||||
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
|
||||
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
|
||||
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
|
||||
@ -275,21 +355,23 @@ class ChatEngine:
|
||||
out["counter_price"] = pending_i
|
||||
out["middle_price"] = pending_i
|
||||
out["target_mid_price"] = pending_i
|
||||
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
|
||||
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
|
||||
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
||||
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
|
||||
# 기준가 호칭(공급가/매입가/회사 라벨)은 회사 설정에서 온다 — loader 가 박제한 값.
|
||||
base = ctx.get("item_price") or 0
|
||||
label = ctx.get("item_price_label") or _SCRIPT_LABELS["label_item_price"][1]
|
||||
if base > 1 and "input_price" in ctx:
|
||||
rate = ((base - ctx["input_price"]) / base) * 100
|
||||
out["discount_rate"] = f"{max(0.0, rate):.1f}"
|
||||
if rate >= 0.05:
|
||||
out["discount_phrase"] = f"기존 공급가 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
|
||||
out["discount_phrase"] = f"기존 {label} 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
|
||||
elif rate <= -0.05:
|
||||
out["discount_phrase"] = (
|
||||
f"기존 공급가(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
|
||||
f"기존 {label}(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
|
||||
else:
|
||||
out["discount_phrase"] = "기존 공급가와 동일한 수준의 금액입니다. "
|
||||
out["discount_phrase"] = f"기존 {_josa(label, '와')} 동일한 수준의 금액입니다. "
|
||||
else:
|
||||
out["discount_rate"] = "0.0"
|
||||
out["discount_phrase"] = ""
|
||||
@ -306,13 +388,14 @@ class ChatEngine:
|
||||
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
|
||||
if not step_key or step_key not in self.scripts:
|
||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||
# 가드레일(최후 방어선): 구매자 대리는 목표가 초과로 절대 타결하지 않는다.
|
||||
# 카운터 클램프·종결 규칙이 정상이면 도달하지 않지만, 스크립트 편집 실수 등으로
|
||||
# 성공 스텝에 초과가로 진입하면 결렬로 강제 전환한다. (재협상 흐름 한정)
|
||||
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
||||
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
||||
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데
|
||||
# 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
||||
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
||||
ctx = session.context
|
||||
target = ctx.get("target_price") or 0
|
||||
if target > 0 and ctx.get("input_price", 0) > target:
|
||||
ceiling = settle_ceiling(ctx)
|
||||
if ceiling > 0 and ctx.get("input_price", 0) > ceiling:
|
||||
step_key = "협상실패"
|
||||
node = self.scripts[step_key]
|
||||
session.step = step_key
|
||||
@ -326,11 +409,14 @@ class ChatEngine:
|
||||
elif step_key in _FAILURE_STEPS:
|
||||
session.context["final_outcome"] = "failure"
|
||||
outcome = session.context.get("final_outcome") if chat_end else None
|
||||
# 선택지도 스크립트와 같은 변수 치환을 태운다 — 배송형태 보기가 회사 용어({label_delivery_type_1} 등)라
|
||||
# 치환을 건너뛰면 사용자에게 토큰 원문이 그대로 보인다.
|
||||
step_vars = self._vars(session)
|
||||
return StepView(
|
||||
step=step_key,
|
||||
script=self.repo.format_script(node.get("script", ""), self._vars(session)),
|
||||
script=self.repo.format_script(node.get("script", ""), step_vars),
|
||||
input_mode=node.get("next_input_mode", "null"),
|
||||
input_options=node.get("input_options", []),
|
||||
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
|
||||
chat_end=bool(node.get("chat_end")),
|
||||
client_step=self.step_map.get(step_key, step_key),
|
||||
needs_card_selection=(step_key == "가격협상"),
|
||||
@ -339,9 +425,13 @@ class ChatEngine:
|
||||
)
|
||||
|
||||
def _error(self, session: ChatSession, msg: str) -> StepView:
|
||||
# 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
|
||||
# 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
|
||||
node = self.scripts.get(session.step, {})
|
||||
step_vars = self._vars(session)
|
||||
return StepView(
|
||||
step=session.step, script=node.get("script", ""),
|
||||
input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []),
|
||||
step=session.step, script=self.repo.format_script(node.get("script", ""), step_vars),
|
||||
input_mode=node.get("next_input_mode", "null"),
|
||||
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
|
||||
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
|
||||
)
|
||||
|
||||
@ -19,6 +19,7 @@ from typing import Optional
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from negotiation.cards.domain.tactics import build_card_spec
|
||||
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
|
||||
from negotiation.qtable.domain.model.snapshot import PartnerType
|
||||
|
||||
@ -38,7 +39,10 @@ class NegotiationDbContext:
|
||||
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
|
||||
target_price: int # 목표 매입가(원) — sessions.target_price
|
||||
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
||||
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
|
||||
done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
|
||||
item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
|
||||
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
|
||||
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
|
||||
internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0
|
||||
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
|
||||
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
|
||||
@ -48,6 +52,9 @@ class NegotiationDbContext:
|
||||
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
|
||||
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
|
||||
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
|
||||
# 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
|
||||
# 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
|
||||
card_specs: dict
|
||||
|
||||
|
||||
class NegotiationContextLoader:
|
||||
@ -67,8 +74,10 @@ class NegotiationContextLoader:
|
||||
err, row = await self.crud.get_session_row(s, sid)
|
||||
if err != ErrorType.SUCCESS or row is None:
|
||||
return None
|
||||
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row
|
||||
qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id = row
|
||||
target = int(target_price or 0)
|
||||
# 타결 상한가: 견적 생성 시 박제(목표가×(1+타결상한율)). 옛 세션은 NULL → 목표가로 폴백.
|
||||
ceiling = int(done_ceiling_price or 0) or target
|
||||
|
||||
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
|
||||
@ -85,8 +94,9 @@ class NegotiationContextLoader:
|
||||
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
|
||||
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
|
||||
|
||||
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
||||
_, item_price = await self.crud.get_item_price(s, item_id)
|
||||
# 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
|
||||
# 없으면 0(인하율 멘트 미표시).
|
||||
_, (item_price, item_price_label, labels) = await self.crud.get_item_baseline(s, item_id)
|
||||
|
||||
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
|
||||
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
|
||||
@ -106,7 +116,19 @@ class NegotiationContextLoader:
|
||||
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
|
||||
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
|
||||
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
|
||||
selected_nego_cards, selected_wild_cards = selected_cards
|
||||
nego_rows, wild_rows = selected_cards
|
||||
selected_nego_cards = [number for number, _script, _tactic in nego_rows]
|
||||
selected_wild_cards = [number for number, _script, _tactic in wild_rows]
|
||||
# 카드 전술 확정 — "스크립트에 꽂힌 변수가 곧 전술"(제안가 파싱) + tactic JSONB(min_round·closing).
|
||||
card_specs = {}
|
||||
for number, script, tactic in [*nego_rows, *wild_rows]:
|
||||
spec = build_card_spec(script, tactic if isinstance(tactic, dict) else None)
|
||||
card_specs[number] = {
|
||||
"offer_variable": spec.offer_variable,
|
||||
"min_round": spec.min_round,
|
||||
"closing": spec.closing,
|
||||
"requires": list(spec.requires), # 세션-의존 변수 결측 시 미발동(available)
|
||||
}
|
||||
|
||||
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
|
||||
_, card_count = await self.crud.get_card_count(s, sid)
|
||||
@ -115,7 +137,10 @@ class NegotiationContextLoader:
|
||||
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
|
||||
target_price=target,
|
||||
anchor_price=anchor,
|
||||
done_ceiling_price=ceiling,
|
||||
item_price=item_price,
|
||||
item_price_label=item_price_label,
|
||||
labels=labels,
|
||||
internet_lowest_price=internet_lowest_price,
|
||||
partner_name=partner_name,
|
||||
product_name=product_name,
|
||||
@ -125,6 +150,7 @@ class NegotiationContextLoader:
|
||||
selected_nego_card_numbers=selected_nego_cards,
|
||||
selected_wild_card_numbers=selected_wild_cards,
|
||||
card_count=card_count,
|
||||
card_specs=card_specs,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@ -121,7 +121,7 @@ class ScriptNaturalizer:
|
||||
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
|
||||
|
||||
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 기존 공급가 대비.
|
||||
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 협상 기준가 대비.
|
||||
"""
|
||||
out: Dict[str, Any] = {}
|
||||
rnd = context.get("round") or 0
|
||||
|
||||
@ -13,7 +13,7 @@ from common.enums import DBType, ErrorType
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from config.server_configs import agent_config
|
||||
from negotiation.cards.domain.tactics import compute_counter, tactic_available, tactic_for
|
||||
from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context
|
||||
from negotiation.chat.service.chat_engine import (
|
||||
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
||||
)
|
||||
@ -41,6 +41,8 @@ _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_reven
|
||||
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
|
||||
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
|
||||
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
|
||||
_DEFAULT_ITEM_PRICE_LABEL = "상품 단가" # 협상 기준가 호칭 — DB 컨텍스트 없는 데모/직접호출 경로 폴백
|
||||
# (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
|
||||
|
||||
|
||||
class ChatService:
|
||||
@ -108,12 +110,19 @@ class ChatService:
|
||||
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
|
||||
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
|
||||
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
|
||||
# 타결 상한가(sessions.done_ceiling_price 박제) — 타결 판정선이자 카드 제안가 상한.
|
||||
# 목표가를 조금 넘어도 이 이하면 타결한다. 미박제/데모는 목표가와 같다.
|
||||
"done_ceiling_price": db_ctx.done_ceiling_price if db_ctx else _DEFAULT_TARGET_PRICE,
|
||||
# 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백.
|
||||
"partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME),
|
||||
"product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),
|
||||
"round": 0,
|
||||
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
|
||||
# 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 가격협상_확인 인하율 산출용.
|
||||
# 호칭은 회사 설정 라벨을 따른다("기존 {label} 대비 …" 멘트).
|
||||
"item_price": db_ctx.item_price if db_ctx else 0,
|
||||
"item_price_label": db_ctx.item_price_label if db_ctx else _DEFAULT_ITEM_PRICE_LABEL,
|
||||
# 회사 용어 사전 — 스크립트의 {label_*} 토큰(협력사·목표가·배송형태 등) 치환용.
|
||||
"labels": (db_ctx.labels if db_ctx else {}),
|
||||
# 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용.
|
||||
# 미수집(0)이면 vars_for 가 키를 만들지 않아 원형 유지(허위 시장가 표기 방지).
|
||||
"internet_lowest_price": db_ctx.internet_lowest_price if db_ctx else 0,
|
||||
@ -123,6 +132,9 @@ class ChatService:
|
||||
"db_context_loaded": db_ctx is not None,
|
||||
"selected_nego_card_numbers": selected_nego_cards,
|
||||
"selected_wild_card_numbers": selected_wild_cards,
|
||||
# 카드번호 → 전술 {offer_variable, min_round, closing}. 시작 시 1회 박제(loader) —
|
||||
# 이후 카드 멘트가 바뀌어도 이 협상은 시작 시점 전술로 끝까지 간다.
|
||||
"card_specs": (db_ctx.card_specs if db_ctx else {}),
|
||||
"allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards),
|
||||
},
|
||||
)
|
||||
@ -290,14 +302,17 @@ class ChatService:
|
||||
decision = policy.select(ctx)
|
||||
session.used_action_ids.add(decision.action_id)
|
||||
card_id = self._card_id_for_action(engine, session, decision.action_id)
|
||||
# 전술 실행(재설계): 카드의 가격 행동 — 카운터 제시가를 계산해 세션에 적재한다.
|
||||
# 카드번호 공용 이력 — 와일드/종결 경로와 같은 목록을 본다("한 협상 한 카드 1회" 단일 판정).
|
||||
mark_played(session.context, card_id)
|
||||
# 전술 실행: 카드가 제시할 금액(스크립트 파싱 결과)을 계산해 세션에 적재한다.
|
||||
# pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고,
|
||||
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
|
||||
spec = tactic_for(card_id)
|
||||
counter = compute_counter(spec, session.context) if tactic_available(spec, session.context) else None
|
||||
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
||||
spec = spec_from_context(session.context, card_id)
|
||||
counter = compute_offer(spec, session.context) if available(spec, session.context) else None
|
||||
if counter is not None:
|
||||
session.context["pending_counter_price"] = counter
|
||||
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(middle_price 기준)
|
||||
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(절충가 계산 기준)
|
||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||
@ -352,18 +367,26 @@ class ChatService:
|
||||
규칙층의 강제 결정이므로 RL 선택/학습을 우회한다."""
|
||||
ctx = session.context
|
||||
ctx["closing_played"] = True
|
||||
# 선택 와일드카드 중 종결 전술 (WC-05/WC-03) — 순서대로 첫 매치.
|
||||
closing_number = next(
|
||||
(str(n) for n in (ctx.get("selected_wild_card_numbers") or []) if tactic_for(str(n)).closing),
|
||||
None,
|
||||
)
|
||||
counter = compute_counter(tactic_for(closing_number), ctx) if closing_number else None
|
||||
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
||||
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
||||
closing_number, counter = None, None
|
||||
for n in (ctx.get("selected_wild_card_numbers") or []):
|
||||
n = str(n)
|
||||
spec = spec_from_context(ctx, n)
|
||||
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
||||
continue
|
||||
offer = compute_offer(spec, ctx)
|
||||
if offer is not None:
|
||||
closing_number, counter = n, offer
|
||||
break
|
||||
if counter is None:
|
||||
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
||||
closing_number = None
|
||||
target = int(ctx.get("target_price") or 0)
|
||||
counter = target if 0 < target < ctx.get("input_price", 0) else None
|
||||
if counter is None:
|
||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||
ctx["pending_counter_price"] = counter
|
||||
ctx["prev_customer_price"] = counter
|
||||
|
||||
@ -441,10 +464,12 @@ class ChatService:
|
||||
|
||||
@staticmethod
|
||||
def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
|
||||
"""전술 발동조건(min_round·가격구간)을 만족하는 action 만 True. HOLD(설득)는 항상 True.
|
||||
"""지금 플레이 가능한 action 만 True. HOLD(설득)는 발동조건만, 금액 카드는 제안가 유효까지
|
||||
본다(playable) — 무효 금액(역행·목표가 초과 등)이 멘트 글자로 나가는 것 자체를 막는다.
|
||||
전부 True 면 None(마스크 불필요)."""
|
||||
ctx = session.context
|
||||
mask = np.array(
|
||||
[tactic_available(tactic_for(engine.mapper.get_card_id(a)), session.context)
|
||||
[playable(spec_from_context(ctx, engine.mapper.get_card_id(a)), ctx)
|
||||
for a in range(engine.action_space_size)],
|
||||
dtype=bool,
|
||||
)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{
|
||||
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
||||
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
||||
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
|
||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||
"2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
||||
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||
"4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
|
||||
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
|
||||
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.",
|
||||
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
|
||||
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"서비스안내": {
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||
"editor_script_id": "서비스안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": [
|
||||
@ -25,7 +25,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"담당자확인": {
|
||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "담당자확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": [
|
||||
@ -55,7 +55,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"정보변경_완료": {
|
||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||
"script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||
"editor_script_id": "정보변경_완료",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"서비스안내": {
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
||||
"editor_script_id": "서비스안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["확인"],
|
||||
@ -19,7 +19,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"담당자확인": {
|
||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "담당자확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
@ -37,7 +37,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"정보변경_완료": {
|
||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
||||
"script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
||||
"editor_script_id": "정보변경_완료",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
@ -46,7 +46,7 @@
|
||||
"chat_end": true
|
||||
},
|
||||
"협상품목안내": {
|
||||
"script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
||||
"script": "{company_name}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
||||
"editor_script_id": "협상품목안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["네, 알겠습니다."],
|
||||
@ -73,10 +73,10 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"배송형태선택": {
|
||||
"script": "배송 형태를 선택해 주세요.",
|
||||
"script": "{label_delivery_type_를} 선택해 주세요.",
|
||||
"editor_script_id": "배송형태선택",
|
||||
"next_input_mode": "delivery_type",
|
||||
"input_options": ["협력사배송", "지정택배배송", "픽업배송"],
|
||||
"input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
|
||||
"next_step": { "default": "가격협상_입력" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"editor_script_id": "wild_card_1pct"
|
||||
},
|
||||
"wild_card_budget": {
|
||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||
"type": "text",
|
||||
"chat_end": false,
|
||||
"next_input_mode": "price",
|
||||
|
||||
157
agent/tests/fuzz_negotiation.py
Normal file
157
agent/tests/fuzz_negotiation.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 N회 완주시키고 불변식 위반을 수집한다.
|
||||
시드 고정(재현 가능). test_ 접두사 없음 — pytest 수집 대상 아님, 수동 실행 전용:
|
||||
docker run --rm -v $PWD/agent:/work -w /work -e APP_ENV=local -e DB_HOST=host.docker.internal \
|
||||
o2o-negosium-agent sh -lc "pip install -q pytest pytest-asyncio httpx; python tests/fuzz_negotiation.py"
|
||||
|
||||
케이스마다 검사하는 불변식:
|
||||
1. 전 턴 success
|
||||
2. 같은 카드 2회 발동 금지
|
||||
3. 종결 전용(WC-03·05)은 가격협상_카운터에서만 / 비종결 와일드는 wild_card_dynamic 에서만
|
||||
4. 타결 시 타결가 ≤ 목표가
|
||||
5. 카운터/1% 수락으로 타결하면 그 멘트에 타결가 표기
|
||||
6. 멘트·버튼에 미치환 토큰({xxx}) 잔존 금지
|
||||
7. 턴 상한(60) 안에 반드시 종료
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, "/work")
|
||||
|
||||
from router.v1.chat.protocol import Req_Chat # noqa: E402
|
||||
from services.chat_service import ChatService, reset_sessions # noqa: E402
|
||||
from tenancy.config_loader import TenantConfigLoader # noqa: E402
|
||||
from tenancy.registry import TenantEngineRegistry # noqa: E402
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402
|
||||
|
||||
N = 100
|
||||
SEED = 20260805
|
||||
TARGET = 10_000
|
||||
NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005",
|
||||
"NGC-007", "NGC-008", "NGC-010", "NGC-011"]
|
||||
WILD_POOL = ["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]
|
||||
CLOSING = {"WC-03", "WC-05"}
|
||||
TOKEN_RE = re.compile(r"(?<!\{)\{([a-z_0-9]+)\}(?!\})")
|
||||
|
||||
|
||||
class Supplier:
|
||||
"""랜덤 협력사 — 높은 시작가에서 점진 양보, 카운터는 확률적으로 수락/거절."""
|
||||
|
||||
def __init__(self, rng, anchor):
|
||||
self.rng = rng
|
||||
self.anchor = anchor
|
||||
self.price = TARGET * rng.uniform(1.02, 1.30)
|
||||
self.accept_p = rng.uniform(0.15, 0.5)
|
||||
|
||||
def next_price(self):
|
||||
p = int(self.price)
|
||||
# 다음 라운드를 위해 양보 — 가끔 앵커 밑까지 다이브(우선협상 유도).
|
||||
self.price *= self.rng.uniform(0.90, 0.99)
|
||||
if self.rng.random() < 0.15:
|
||||
self.price = self.anchor * self.rng.uniform(0.95, 1.04)
|
||||
return str(max(p, 100))
|
||||
|
||||
def choose(self, options):
|
||||
if "수락" in options:
|
||||
return "수락" if self.rng.random() < self.accept_p else "다른 가격 제시"
|
||||
if set(options) >= {"예", "아니오"}:
|
||||
return "예" if self.rng.random() < max(self.accept_p, 0.5) else "아니오"
|
||||
return options[0] if options else "확인"
|
||||
|
||||
|
||||
async def run_case(idx, rng):
|
||||
anchor = int(TARGET * rng.choice([0.99, 0.99, 0.97, 0.95, 1.0]))
|
||||
nego = rng.sample(NEGO_POOL, rng.randint(1, 5))
|
||||
wild = rng.sample(WILD_POOL, rng.randint(0, 5))
|
||||
sup = Supplier(rng, anchor)
|
||||
|
||||
reset_sessions()
|
||||
sid = uuid.uuid4()
|
||||
qid, ver = await _seed_quote_session(sid, nego, wild_numbers=wild, target=TARGET, anchor=anchor)
|
||||
violations, fired, settled, outcome, ended = [], [], None, None, False
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
ui, last_input = None, None
|
||||
for _turn in range(60):
|
||||
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||
if r.result.success is not True:
|
||||
violations.append(f"턴 실패 input={ui} msg={r.msg}")
|
||||
break
|
||||
script, opts = r.script or "", list(r.input_options or [])
|
||||
if TOKEN_RE.search(script):
|
||||
violations.append(f"미치환 토큰(script): {TOKEN_RE.findall(script)} @ {r.step}")
|
||||
for o in opts:
|
||||
if TOKEN_RE.search(o):
|
||||
violations.append(f"미치환 토큰(option): {o} @ {r.step}")
|
||||
if r.card_id:
|
||||
fired.append((r.step, r.card_id))
|
||||
if r.settled_price is not None:
|
||||
settled = r.settled_price
|
||||
# 카운터/1% '수락' 타결이면 마지막 카운터 멘트에 타결가가 보였어야 한다.
|
||||
if last_input in ("수락",) and str(settled) not in (last_counter or ""):
|
||||
violations.append(f"표시가≠타결가: {settled} not in counter script")
|
||||
if r.step in ("가격협상_카운터", "wild_card_dynamic", "wild_card_1pct"):
|
||||
last_counter = script
|
||||
if r.chat_end:
|
||||
outcome, ended = r.outcome, True
|
||||
break
|
||||
# 다음 입력 결정
|
||||
last_input = None
|
||||
if r.input_mode == "price":
|
||||
ui = sup.next_price()
|
||||
elif opts:
|
||||
ui = sup.choose(opts)
|
||||
last_input = ui
|
||||
else:
|
||||
ui = "확인"
|
||||
if not ended:
|
||||
violations.append("60턴 내 미종료")
|
||||
|
||||
# 카드 불변식
|
||||
ids = [c for _, c in fired]
|
||||
if len(ids) != len(set(ids)):
|
||||
violations.append(f"카드 중복: {ids}")
|
||||
for step, c in fired:
|
||||
if c in CLOSING and step != "가격협상_카운터":
|
||||
violations.append(f"종결 카드 {c} 가 {step} 에서 발동")
|
||||
if c.startswith("WC") and c not in CLOSING and step != "wild_card_dynamic":
|
||||
violations.append(f"비종결 와일드 {c} 가 {step} 에서 발동")
|
||||
if outcome == "success":
|
||||
if settled is None:
|
||||
violations.append("성공인데 settled 없음")
|
||||
elif settled > TARGET:
|
||||
violations.append(f"목표가 초과 타결: {settled}")
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver)
|
||||
return {"idx": idx, "anchor": anchor, "nego": nego, "wild": wild,
|
||||
"fired": fired, "settled": settled, "outcome": outcome, "violations": violations}
|
||||
|
||||
|
||||
async def main():
|
||||
rng = random.Random(SEED)
|
||||
results, bad = [], []
|
||||
for i in range(N):
|
||||
res = await run_case(i, random.Random(rng.random()))
|
||||
results.append(res)
|
||||
if res["violations"]:
|
||||
bad.append(res)
|
||||
tag = "OK " if not res["violations"] else "BAD"
|
||||
print(f"[{tag}] #{i:02d} anchor={res['anchor']} nego={len(res['nego'])} wild={len(res['wild'])} "
|
||||
f"fired={'→'.join(c for _, c in res['fired']) or '-'} settled={res['settled']} {res['outcome']}")
|
||||
ok = sum(1 for r in results if not r["violations"])
|
||||
succ = sum(1 for r in results if r["outcome"] == "success")
|
||||
print(f"\n===== {ok}/{N} clean · 타결 {succ} / 결렬 {N - succ} =====")
|
||||
for r in bad:
|
||||
print(f"\n#{r['idx']} 위반: nego={r['nego']} wild={r['wild']} anchor={r['anchor']}")
|
||||
for v in r["violations"]:
|
||||
print(" -", v)
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
sys.exit(0 if not bad else 1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@ -53,7 +53,7 @@ async def test_4_4_company_id_auto_onboard():
|
||||
eng = await _reg().get_engine(COMPANY_ID)
|
||||
assert eng.tenant_id == COMPANY_ID
|
||||
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
|
||||
assert eng.action_space_size == 11 # base 기본 카드(162×11 정합)
|
||||
assert eng.action_space_size == 9 # DB 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
assert eng.state_space_size == 162
|
||||
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ async def test_selected_cards_only_are_played(db_engine):
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그)
|
||||
assert eng.action_space_size == 11 # 카탈로그 11장(NGC-001~011)
|
||||
assert eng.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
|
||||
svc = ChatService()
|
||||
played = []
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"""카드 전술 재설계 검증 — "멘트 카드 → 전술 카드" (가격 행동 실행 계층).
|
||||
"""카드 전술 검증 — "스크립트에 꽂힌 변수가 곧 전술" (파싱 + 변수별 유효조건 + tactic JSONB).
|
||||
|
||||
① 카운터 산식 결정론 + min(counter, target) 클램프 + 무의미 카운터(HOLD 강등)
|
||||
② 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||
③ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||
④ 선택형 와일드카드(WC-05 중간값 절충) 발동 — 1.02~1.05 구간 갭 해소
|
||||
⑤ E2E: 견적 선택 카드(NGC-009 조건부 가격 조정)의 카운터를 수락하면 settled=target
|
||||
⑥ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||
① 제안가 파싱(마지막 제안가 변수) + 변수별 계산식 결정론
|
||||
② 변수 공통 유효조건 — 목표가 초과·제시가 이상이면 미발동(클램프 아님 — IMK 8AB0 회귀)
|
||||
③ 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||
④ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||
⑤ 와일드 진입 — 종결 전용 카드 예약(중반 미발동) + 카드 이력 공유(중복 발동 차단, IMK BB9A 회귀)
|
||||
⑥ E2E: 견적 선택 카드(NGC-010 목표가 제안)의 카운터를 수락하면 settled=target
|
||||
⑦ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -15,7 +16,8 @@ from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
|
||||
from negotiation.cards.domain.tactics import (
|
||||
PriceAction, TacticSpec, compute_counter, tactic_available, tactic_for,
|
||||
CardSpec, HOLD, available, build_card_spec, compute_offer,
|
||||
is_played, mark_played, parse_offer_variable, playable, spec_from_context,
|
||||
)
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
@ -23,6 +25,12 @@ from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||
|
||||
# 엔진 단위 테스트용 카드 스펙(로더가 DB 스크립트 파싱으로 만드는 것과 같은 형태).
|
||||
_SPECS = {
|
||||
"WC-02": {"offer_variable": "target_mid_price", "min_round": 1, "closing": False},
|
||||
"WC-05": {"offer_variable": "middle_price", "min_round": 1, "closing": True},
|
||||
}
|
||||
|
||||
|
||||
def _engine() -> ChatEngine:
|
||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||
@ -31,47 +39,134 @@ def _engine() -> ChatEngine:
|
||||
|
||||
def _session(step="가격협상_확인", **ctx_over):
|
||||
ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100,
|
||||
"round": 1, "allow_selected_wildcards": False}
|
||||
"round": 1, "allow_selected_wildcards": False, "card_specs": dict(_SPECS)}
|
||||
ctx.update(ctx_over)
|
||||
return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea",
|
||||
company_id="imarketkorea", step=step, action_space_size=0, context=ctx)
|
||||
|
||||
|
||||
# ---- ① 카운터 산식 (결정론 + 가드레일 클램프) --------------------------------
|
||||
def test_counter_formulas_and_clamp():
|
||||
# ---- ① 제안가 파싱 + 계산식 ---------------------------------------------------
|
||||
def test_parse_offer_variable_last_offer_wins():
|
||||
"""제안가 변수가 여럿이면 마지막 것 — 카드 문장은 배경을 먼저, 제안을 마지막에 한다(WC-04)."""
|
||||
assert parse_offer_variable("적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안") == "target_price"
|
||||
assert parse_offer_variable("{target_price}원을 제안 드립니다") == "target_price"
|
||||
# 읽어주기 변수만 있으면 설득 카드 — 제안가 없음
|
||||
assert parse_offer_variable("시장가 {internet_lowest_price}원 안팎, 제시가 {prev_partner_price}원") is None
|
||||
assert parse_offer_variable("가격 변수 없는 설득 멘트") is None
|
||||
assert parse_offer_variable(None) is None
|
||||
# WC-05 정본: 읽어주기(직전 제안·제시가) 뒤 절충가 제안
|
||||
assert parse_offer_variable("당사 제안 {prev_customer_price}원과 귀사 제안 {prev_partner_price}원을 절반씩, {middle_price}원으로") == "middle_price"
|
||||
# negodata 에디터 칩 표기(anchor_price)도 앵커가 제안으로 인식 — DB 시드 표기(anchoring_price)의 별칭
|
||||
assert parse_offer_variable("예산 한도는 {anchor_price}원입니다") == "anchor_price"
|
||||
|
||||
|
||||
def test_build_card_spec_merges_script_and_tactic():
|
||||
spec = build_card_spec("{target_price}원으로 제안", {"min_round": 2, "closing": True})
|
||||
assert spec == CardSpec(offer_variable="target_price", min_round=2, closing=True)
|
||||
# tactic 없음 → 기본값. offer_variable override 는 파싱보다 우선.
|
||||
assert build_card_spec("설득 멘트", None) == HOLD
|
||||
assert build_card_spec("멘트", {"offer_variable": "anchoring_price"}).offer_variable == "anchoring_price"
|
||||
|
||||
|
||||
def test_offer_formulas():
|
||||
ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000}
|
||||
assert compute_counter(tactic_for("NGC-009"), ctx) == 10000 # COUNTER_TARGET
|
||||
assert compute_counter(tactic_for("NGC-007"), ctx) == 9900 # COUNTER_ANCHOR
|
||||
assert compute_counter(tactic_for("WC-02"), ctx) == 9950 # (anchor+target)/2
|
||||
# COUNTER_MID: 갑 직전 포지션 폴백 = anchor → (9900+11000)/2 = 10450 → target 클램프
|
||||
assert compute_counter(tactic_for("WC-05"), ctx) == 10000
|
||||
# 갑 직전 포지션이 있으면 그 기준: (9800+11000)/2 = 10400 → 역시 클램프 10000
|
||||
assert compute_counter(tactic_for("WC-05"), dict(ctx, prev_customer_price=9800)) == 10000
|
||||
# 클램프 미발동 구간: (9900+10050)/2 = 9975 ≤ target → 10원 단위 반올림 9980
|
||||
assert compute_counter(tactic_for("WC-05"), dict(ctx, input_price=10050)) == 9980
|
||||
offer = lambda var, c=None: compute_offer(CardSpec(offer_variable=var), c or ctx) # noqa: E731
|
||||
assert offer("target_price") == 10000
|
||||
assert offer("anchoring_price") == 9900
|
||||
assert offer("target_mid_price") == 9950 # (anchor+target)/2
|
||||
# 절충가: 갑 직전 포지션 폴백 = anchor → (8900+9500)/2 = 9200
|
||||
assert offer("middle_price", dict(ctx, input_price=9500, anchor_price=8900)) == 9200
|
||||
# 갑 직전 포지션이 있으면 그 기준: (9000+9500)/2 = 9250
|
||||
assert offer("middle_price", dict(ctx, input_price=9500, prev_customer_price=9000)) == 9250
|
||||
|
||||
|
||||
def test_counter_meaningless_degrades_to_hold():
|
||||
"""협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(순수 설득 유지)."""
|
||||
# ---- ② 변수 공통 유효조건 — 미발동(클램프 아님) --------------------------------
|
||||
def test_offer_over_target_does_not_fire_imk_8ab0():
|
||||
"""IMK 8AB0 회귀: 목표가 9,000 / 제시가 9,500 → 절충가 (8,910+9,500)/2 = 9,205 > 목표가.
|
||||
구현이 목표가로 깎아 부르면 '중간에서 만나자며 목표가를 부르는' 모순 — 클램프가 아니라 미발동이 정답."""
|
||||
ctx = {"input_price": 9500, "anchor_price": 8910, "target_price": 9000}
|
||||
assert compute_offer(CardSpec(offer_variable="middle_price"), ctx) is None
|
||||
|
||||
|
||||
def test_offer_at_or_above_input_price_does_not_fire():
|
||||
"""협력사 제시가가 이미 제안가 이하면 부를 이유가 없다 → 미발동."""
|
||||
ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000}
|
||||
assert compute_counter(tactic_for("NGC-009"), ctx) is None # target(10000) ≥ 제시가
|
||||
assert compute_counter(TacticSpec(PriceAction.COUNTER_ANCHOR), dict(ctx, input_price=9900)) is None
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) is None # target ≥ 제시가
|
||||
assert compute_offer(CardSpec(offer_variable="anchoring_price"), dict(ctx, input_price=9900)) is None
|
||||
|
||||
|
||||
def test_unknown_card_falls_back_to_hold():
|
||||
"""미등록 카드번호(데모 NGC-B*, 커스텀 COMP-*)는 HOLD — 기존 동작 그대로."""
|
||||
spec = tactic_for("NGC-B003")
|
||||
assert spec.price_action is PriceAction.HOLD
|
||||
assert compute_counter(spec, {"input_price": 11000, "target_price": 10000}) is None
|
||||
assert tactic_available(spec, {}) is True
|
||||
def test_offer_without_materials_does_not_fire():
|
||||
"""재료 결측(목표가·제시가·앵커) — 어떤 변수도 미발동."""
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), {"input_price": 11000}) is None # 목표가 없음
|
||||
assert compute_offer(CardSpec(offer_variable="anchoring_price"),
|
||||
{"input_price": 11000, "target_price": 10000}) is None # 앵커 없음
|
||||
assert compute_offer(HOLD, {"input_price": 11000, "target_price": 10000}) is None # 설득 카드
|
||||
assert compute_offer(CardSpec(offer_variable="없는변수"), {"input_price": 11000, "target_price": 10000}) is None
|
||||
|
||||
|
||||
def test_tactic_availability_conditions():
|
||||
assert tactic_available(tactic_for("WC-04"), {"round": 1}) is False # min_round=2
|
||||
assert tactic_available(tactic_for("WC-04"), {"round": 2}) is True
|
||||
def test_available_min_round_and_closing_phase():
|
||||
spec2 = CardSpec(offer_variable="target_price", min_round=2)
|
||||
assert available(spec2, {"round": 1}) is False # min_round 미만
|
||||
assert available(spec2, {"round": 2}) is True
|
||||
closing = CardSpec(offer_variable="middle_price", closing=True)
|
||||
assert available(closing, {"round": 1}) is False # 종결 전용 — 중반 미발동(예약)
|
||||
assert available(closing, {"round": 1}, closing_phase=True) is True
|
||||
assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만
|
||||
|
||||
|
||||
# ---- ② 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||
def test_tactic_offer_variable_overrides_parse():
|
||||
"""검증: tactic.offer_variable 명시 지정(negodata 셀렉트) — 파싱(마지막 변수) 대신 지정 변수 사용.
|
||||
기대결과: 멘트 마지막이 target_price 여도 지정한 anchoring_price 가 제안가 변수가 된다."""
|
||||
script = "적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안 드립니다."
|
||||
assert build_card_spec(script).offer_variable == "target_price" # 자동: 마지막 변수
|
||||
spec = build_card_spec(script, {"offer_variable": "anchoring_price"})
|
||||
assert spec.offer_variable == "anchoring_price" # 명시 지정이 우선
|
||||
|
||||
|
||||
def test_available_requires_context_value():
|
||||
"""검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고,
|
||||
기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀)."""
|
||||
spec = build_card_spec("유사 거래는 {internet_lowest_price}원 안팎에서 합의되고 있습니다.")
|
||||
assert spec.requires == ("internet_lowest_price",)
|
||||
assert available(spec, {"round": 1}) is False # 결측
|
||||
assert available(spec, {"round": 1, "internet_lowest_price": 0}) is False # 미수집(0)
|
||||
assert available(spec, {"round": 1, "internet_lowest_price": 6300}) is True
|
||||
# 일반 카드는 requires 없음 — 기존 동작 그대로.
|
||||
assert build_card_spec("귀사와의 협력을 소중히 생각합니다.").requires == ()
|
||||
|
||||
|
||||
def test_offer_monotonic_no_regression():
|
||||
"""검증: 역행 금지(IMK 논의 — 절충 16,980 후 예산 상한 16,810 제시) 재현.
|
||||
기대결과: 직전 당사 제안보다 낮은 제안가 카드는 미발동(설득 폴백으로도 안 나감).
|
||||
직전 제안이 없으면 앵커 제시 허용, 같은 금액 재제시 허용, 더 높은 제안은 정상."""
|
||||
anchor_card = CardSpec(offer_variable="anchoring_price")
|
||||
ctx = {"round": 2, "target_price": 17_300, "anchor_price": 16_810, "input_price": 17_500}
|
||||
assert compute_offer(anchor_card, ctx) == 16_810 # 첫 카운터 전(포지션=앵커): 같은 금액 → 허용
|
||||
ctx["prev_customer_price"] = 16_980 # 절충 카드가 이미 16,980 을 부른 상태
|
||||
assert compute_offer(anchor_card, ctx) is None # 앵커 16,810 은 역행 → 미발동
|
||||
assert playable(anchor_card, ctx) is False # 멘트에 금액이 박히므로 설득 폴백도 금지
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) == 17_300 # 상향 제안은 정상
|
||||
|
||||
|
||||
def test_played_history_is_shared_by_number():
|
||||
ctx = {}
|
||||
assert is_played(ctx, "WC-05") is False
|
||||
mark_played(ctx, "WC-05")
|
||||
assert is_played(ctx, "WC-05") is True
|
||||
mark_played(ctx, "WC-05") # 재기록해도 1건 유지
|
||||
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||
mark_played(ctx, None) # no-op(폴백 최후통첩)
|
||||
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||
|
||||
|
||||
def test_spec_from_context_reads_snapshot_and_falls_back_to_hold():
|
||||
ctx = {"card_specs": dict(_SPECS)}
|
||||
assert spec_from_context(ctx, "WC-05") == CardSpec(offer_variable="middle_price", min_round=1, closing=True)
|
||||
assert spec_from_context(ctx, "NGC-B003") == HOLD # 미등록 카드(데모) 폴백
|
||||
assert spec_from_context({}, "WC-05") == HOLD # 스펙 미적재(구세션·데모) 폴백
|
||||
|
||||
|
||||
# ---- ③ 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||
def test_accept_counter_settles_at_counter_price():
|
||||
eng = _engine()
|
||||
s = _session(step="가격협상_카운터", pending_counter_price=10000)
|
||||
@ -102,7 +197,7 @@ def test_wildcard_1pct_decline_keeps_original_price():
|
||||
assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용
|
||||
|
||||
|
||||
# ---- ③ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||
# ---- ④ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||
def test_success_step_guard_rejects_over_target():
|
||||
eng = _engine()
|
||||
s = _session(input_price=10800, target_price=10000)
|
||||
@ -110,20 +205,46 @@ def test_success_step_guard_rejects_over_target():
|
||||
assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제
|
||||
|
||||
|
||||
# ---- ④ 선택형 와일드카드 발동 (1.02~1.05 구간 갭 해소) -------------------------
|
||||
def test_selected_wildcard_fires_in_entry_zone():
|
||||
# ---- ⑤ 와일드 진입 — 종결 예약 + 중복 차단 (IMK BB9A 회귀) ---------------------
|
||||
def test_selected_wildcard_fires_in_entry_zone_and_records_position():
|
||||
eng = _engine()
|
||||
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + 비종결 WC-02 선택
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||
selected_wild_card_numbers=["WC-02"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "wild_card_dynamic"
|
||||
# 제안가 = (anchor 10000 + target 10100)/2 = 10050 ≤ target — 그대로 제시(클램프 없음)
|
||||
assert s.context["pending_counter_price"] == 10050
|
||||
assert s.context["prev_customer_price"] == 10050 # 갑 포지션 기록 — "당사 제안" 멘트 정합(BB9A ③)
|
||||
assert s.context["active_wild_card_number"] == "WC-02"
|
||||
assert is_played(s.context, "WC-02") # 카드 이력 기록
|
||||
# 수락 → 그 가격으로 타결
|
||||
view = eng.advance(s, "수락")
|
||||
assert view.step == "협상완료" and s.context["input_price"] == 10050
|
||||
|
||||
|
||||
def test_closing_card_is_reserved_never_fires_mid_negotiation():
|
||||
"""종결 전용 카드(WC-05)는 entry 존이라도 중반에 안 나간다 — 종결 국면의 마지막 한 방으로 예약.
|
||||
(BB9A 중복의 절반: 중반에 당겨 쓴 카드를 종결에서 또 쓰던 경로 차단.)"""
|
||||
eng = _engine()
|
||||
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + WC-05 선택
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||
selected_wild_card_numbers=["WC-05"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "wild_card_dynamic"
|
||||
# 카운터 = (anchor 10000 + 10300)/2 = 10150 → target(10100) 클램프
|
||||
assert s.context["pending_counter_price"] == 10100
|
||||
assert s.context["active_wild_card_number"] == "WC-05"
|
||||
# 수락 → 그 가격으로 타결
|
||||
view = eng.advance(s, "수락")
|
||||
assert view.step == "협상완료" and s.context["input_price"] == 10100
|
||||
assert view.step == "가격협상" # 종결 카드뿐 → 일반 카드 플레이로
|
||||
assert "active_wild_card_number" not in s.context
|
||||
assert not is_played(s.context, "WC-05") # 안 나갔으니 이력도 없음
|
||||
# 종결 국면에선 발동 가능 + 이력 없음 — 서비스 종결 루프가 이 카드를 쓴다
|
||||
spec = spec_from_context(s.context, "WC-05")
|
||||
assert available(spec, s.context, closing_phase=True) is True
|
||||
|
||||
|
||||
def test_played_wildcard_is_skipped_on_reentry():
|
||||
"""이미 쓴 카드는 같은 협상에서 다시 안 나간다 — 다음 후보로 넘어간다."""
|
||||
eng = _engine()
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True, wildcard_used=False,
|
||||
selected_wild_card_numbers=["WC-02"], played_card_numbers=["WC-02"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "가격협상" # 유일 후보가 사용됨 → 발동 없음
|
||||
|
||||
|
||||
def test_unselected_wildcard_zone_still_falls_to_nego():
|
||||
@ -186,7 +307,7 @@ def test_vars_for_supplies_tactic_variables():
|
||||
assert v1["target_mid_price"] == 10100
|
||||
|
||||
|
||||
# ---- ⑤⑥ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||
# ---- ⑥⑦ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||
from sqlalchemy import column, delete, insert, select, table # noqa: E402
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||
@ -211,24 +332,30 @@ _T_QUOTATIONS = table(
|
||||
)
|
||||
_T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card")
|
||||
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
|
||||
_T_VWC = table("version_wild_cards", column("vwc_id"), column("version_id"), column("wild_card_id"), schema="card")
|
||||
_T_WILD = table("wild_cards", column("wild_card_id"), column("number"), column("deleted"), schema="card")
|
||||
|
||||
|
||||
async def _card_uuid(number: str):
|
||||
async def _card_uuid(number: str, *, wild=False):
|
||||
tbl, pk = (_T_WILD, _T_WILD.c.wild_card_id) if wild else (_T_NEGO, _T_NEGO.c.nego_card_id)
|
||||
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(
|
||||
s, select(_T_NEGO.c.nego_card_id).where(
|
||||
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
|
||||
s, select(pk).where(tbl.c.number == number, tbl.c.deleted == False).limit(1)) # noqa: E712
|
||||
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
|
||||
async def _seed_quote_session(sid, selected_numbers, wild_numbers=(), target=10000, anchor=9900):
|
||||
qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
card_ids = {}
|
||||
card_ids, wild_ids = {}, {}
|
||||
for n in selected_numbers:
|
||||
card_ids[n] = await _card_uuid(n)
|
||||
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||
for n in wild_numbers:
|
||||
wild_ids[n] = await _card_uuid(n, wild=True)
|
||||
assert wild_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||
|
||||
def _seed(s_):
|
||||
async def run(s):
|
||||
@ -243,6 +370,11 @@ async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
|
||||
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
for n in wild_numbers:
|
||||
e = await DB_SESSION_MNG.add(s, insert(_T_VWC).values(
|
||||
vwc_id=_uuid.uuid4(), version_id=ver_id, wild_card_id=wild_ids[n]))
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
|
||||
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
|
||||
qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1,
|
||||
@ -260,17 +392,18 @@ async def _cleanup(sid, qid, ver_id):
|
||||
[DBType.MAIN.value],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_VWC).where(_T_VWC.c.version_id == ver_id)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||
"""견적 선택 카드 NGC-009(조건부 가격 조정 → COUNTER_TARGET)의 카운터를 수락하면
|
||||
합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||
"""견적 선택 카드 NGC-010(향후 거래 연계 — 스크립트 {target_price} 파싱 → 목표가 제안)의
|
||||
카운터를 수락하면 합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-009"])
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-010"])
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
@ -279,9 +412,9 @@ async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||
r = None
|
||||
for ui in [None, "확인", "예", "확인", "11000", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||
# 가격협상 카드 턴 → NGC-009 카운터(target) 제시 스텝
|
||||
# 가격협상 카드 턴 → NGC-010 카운터(target) 제시 스텝
|
||||
assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}"
|
||||
assert r.card_id == "NGC-009"
|
||||
assert r.card_id == "NGC-010"
|
||||
assert r.input_options == ["수락", "다른 가격 제시"]
|
||||
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||
@ -297,7 +430,7 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||
그래도 거절 → 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다."""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # HOLD 카드 1장 → 빠른 소진
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # 설득 카드 1장 → 빠른 소진
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
@ -321,3 +454,46 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||
assert r.settled_price is None # 초과가 타결 없음
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bb9a_no_duplicate_wildcard_and_real_middle(db_engine):
|
||||
"""IMK BB9A 재현 E2E — 와일드카드 2장(WC-02·WC-05) + 설득 카드 1장.
|
||||
|
||||
기대 흐름(수정 후):
|
||||
· 중반 와일드 진입 = 비종결 WC-02 (종결 전용 WC-05 는 예약 — 구현 전엔 WC-05 가 먼저 나갔다)
|
||||
· 종결 국면 = WC-05, 절충가 = (당사 직전 제안 + 협력사 제시가)/2 실계산 (구현 전엔 목표가로 클램프)
|
||||
· 같은 카드 2회 발동 없음 + 종결 발동도 card_id 기록
|
||||
"""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"], wild_numbers=["WC-02", "WC-05"])
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
session_id = str(sid)
|
||||
r = None
|
||||
# 10300: 1pct 존(≤ 9900×1.02=10098) 밖, entry 존(≤ 10395) 안 → 선택형 와일드 발동 구간
|
||||
for ui in [None, "확인", "예", "확인", "10300", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||
assert r.step == "wild_card_dynamic"
|
||||
assert r.card_id == "WC-02" # 종결 전용 WC-05 가 아니라 비종결 카드
|
||||
# WC-02 제안가 = (anchor 9900 + target 10000)/2 = 9950
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
|
||||
# 10010 재제시 → 설득 카드(NGC-003) 1장 소진
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10010"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||
assert r.step == "가격협상" and r.card_id == "NGC-003"
|
||||
# 10005 재제시 → 카드 소진 → 종결 국면: WC-05 절충가 = (9950 + 10005)/2 = 9980 (≤ target)
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10005"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||
assert r.step == "가격협상_카운터"
|
||||
assert r.card_id == "WC-05" # 종결 발동도 카드 기록(구현 전 null)
|
||||
assert "9980" in r.script # 실제 절충가 — 목표가(10000) 클램프 아님
|
||||
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||
assert r.step == "협상완료"
|
||||
assert r.settled_price == 9980 # 표시가 = 타결가
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
|
||||
@ -186,11 +186,13 @@ async def test_loader_with_crud_double(db_engine):
|
||||
|
||||
class _FakeCRUD(INegoContextCRUD):
|
||||
async def get_session_row(self, cdb, session_id):
|
||||
# (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제
|
||||
return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
||||
# (qt_type, target, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id)
|
||||
# — 재견적(2)·앵커 미박제·타결상한 52,500(목표가 +5%)
|
||||
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
async def get_item_price(self, cdb, item_id):
|
||||
return ErrorType.SUCCESS, 7000
|
||||
async def get_item_baseline(self, cdb, item_id):
|
||||
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
|
||||
return ErrorType.SUCCESS, (7000, "매입가", {"supplier": "공급업체"})
|
||||
|
||||
async def get_item_lowest_price(self, cdb, item_id):
|
||||
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
|
||||
@ -217,14 +219,22 @@ async def test_loader_with_crud_double(db_engine):
|
||||
return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
|
||||
|
||||
async def get_quotation_card_numbers(self, cdb, quotation_id):
|
||||
return ErrorType.SUCCESS, (["NGC-003", "NGC-008"], ["WC-02"]) # 견적 선택 카드
|
||||
# 행 = (number, script, tactic) — 스크립트 파싱 + tactic JSONB 로 card_specs 를 만든다
|
||||
return ErrorType.SUCCESS, (
|
||||
[("NGC-003", "설득 멘트(가격 변수 없음)", None),
|
||||
("NGC-008", "시장가 {internet_lowest_price}원 인용(읽기 전용 변수)", None)],
|
||||
[("WC-02", "이에 당사는 {target_mid_price}원을 역으로 제안 드립니다.", None)],
|
||||
)
|
||||
|
||||
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
|
||||
assert ctx is not None
|
||||
assert ctx.rq_type == "재견적" # qt_type=2(1:N)
|
||||
assert ctx.target_price == 50000
|
||||
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
||||
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
|
||||
assert ctx.item_price == 7000
|
||||
assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
|
||||
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
|
||||
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
|
||||
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
|
||||
assert ctx.partner_name == "테스트협력사"
|
||||
@ -234,6 +244,14 @@ async def test_loader_with_crud_double(db_engine):
|
||||
assert ctx.partner_type is PartnerType.NONE
|
||||
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
|
||||
assert ctx.selected_wild_card_numbers == ["WC-02"]
|
||||
# 카드 전술 확정 — 설득 카드/읽기 전용 변수는 제안가 없음, WC-02 는 스크립트 파싱으로 중간가.
|
||||
assert ctx.card_specs["NGC-003"]["offer_variable"] is None
|
||||
assert ctx.card_specs["NGC-008"]["offer_variable"] is None # 인터넷 최저가는 읽어주기 변수 — 제안가 아님
|
||||
# 시장가 인용 카드는 최저가 결측 세션에서 미발동하도록 requires 로 표시된다(토큰 노출 방지).
|
||||
assert ctx.card_specs["NGC-008"]["requires"] == ["internet_lowest_price"]
|
||||
assert ctx.card_specs["WC-02"] == {
|
||||
"offer_variable": "target_mid_price", "min_round": 1, "closing": False, "requires": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -49,9 +49,14 @@ def test_wildcard_threshold_is_config_driven():
|
||||
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
|
||||
view = _engine().advance(_session(10800), "예")
|
||||
assert view.step == "가격협상"
|
||||
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동.
|
||||
# 1%가(10800×0.99=10692)도 제안가 공통 유효조건(≤목표가)을 타므로 목표가를 그 위로 둔다 —
|
||||
# 기본 target(10100)이면 초과 제시 금지 규칙에 걸려 발동하지 않는 게 새 정답.
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800, target_price=11000), "예")
|
||||
assert view.step == "wild_card_1pct"
|
||||
# 목표가가 1%가 아래면(초과 제시 금지) 완화 임계라도 미발동 — 수락해도 결렬되는 모순 제안 차단.
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||
assert view.step == "가격협상"
|
||||
|
||||
|
||||
def test_max_counter_rounds_is_config_driven():
|
||||
|
||||
190
agent/tests/test_negotiation_invariants.py
Normal file
190
agent/tests/test_negotiation_invariants.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""협상 불변식 시나리오 하네스 — 실서비스 스택(ChatService + 실 DB 카드)으로 13개 협상을 완주시키고,
|
||||
IMK 가 잡은 두 부류의 사고(같은 카드 반복 · 이상한 금액)가 어떤 흐름에서도 안 나는지 검사한다.
|
||||
|
||||
시나리오별 기대 이벤트(카드가 나간 턴의 step·카드·금액)를 정확히 못박고, 공통 불변식을 전 턴에 건다:
|
||||
· 카드 중복 없음 — 한 협상에서 같은 card_id 2회 발동 금지
|
||||
· 카드 자리 규칙 — 종결 전용(WC-03·05)은 가격협상_카운터에서만, 비종결 와일드는 wild_card_dynamic 에서만
|
||||
· 타결가 ≤ 목표가 — 어떤 성공 경로도 목표가 초과로 안 끝남
|
||||
· 카운터 멘트의 금액 = 수락 시 타결가 (표시가=타결가)
|
||||
"""
|
||||
|
||||
import uuid as _uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from router.v1.chat.protocol import Req_Chat
|
||||
from services.chat_service import ChatService, reset_sessions
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
from tenancy.registry import TenantEngineRegistry
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session
|
||||
|
||||
# 종결 전용 와일드카드(DB tactic 시드와 동일) — 자리 규칙 검사용.
|
||||
_CLOSING_WILDS = {"WC-03", "WC-05"}
|
||||
_NONCLOSING_WILDS = {"WC-01", "WC-02", "WC-04"}
|
||||
# 카드가 나갈 수 있는 스텝(이벤트로 수집).
|
||||
_CARD_STEPS = {"가격협상", "wild_card_dynamic", "wild_card_1pct", "가격협상_카운터"}
|
||||
_BOILERPLATE = [None, "확인", "예", "확인"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
name: str
|
||||
inputs: list # 서두(안내~기존가격제시) 이후의 협력사 입력 시퀀스
|
||||
# 기대 이벤트: (step, card, offer_substring). card="NGC-*" 는 임의 협상카드(중복만 검사).
|
||||
events: list
|
||||
settled: Optional[int] # 기대 타결가(원). None=결렬
|
||||
nego: list = field(default_factory=lambda: ["NGC-001"])
|
||||
wild: list = field(default_factory=list)
|
||||
target: int = 10_000
|
||||
anchor: int = 9_900
|
||||
|
||||
|
||||
# 밴드(기본 target 10000·anchor 9900): 1% 존 ≤ 10,098 · 진입 존 ≤ 10,395.
|
||||
SCENARIOS = [
|
||||
# S01 BB9A 재현 — 중반 비종결 WC-02, 종결 WC-05 실절충가. 같은 카드 2회 없음.
|
||||
Scenario("S01_bb9a_mid_wc02_close_wc05",
|
||||
["10300", "예", "다른 가격 제시", "10010", "예", "10005", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-05", "9980")],
|
||||
settled=9980, wild=["WC-02", "WC-05"]),
|
||||
# S02 8AB0 재현 — 절충가(9,205)가 목표가(9,000) 초과 → WC-05 미발동, 목표가 최후통첩(카드 없음).
|
||||
Scenario("S02_8ab0_middle_over_target_skips",
|
||||
["9500", "예", "9500", "예", "다른 가격 제시", "9500", "예", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", None, "9000")],
|
||||
settled=None, wild=["WC-05"], target=9_000, anchor=8_910),
|
||||
# S03 와일드 5장 전부 + 협상카드 2장 — 중반 1장(WC-01)·종결 1장(WC-03)만, 협상카드는 서로 다른 2장.
|
||||
Scenario("S03_five_wilds_full_run",
|
||||
["10300", "예", "다른 가격 제시", "10200", "예", "10150", "예", "10100", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-01", "10000"),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=10_000, nego=["NGC-001", "NGC-003"],
|
||||
wild=["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]),
|
||||
# S04 종결 전용 와일드만 담김 + 제시가가 진입 존에 머무름 — 소진 판정이 막히지 않고
|
||||
# 종결로 넘어간다(프로브 픽스 회귀: 픽스 전엔 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상).
|
||||
Scenario("S04_closing_only_wild_no_deadlock",
|
||||
["10300", "예", "10250", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", None, "10000")], # WC-05 절충 10,075>목표가 → 미발동 → 최후통첩
|
||||
settled=10_000, wild=["WC-05"]),
|
||||
# S05 1% 존 — 시스템 1% 카드, 수락 시 표시 금액 그대로 타결.
|
||||
Scenario("S05_one_pct_zone_accept",
|
||||
["10050", "예", "예", "확인"],
|
||||
[("wild_card_1pct", None, "9950")],
|
||||
settled=9_950),
|
||||
# S06 앵커 이하 즉시 타결 — 카드 0장.
|
||||
Scenario("S06_priority_match_no_cards",
|
||||
["9800", "예", "확인"],
|
||||
[],
|
||||
settled=9_800),
|
||||
# S07 목표가 초과 고수 → 설득 1장 → 최후통첩 → 결렬.
|
||||
Scenario("S07_hold_high_fails",
|
||||
["11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||
[("가격협상", "NGC-003", None),
|
||||
("가격협상_카운터", None, "10000")],
|
||||
settled=None, nego=["NGC-003"]),
|
||||
# S08 협상카드 카운터(NGC-010 목표가 제안) 수락 — 협상카드도 카운터 스텝을 쓴다.
|
||||
Scenario("S08_nego_counter_accept",
|
||||
["11000", "예", "수락", "확인"],
|
||||
[("가격협상_카운터", "NGC-010", "10000")],
|
||||
settled=10_000, nego=["NGC-010"]),
|
||||
# S09 min_round=2 — WC-04 는 1라운드 진입 존에서 안 나가고 2라운드에 나간다.
|
||||
Scenario("S09_min_round_two_defers_wc04",
|
||||
["10300", "예", "10200", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("wild_card_dynamic", "WC-04", "10000")],
|
||||
settled=10_000, wild=["WC-04"]),
|
||||
# S10 종결 체인 폴백 — WC-05 무효(절충 10,175>목표) → 다음 종결 WC-03 발동.
|
||||
Scenario("S10_closing_chain_falls_to_wc03",
|
||||
["10500", "예", "10450", "예", "다른 가격 제시", "10450", "예", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=None, wild=["WC-05", "WC-03"]),
|
||||
# S11 중반+종결 콤보 — WC-02 중반, 종결은 WC-05 무효 건너뛰고 WC-03. 전 카드 1회씩.
|
||||
Scenario("S11_mid_and_closing_combo",
|
||||
["10300", "예", "다른 가격 제시", "10400", "예", "10350", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=10_000, wild=["WC-02", "WC-05", "WC-03"]),
|
||||
# S12 라운드 상한 — 협상카드 3장 각 1회(중복 없음) 후 상한 도달 → 최후통첩 → 결렬.
|
||||
Scenario("S12_round_cap_distinct_nego_cards",
|
||||
["11000", "예", "11000", "예", "11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||
[("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상_카운터", None, "10000")],
|
||||
settled=None, nego=["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005"]),
|
||||
# S13 재생성 아님·재료 극단 — 앵커 미박제 세션(anchor=target 폴백)에서도 초과 제시·중복 없음.
|
||||
Scenario("S13_anchor_equals_target_fallback",
|
||||
["10300", "예", "10200", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")], # WC-05 절충 (10000+10200)/2=10100>목표 → 스킵
|
||||
settled=10_000, wild=["WC-05", "WC-03"], anchor=10_000),
|
||||
# S14 역행 금지(IMK 논의 재현) — 절충 카드(9,950) 뒤에 예산 상한 카드(NGC-007, 앵커 9,900)가
|
||||
# 선택돼 있어도 발동하지 않는다(설득 폴백으로도 안 나감). 낼 카드가 없어져 종결(목표가 최후통첩)로.
|
||||
Scenario("S14_no_offer_regression",
|
||||
["10300", "예", "다른 가격 제시", "10200", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상_카운터", None, "10000")], # NGC-007 이벤트가 없어야 함(역행 차단)
|
||||
settled=10_000, nego=["NGC-007"], wild=["WC-02"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sc", SCENARIOS, ids=[s.name for s in SCENARIOS])
|
||||
async def test_negotiation_invariants(db_engine, sc: Scenario):
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, sc.nego, wild_numbers=sc.wild,
|
||||
target=sc.target, anchor=sc.anchor)
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
trace, settled, outcome = [], None, None
|
||||
for ui in [*_BOILERPLATE, *sc.inputs]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||
assert r.result.success is True, f"{sc.name}: 턴 실패 input={ui} msg={r.msg}"
|
||||
trace.append(r)
|
||||
if r.settled_price is not None:
|
||||
settled = r.settled_price
|
||||
if r.chat_end:
|
||||
outcome = r.outcome
|
||||
|
||||
# ── 기대 이벤트(카드/카운터 턴) 정확 일치 ──
|
||||
events = [r for r in trace if r.step in _CARD_STEPS]
|
||||
got = [(r.step, r.card_id) for r in events]
|
||||
assert len(events) == len(sc.events), f"{sc.name}: 이벤트 수 {got} ≠ 기대 {sc.events}"
|
||||
for r, (step, card, offer) in zip(events, sc.events):
|
||||
assert r.step == step, f"{sc.name}: step {r.step} ≠ {step} (전체 {got})"
|
||||
if card == "NGC-*":
|
||||
assert r.card_id and r.card_id.startswith("NGC-"), f"{sc.name}: 협상카드 기대, 실제 {r.card_id}"
|
||||
else:
|
||||
assert r.card_id == card, f"{sc.name}: card {r.card_id} ≠ {card} (전체 {got})"
|
||||
if offer is not None:
|
||||
assert offer in (r.script or ""), f"{sc.name}: 멘트에 금액 {offer} 없음 — {r.script[:80]}"
|
||||
|
||||
# ── 공통 불변식 ──
|
||||
played = [r.card_id for r in events if r.card_id]
|
||||
assert len(played) == len(set(played)), f"{sc.name}: 카드 중복 발동 {played}"
|
||||
for r in events:
|
||||
if r.card_id in _CLOSING_WILDS:
|
||||
assert r.step == "가격협상_카운터", f"{sc.name}: 종결 카드 {r.card_id}가 중반({r.step})에 발동"
|
||||
if r.card_id in _NONCLOSING_WILDS:
|
||||
assert r.step == "wild_card_dynamic", f"{sc.name}: 비종결 와일드 {r.card_id}가 {r.step}에서 발동"
|
||||
|
||||
# ── 결말 ──
|
||||
if sc.settled is None:
|
||||
assert outcome == "failure" and settled is None, f"{sc.name}: 결렬 기대, settled={settled} outcome={outcome}"
|
||||
else:
|
||||
assert outcome == "success", f"{sc.name}: 타결 기대, outcome={outcome}"
|
||||
assert settled == sc.settled, f"{sc.name}: 타결가 {settled} ≠ 기대 {sc.settled}"
|
||||
assert settled <= sc.target, f"{sc.name}: 목표가 초과 타결 {settled} > {sc.target}"
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
@ -35,7 +35,7 @@ async def test_two_tenants_distinct_engines():
|
||||
assert e1.mapper.get_card_id(0) == "NGC-001"
|
||||
assert e2.mapper.get_card_id(0) == "NGC-B001"
|
||||
# 차원
|
||||
assert e1.state_space_size == 162 and e1.action_space_size == 11
|
||||
assert e1.state_space_size == 162 and e1.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -69,7 +69,7 @@ async def test_unregistered_company_id_auto_onboards():
|
||||
reg = _registry()
|
||||
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
|
||||
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
|
||||
assert eng.action_space_size == 11 and eng.state_space_size == 162
|
||||
assert eng.action_space_size == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
|
||||
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
||||
assert reg.is_registered("imarketkorea") is True
|
||||
# 빈 키만 미등록 → KeyError
|
||||
|
||||
@ -69,27 +69,27 @@ async def test_cold_start_creates_warmstart_version(db_engine):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
|
||||
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||
"""카탈로그 카드 수 변경(7→9) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||
import uuid as _uuid
|
||||
cid = str(_uuid.uuid4())
|
||||
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (5,2)=0.9
|
||||
# 이 회사 활성 버전을 A=7 로 시드 + 셀 (5,2)=0.9
|
||||
repo = LearningRepository(cid)
|
||||
vid = await repo.get_or_create_active_version(
|
||||
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95,
|
||||
scope=2, version_name="old_v9")
|
||||
state_space_size=162, action_space_size=7, learning_rate=0.1, discount_factor=0.95,
|
||||
scope=2, version_name="old_v7")
|
||||
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
|
||||
|
||||
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션
|
||||
# 엔진(_base type:db → 카탈로그 9장) 로드 → 7≠9 감지 → 마이그레이션
|
||||
eng = await _reg().get_engine(cid)
|
||||
assert eng.action_space_size == 11
|
||||
assert eng.action_space_size == 9
|
||||
policy, new_vid, _ = await QTablePolicyStore.load(eng)
|
||||
assert str(new_vid) != str(vid) # 새 버전
|
||||
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
|
||||
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) fresh
|
||||
assert policy.qtable.q[5, 8] == 0.0 # 새 카드(action 8) fresh
|
||||
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
|
||||
# 새 버전이 활성 · 차원 11
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
|
||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -42,7 +42,10 @@ def test_requote_structure_preserved():
|
||||
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
|
||||
assert key in s
|
||||
assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
|
||||
assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
# 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
|
||||
assert s["배송형태선택"]["input_options"] == [
|
||||
"{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}",
|
||||
]
|
||||
|
||||
|
||||
def test_wildcard_present_and_merged():
|
||||
@ -170,3 +173,24 @@ async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
||||
|
||||
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
|
||||
assert out == "선택 카드 DB 멘트 **10200원**"
|
||||
|
||||
|
||||
def test_option_label_tokens_rendered():
|
||||
"""검증: 옵션에 회사 용어 토큰({label_delivery_type_*})이 있는 스텝을 정상 렌더·에러 재렌더로 출력.
|
||||
기대결과: 두 경로 모두 버튼 문자열이 기본 라벨(협력사배송 등)로 치환되고 토큰이 남지 않는다."""
|
||||
import os
|
||||
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
tenants = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||
cfg = TenantConfigLoader(tenants_dir=tenants, cache_ttl_seconds=0).load("_base")
|
||||
engine = ChatEngine(ScriptRepository(cfg, tenants), rq_type="재견적")
|
||||
session = ChatSession(session_id="s", tenant_id="_base", company_id="_base")
|
||||
|
||||
view = engine.render_step(session, "배송형태선택")
|
||||
assert view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
|
||||
# 에러 재렌더(잘못된 입력 등)도 같은 치환을 타야 한다 — raw 옵션이면 토큰이 버튼에 노출된다.
|
||||
err_view = engine._error(session, "다시 선택해 주세요.")
|
||||
assert err_view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
|
||||
@ -50,6 +50,7 @@ class Res_Me(Res_WebPacketProtocol):
|
||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
|
||||
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
|
||||
guide_notices: list = Field(default_factory=list, description="협상 유의사항 항목(companies.settings.guide_notices). 빈 값이면 포털 기본 문구")
|
||||
|
||||
|
||||
class Res_Logout(Res_WebPacketProtocol):
|
||||
@ -72,3 +73,4 @@ class Res_SessionBranding(Res_WebPacketProtocol):
|
||||
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
|
||||
logo_url: str = Field("", description="회사 로고 URL")
|
||||
primary_color: str = Field("", description="브랜드 색상(hex)")
|
||||
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")
|
||||
|
||||
@ -260,6 +260,7 @@ class AuthService:
|
||||
settings = settings or {}
|
||||
res.branding = settings.get("branding") or {}
|
||||
res.session_fields = settings.get("session_fields") or []
|
||||
res.guide_notices = settings.get("guide_notices") or []
|
||||
return res
|
||||
|
||||
async def session_branding(self, session_id: str) -> Res_SessionBranding:
|
||||
@ -279,6 +280,7 @@ class AuthService:
|
||||
res.service_name = branding.get("service_name") or ""
|
||||
res.logo_url = branding.get("logo_url") or ""
|
||||
res.primary_color = branding.get("primary_color") or ""
|
||||
res.helpdesk = branding.get("helpdesk") or []
|
||||
return res
|
||||
|
||||
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:
|
||||
|
||||
@ -266,6 +266,16 @@ class ChatService:
|
||||
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
|
||||
if "vat_yn" in _hidden:
|
||||
res.item_vat_yn = None
|
||||
|
||||
# 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
|
||||
# agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
|
||||
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||
_baseline = _features.get("nego_baseline_field")
|
||||
if _baseline not in ("price", "purchase_price"):
|
||||
# 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.
|
||||
_baseline = "purchase_price" if ("price" in _hidden and "purchase_price" not in _hidden) else "price"
|
||||
if _baseline == "purchase_price":
|
||||
res.item_price = item.purchase_price or 0
|
||||
return res
|
||||
|
||||
async def _ensure_in_progress(self, sess, quote) -> None:
|
||||
@ -452,24 +462,40 @@ class ChatService:
|
||||
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
||||
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
||||
|
||||
# 카드 번호(turn.card_id) → UUID 변환. step 으로 nego/wild 갈라 각 테이블 조회(번호가 겹칠 수 있어 종류로 구분).
|
||||
# 카드 번호(turn.card_id) → UUID 변환. 번호 정본 표기(NGC-/WC- prefix)로 종류를 가르고,
|
||||
# prefix 없는 구번호는 step 휴리스틱 폴백. 1차 조회가 비면 반대 테이블 재조회 —
|
||||
# 종결 전술의 와일드카드는 step 이 '가격협상_카운터'(wild 미시작)라 step 만으론 카드가
|
||||
# 영영 null 로 남았다(사용 카드 통계·화면 누락 원인).
|
||||
# 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다. (1% 인하 시스템 카드는 agent 가 card_id 미제공)
|
||||
card_uuid = None
|
||||
card_type = None
|
||||
if turn.card_id:
|
||||
is_wild = bool(turn.step and turn.step.startswith("wild"))
|
||||
if is_wild:
|
||||
card_uuid = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_wild_card_id_by_number(s, str(turn.card_id)),
|
||||
)
|
||||
card_type = 2
|
||||
number = str(turn.card_id)
|
||||
if number.startswith("WC"):
|
||||
wild_first = True
|
||||
elif number.startswith("NGC"):
|
||||
wild_first = False
|
||||
else:
|
||||
card_uuid = await DB_SESSION_MNG.execute_lambda(
|
||||
wild_first = bool(turn.step and turn.step.startswith("wild"))
|
||||
|
||||
async def _lookup(wild: bool):
|
||||
if wild:
|
||||
found = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_wild_card_id_by_number(s, number),
|
||||
)
|
||||
return found, 2
|
||||
found = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
|
||||
lambda s: self.chat_crud.get_nego_card_id_by_number(s, number),
|
||||
)
|
||||
card_type = 1
|
||||
return found, 1
|
||||
|
||||
card_uuid, card_type = await _lookup(wild_first)
|
||||
if card_uuid is None:
|
||||
card_uuid, card_type = await _lookup(not wild_first)
|
||||
if card_uuid is None:
|
||||
card_type = None
|
||||
|
||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, card_uuid=card_uuid, card_type=card_type)
|
||||
@ -610,7 +636,17 @@ class ChatService:
|
||||
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
|
||||
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
|
||||
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
|
||||
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
|
||||
# 회사가 배송유형 보기를 자기 용어로 바꿨으면(settings.labels['delivery_type.N']) 그 단어를 쓴다 —
|
||||
# 협상 중 공급사가 고른 보기와 요약 표기가 갈리지 않도록.
|
||||
item_delivery_label = ""
|
||||
if item and item.delivery_type is not None:
|
||||
_e2, _settings = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
|
||||
)
|
||||
_labels = (_settings.get("labels") or {}) if _e2 == ErrorType.SUCCESS and _settings else {}
|
||||
item_delivery_label = (_labels.get(f"delivery_type.{item.delivery_type}")
|
||||
or DeliveryType.label_of(item.delivery_type))
|
||||
|
||||
def _iso(dt):
|
||||
if dt is None:
|
||||
|
||||
@ -59,6 +59,7 @@ export interface Branding {
|
||||
logo_url?: string
|
||||
primary_color?: string
|
||||
email_header?: string
|
||||
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
|
||||
}
|
||||
|
||||
// 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요
|
||||
@ -67,6 +68,7 @@ export interface SessionBrandingResponse {
|
||||
service_name: string
|
||||
logo_url: string
|
||||
primary_color: string
|
||||
helpdesk?: string[]
|
||||
}
|
||||
|
||||
// 협상완료 부가정보 필드 정의(companies.settings.session_fields)
|
||||
@ -87,6 +89,7 @@ export interface MeResponse {
|
||||
role: number
|
||||
branding?: Branding
|
||||
session_fields?: SessionField[]
|
||||
guide_notices?: string[]
|
||||
}
|
||||
|
||||
// --- 로그아웃 -------------------------------------------------------------
|
||||
@ -121,6 +124,8 @@ export interface AuthUser {
|
||||
role: number
|
||||
branding: Branding
|
||||
sessionFields: SessionField[]
|
||||
/** 협상 유의사항 항목(회사 설정). 비면 포털 기본 문구를 쓴다 */
|
||||
guideNotices: string[]
|
||||
}
|
||||
|
||||
export function toAuthUser(res: MeResponse): AuthUser {
|
||||
@ -133,5 +138,6 @@ export function toAuthUser(res: MeResponse): AuthUser {
|
||||
role: res.role,
|
||||
branding: res.branding ?? {},
|
||||
sessionFields: res.session_fields ?? [],
|
||||
guideNotices: res.guide_notices ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,8 +44,9 @@ export function usePreLoginBranding(): Branding | null {
|
||||
service_name: res.service_name || undefined,
|
||||
logo_url: res.logo_url || undefined,
|
||||
primary_color: res.primary_color || undefined,
|
||||
helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
|
||||
}
|
||||
if (!next.service_name && !next.logo_url) return
|
||||
if (!next.service_name && !next.logo_url && !next.helpdesk) return
|
||||
setBranding(next)
|
||||
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
|
||||
})
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import { useMeQuery } from '@/apis'
|
||||
|
||||
// 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
|
||||
// 연락처는 회사 설정(companies.settings.branding.helpdesk)에서 온다. 미등록이면 섹션 자체를 숨긴다.
|
||||
export function Contact() {
|
||||
const { data: user } = useMeQuery()
|
||||
const helpdesk = user?.branding?.helpdesk ?? []
|
||||
if (helpdesk.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="w-full pt-4">
|
||||
<div className="border-b border-border pb-2">
|
||||
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60">
|
||||
<span>010-0000-0000</span>
|
||||
<span>o2odev@o2o.kr</span>
|
||||
{helpdesk.map((line) => (
|
||||
<span key={line}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { useMeQuery } from '@/apis'
|
||||
|
||||
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
|
||||
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다.
|
||||
// 항목은 회사 설정(companies.settings.guide_notices)에서 오고, 비어 있으면 아래 기본 문구를 쓴다.
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
@ -15,10 +26,8 @@ function Bullet({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function GuideContent() {
|
||||
const { item_vat_yn, item_delivery_fee_yn } = useChatInitStore()
|
||||
// init 미로드/값 없음 → 보수적 기본값 (KT-NEGOWIZ 와 동일한 폴백 규칙)
|
||||
const vat = item_vat_yn || 'VAT별도'
|
||||
const deliveryFee = item_delivery_fee_yn || '배송비별도'
|
||||
const { data: user } = useMeQuery()
|
||||
const notices = user?.guideNotices?.length ? user.guideNotices : DEFAULT_NOTICES
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@ -31,59 +40,30 @@ export function GuideContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1">
|
||||
<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>
|
||||
{notices.map((notice) => (
|
||||
<Bullet key={notice}>{notice}</Bullet>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 하단 문의 안내 박스
|
||||
// 하단 문의 안내 박스 — 연락처는 회사 설정(branding.helpdesk). 미등록이면 박스를 통째로 숨긴다.
|
||||
export function GuideContactBox() {
|
||||
const { data: user } = useMeQuery()
|
||||
const helpdesk = user?.branding?.helpdesk ?? []
|
||||
if (helpdesk.length === 0) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="break-keep text-center text-sm font-bold text-neutral-90">
|
||||
헬프데스크 010-0000-0000 · o2odev@o2o.kr
|
||||
</div>
|
||||
{helpdesk.map((line) => (
|
||||
<div key={line} className="break-keep text-center text-sm font-bold text-neutral-90">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,11 +31,13 @@ export function LoginPage() {
|
||||
<LoginForm />
|
||||
</div>
|
||||
|
||||
{/* 푸터 — 문의처 + 솔루션/제작사 표기(회사 브랜딩과 무관하게 고정) */}
|
||||
{/* 푸터 — 문의처(회사 설정 branding.helpdesk, 미등록이면 생략) + 솔루션/제작사 표기(고정) */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-xs font-medium text-neutral-60">
|
||||
문의: 헬프데스크 010-0000-0000 · o2odev@o2o.kr
|
||||
</p>
|
||||
{(branding?.helpdesk ?? []).map((line) => (
|
||||
<p key={line} className="text-xs font-medium text-neutral-60">
|
||||
문의: {line}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-[11px] font-medium text-neutral-50">
|
||||
© {new Date().getFullYear()} negotium · Made by AI O2O
|
||||
</p>
|
||||
|
||||
@ -212,6 +212,7 @@ class nego_cards(MainTableMixin, MAIN_BASE):
|
||||
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
|
||||
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
|
||||
usage_type = Column(SmallInteger, nullable=False, default=1)
|
||||
tactic = Column(JSONB, nullable=True) # 전술 운영 규칙 {"min_round", "closing"} — 제안가는 script 변수 파싱(agent)
|
||||
|
||||
|
||||
class wild_cards(MainTableMixin, MAIN_BASE):
|
||||
@ -228,6 +229,7 @@ class wild_cards(MainTableMixin, MAIN_BASE):
|
||||
condition = Column(String(255), nullable=True) # 사용 조건(트리거)
|
||||
available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑)
|
||||
memo = Column(String(255), nullable=True) # 자유 메모
|
||||
tactic = Column(JSONB, nullable=True) # 전술 운영 규칙 {"min_round", "closing"} — 제안가는 script 변수 파싱(agent)
|
||||
|
||||
|
||||
class versions(MainTableMixin, MAIN_BASE):
|
||||
@ -266,6 +268,7 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
|
||||
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율(목표가 산정에 사용)
|
||||
card_count = Column(Integer, nullable=False, default=3)
|
||||
done_ceiling_rate = Column(SmallInteger, nullable=False, server_default=text("50"), default=50) # 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000)
|
||||
# 낙찰 가격정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 제거됨.
|
||||
|
||||
|
||||
@ -303,6 +306,7 @@ class quotations(MainTableMixin, MAIN_BASE):
|
||||
# 1:1 협상: over 는 항상 OPEN(목표 초과=개찰), mid 만 앵커/목표 택1. 1:N 경매: mid=over=AWARD 강제(무조건 최저가 낙찰).
|
||||
mid_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 앵커링가<투찰가≤목표가 처리(1=낙찰/2=개찰)
|
||||
over_action = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # PriceGateAction: 목표가<투찰가 처리(1=낙찰/2=개찰)
|
||||
done_ceiling_rate = Column(SmallInteger, nullable=True) # 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값
|
||||
|
||||
|
||||
class sessions(MainTableMixin, MAIN_BASE):
|
||||
@ -319,6 +323,7 @@ class sessions(MainTableMixin, MAIN_BASE):
|
||||
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
|
||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||
anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
|
||||
done_ceiling_price = Column(BigInteger, nullable=True) # 협상 완료 상한가(원) — 생성 시 박제 = 목표가×(1+완료상한율/1000). 봇 종결·마감이 이 이하면 타결
|
||||
anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함
|
||||
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
|
||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||
|
||||
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)
|
||||
@ -450,21 +450,23 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
"""견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수).
|
||||
"""견적 세팅의 목표 마진율·협상 완료 상한율: {margin, done_ceiling_rate}.
|
||||
margin·수수료는 목표가 산정 입력, done_ceiling_rate(‰)는 완료 상한 = 목표가×(1+값/1000).
|
||||
(낙찰 정책은 견적 단위 이관, 앵커링은 칸 rate v1.2 → 세팅 컬럼 제거됨.)"""
|
||||
try:
|
||||
query = select(
|
||||
quotation_settings.target_margin_rate,
|
||||
quotation_settings.done_ceiling_rate,
|
||||
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
if not rows:
|
||||
return ErrorType.SUCCESS, {}
|
||||
# 단일 컬럼 select → execute 가 스칼라 리스트를 돌려준다(Row 아님).
|
||||
margin = rows[0]
|
||||
row = rows[0]
|
||||
return ErrorType.SUCCESS, {
|
||||
"margin": float(margin) if margin is not None else None,
|
||||
"margin": float(row.target_margin_rate) if row.target_margin_rate is not None else None,
|
||||
"done_ceiling_rate": int(row.done_ceiling_rate) if row.done_ceiling_rate is not None else None,
|
||||
}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -23,6 +23,7 @@ class Req_CreateCard(CardProtocol):
|
||||
status: int = CardStatus.ACTIVE.value # 와일드카드 적용 여부(available 매핑). 일반카드는 무시.
|
||||
condition: Optional[str] = None # 와일드카드 전용
|
||||
memo: Optional[str] = None # 와일드카드 전용
|
||||
tactic: Optional[Any] = None # 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent)
|
||||
|
||||
|
||||
class Req_UpdateCard(CardProtocol):
|
||||
@ -34,6 +35,7 @@ class Req_UpdateCard(CardProtocol):
|
||||
status: Optional[int] = None
|
||||
condition: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
tactic: Optional[Any] = None # 전술 운영 규칙 {"min_round": N, "closing": bool}
|
||||
|
||||
|
||||
# 통합 카드 표현(nego_cards + wild_cards 공통).
|
||||
@ -53,6 +55,7 @@ class CardData(WebPacketProtocol):
|
||||
status: CardStatus = CardStatus.ACTIVE
|
||||
condition: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
tactic: Optional[Any] = None # 전술 운영 규칙 {"min_round": N, "closing": bool}
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
success_rate: float = 0.0 # 카드 성공률(사용 세션 중 타결 비율). #12 순위용
|
||||
|
||||
@ -33,10 +33,16 @@ class Req_CreateQuotation(QuotationProtocol):
|
||||
# 1:N 경매는 미전송 → 서버가 mid=over=AWARD 강제('무조건 최저가 낙찰').
|
||||
mid_action: Optional[int] = None # PriceGateAction: 앵커링가<투찰가≤목표가 처리
|
||||
over_action: Optional[int] = None # PriceGateAction: 목표가<투찰가 처리
|
||||
done_ceiling_rate: Optional[int] = None # 협상 완료 상한율(‰) 견적 override. None 이면 회사 세팅값
|
||||
|
||||
|
||||
class Req_RegenerateQuotation(QuotationProtocol):
|
||||
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음
|
||||
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·번호는 원 견적에서 이어받음
|
||||
# 아래 재지정값은 전부 미전송(None)이면 원 견적/직전 라운드 값을 그대로 승계한다.
|
||||
card_ids: Optional[list[uuid.UUID]] = None # 다음 라운드 협상카드. 빈 리스트면 카드 없는 새 버전
|
||||
target_price: Optional[int] = None # 목표가(원). 이 라운드의 모든 상품에 적용
|
||||
end_time: Optional[datetime] = None # 마감기한. 미전송이면 원 견적과 같은 기간 길이로 생성 시각부터
|
||||
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰). 완료 상한=목표가×(1+값/1000)
|
||||
|
||||
|
||||
class Req_AwardQuotation(QuotationProtocol):
|
||||
@ -71,6 +77,7 @@ class QuotationData(WebPacketProtocol):
|
||||
close_reason: Optional[CloseReason] = None # 마감 사유(CloseReason). 미마감이면 None
|
||||
mid_action: Optional[int] = None # 낙찰 기준(견적 단위). 상세 드로어 낙찰기준 표시용
|
||||
over_action: Optional[int] = None
|
||||
done_ceiling_rate: Optional[int] = None # 타결 상한율(‰) 견적 override. None 이면 견적 세팅값을 따름
|
||||
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
|
||||
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
|
||||
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
|
||||
|
||||
@ -72,7 +72,13 @@ async def award_quotation(
|
||||
async def regenerate_quotation(
|
||||
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role))
|
||||
return RemoveNoneResponse(
|
||||
await service.regenerate_quotation(
|
||||
str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role,
|
||||
card_ids=req.card_ids, target_price=req.target_price,
|
||||
end_time=req.end_time, done_ceiling_rate=req.done_ceiling_rate,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
|
||||
|
||||
@ -15,11 +15,13 @@ class QuotationSettingProtocol(WebPacketProtocol):
|
||||
class Req_CreateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: float
|
||||
card_count: int = 3
|
||||
done_ceiling_rate: int = 50 # 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000)
|
||||
|
||||
|
||||
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: Optional[float] = None
|
||||
card_count: Optional[int] = None
|
||||
done_ceiling_rate: Optional[int] = None
|
||||
|
||||
|
||||
class QuotationSettingData(WebPacketProtocol):
|
||||
@ -29,6 +31,7 @@ class QuotationSettingData(WebPacketProtocol):
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
target_margin_rate: float
|
||||
card_count: int
|
||||
done_ceiling_rate: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ class CardService:
|
||||
edit_script=row.edit_script,
|
||||
usage_type=row.usage_type,
|
||||
status=CardStatus.ACTIVE.value,
|
||||
tactic=row.tactic,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
@ -59,6 +60,7 @@ class CardService:
|
||||
status=CardStatus.ACTIVE.value if row.available else CardStatus.INACTIVE.value,
|
||||
condition=row.condition,
|
||||
memo=row.memo,
|
||||
tactic=row.tactic,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
@ -209,6 +211,7 @@ class CardService:
|
||||
script=req.script,
|
||||
edit_script=req.edit_script,
|
||||
usage_type=req.usage_type,
|
||||
tactic=req.tactic,
|
||||
)
|
||||
if is_wildcard:
|
||||
card = wild_cards(
|
||||
@ -256,7 +259,7 @@ class CardService:
|
||||
return res
|
||||
|
||||
# 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). status → available(와일드 전용).
|
||||
allowed = {"name", "number", "script", "edit_script", "usage_type"}
|
||||
allowed = {"name", "number", "script", "edit_script", "usage_type", "tactic"}
|
||||
if is_wild:
|
||||
allowed |= {"condition", "memo"}
|
||||
payload = {k: v for k, v in data.items() if k in allowed}
|
||||
|
||||
@ -20,13 +20,16 @@ import asyncio
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import item_internet_lowest_prices
|
||||
from common.database.model.models import companies, item_internet_lowest_prices
|
||||
from common.enums import DBType, DBWRType, ErrorType, LowestPriceWebsite
|
||||
from common.logger import LOG
|
||||
from common.nego_baseline import resolve_baseline_price
|
||||
from config.server_configs import web_server_config
|
||||
from crud.lps_sync_crud import ILpsSyncCRUD, LpsSyncCRUD
|
||||
|
||||
@ -56,14 +59,36 @@ class LpsSyncService:
|
||||
"""LPS 연동 활성 여부 — lps_db 가 등록된 환경에서만 배치가 돈다."""
|
||||
return DB_SESSION_MNG.is_registered(DBType.LPS.value)
|
||||
|
||||
async def _company_settings(self, company_id) -> dict:
|
||||
"""상품이 속한 회사의 settings(JSONB). 조회 실패/미설정이면 빈 dict."""
|
||||
if not company_id:
|
||||
return {}
|
||||
|
||||
async def _q(s):
|
||||
query = select(companies.settings).where(
|
||||
companies.company_id == company_id, companies.deleted == False, # noqa: E712
|
||||
).limit(1)
|
||||
return await DB_SESSION_MNG.execute(s, query, "lps company settings failed.", raise_error=False)
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(companies.DBType(), DBWRType.DB_READ.value, _q)
|
||||
if err_type != ErrorType.SUCCESS or not rows:
|
||||
return {}
|
||||
return rows[0] if isinstance(rows[0], dict) else {}
|
||||
|
||||
# ---- 단건 즉시 요청 (lowest-price 트리거 API 용) ---------------------
|
||||
async def request_search_for_item(self, item, force: bool = False) -> tuple:
|
||||
async def request_search_for_item(self, item, force: bool = False, settings: Optional[dict] = None) -> tuple:
|
||||
"""상품 1건을 즉시 LPS 에 검색 요청(수동 트리거 — job_type=manual, 배치보다 높은 우선순위).
|
||||
force=True 면 LPS 의 네거티브 캐시(24h not_found)를 무시하고 실제로 재검색한다
|
||||
(사용자가 '다시 검색'을 누른 경우. 상품명·모델을 고쳐 재시도하는 흐름에 필요).
|
||||
settings 는 회사 설정(companies.settings) — 검색 힌트로 보낼 가격 컬럼을 여기서 정한다.
|
||||
반환: (status, message) — queued | duplicated | unavailable."""
|
||||
if not self.available():
|
||||
return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)"
|
||||
# 가격 힌트는 이 회사가 관리하는 지불 단가로 보낸다 — 협상 기준가와 같은 규칙.
|
||||
# 호출부가 안 넘기면 상품의 소속 회사 설정을 직접 읽는다(실패해도 검색은 진행).
|
||||
if settings is None:
|
||||
settings = await self._company_settings(item.company_id)
|
||||
baseline = resolve_baseline_price(item, settings)
|
||||
payload = {
|
||||
"product_code": str(item.item_id),
|
||||
"product_name": item.name,
|
||||
@ -71,7 +96,7 @@ class LpsSyncService:
|
||||
"model": item.model_name or "",
|
||||
"specification": item.spec or "",
|
||||
"company": item.manufacturer or "",
|
||||
"price": str(item.price) if item.price else "",
|
||||
"price": str(baseline) if baseline else "",
|
||||
"force": force,
|
||||
}
|
||||
base = web_server_config.lps_base_url.rstrip("/")
|
||||
|
||||
@ -51,9 +51,10 @@ class BuildMixin:
|
||||
md_price=req.md_price,
|
||||
item_ids=req.item_ids,
|
||||
supplier_ids=req.supplier_ids,
|
||||
card_ids=req.card_ids,
|
||||
card_ids=req.card_ids or None, # 미선택이면 새 버전 없이 기본 전략 버전(version_id)을 그대로 쓴다
|
||||
mid_action=req.mid_action,
|
||||
over_action=req.over_action,
|
||||
done_ceiling_rate=req.done_ceiling_rate,
|
||||
)
|
||||
if res.result.success:
|
||||
await create_notification(
|
||||
@ -63,7 +64,11 @@ class BuildMixin:
|
||||
)
|
||||
return res
|
||||
|
||||
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||
async def regenerate_next_round(
|
||||
self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None, *,
|
||||
card_ids: Optional[list] = None, target_price: Optional[int] = None,
|
||||
end_time=None, done_ceiling_rate: Optional[int] = None,
|
||||
) -> Res_CreateQuotation:
|
||||
"""[재생성] 마감된 견적의 '다음 라운드'를 새로 만든다. 호출 경로는 수동 재생성·재협상 승인뿐.
|
||||
|
||||
플로우:
|
||||
@ -73,6 +78,9 @@ class BuildMixin:
|
||||
|
||||
견적번호(number)를 원본 그대로 이어받아 '같은 번호 = 한 체인'으로 묶는다(parent_id 대체).
|
||||
supplier_ids: 다음 라운드에 부를 공급사(동가면 동가 업체만, 그 외엔 원 견적 공급사 전체).
|
||||
|
||||
card_ids·target_price·end_time·done_ceiling_rate 는 담당자가 이번 라운드에서만 바꾸는 조정값이다.
|
||||
미지정(None)이면 전부 원 견적/직전 라운드 값을 그대로 승계한다(기존 동작).
|
||||
"""
|
||||
res = Res_CreateQuotation()
|
||||
|
||||
@ -88,16 +96,22 @@ class BuildMixin:
|
||||
)
|
||||
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
|
||||
# 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
|
||||
# 앵커링가는 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
|
||||
inherited_target_prices = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {}
|
||||
# 담당자가 목표가를 다시 잡았으면(target_price) 그 값이 이번 라운드 전 상품의 목표가가 된다.
|
||||
# 앵커링가는 어느 쪽이든 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
|
||||
if target_price is not None:
|
||||
inherited_target_prices = {iid: target_price for iid in item_ids}
|
||||
else:
|
||||
inherited_target_prices = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {}
|
||||
|
||||
# 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
|
||||
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
|
||||
|
||||
# 3) 다음 라운드의 견적 생성
|
||||
now = GTime.UTC()
|
||||
# 원본 협상기간을 이어쓰되, 비정상적으로 짧으면 최소 하한을 적용(즉시 만료→연쇄 재마감 방지).
|
||||
# 마감기한을 다시 잡았으면 그 값, 아니면 원본 협상기간을 이어쓴다.
|
||||
# 이어쓸 때만 최소 하한을 적용한다(원본 기간이 비정상적으로 짧아 즉시 만료→연쇄 재마감 되는 것 방지).
|
||||
duration = max(original.end_time - original.start_time, self.MIN_REGEN_DURATION)
|
||||
next_end_time = end_time or (now + duration)
|
||||
# 다음 차수는 '원본 round+1' 이 아니라 '체인(같은 번호) 최신 round+1'.
|
||||
# 크론 마감과 수동 regenerate_quotation 이 같은 체인을 처리하는 타이밍이 엇갈려도
|
||||
# 항상 체인 끝에 이어붙어 uq_quotations_number(number, round) 충돌을 막는다.
|
||||
@ -122,23 +136,30 @@ class BuildMixin:
|
||||
status=QuotationStatus.CREATED.value,
|
||||
round_=next_round,
|
||||
start_time=now,
|
||||
end_time=now + duration,
|
||||
end_time=next_end_time,
|
||||
manager_name=original.manager_name,
|
||||
manager_email=original.manager_email,
|
||||
manager_contact_number=original.manager_contact_number,
|
||||
memo=original.memo,
|
||||
md_price=original.md_price,
|
||||
md_price=target_price if target_price is not None else original.md_price,
|
||||
item_ids=item_ids,
|
||||
supplier_ids=list(supplier_ids),
|
||||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||||
# None=원본 version_id 재사용(새 버전 안 만듦), 리스트=이 카드들로 새 버전 생성(빈 리스트면 카드 없는 버전).
|
||||
card_ids=card_ids,
|
||||
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
|
||||
over_action=original.over_action,
|
||||
done_ceiling_rate=done_ceiling_rate if done_ceiling_rate is not None else original.done_ceiling_rate,
|
||||
inherited_target_prices=inherited_target_prices, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
|
||||
)
|
||||
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None) -> Res_CreateQuotation:
|
||||
async def regenerate_quotation(
|
||||
self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None, *,
|
||||
card_ids: Optional[list] = None, target_price: Optional[int] = None,
|
||||
end_time=None, done_ceiling_rate: Optional[int] = None,
|
||||
) -> Res_CreateQuotation:
|
||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
||||
상품·견적번호는 원 견적에서 이어받고, 카드·목표가·마감기한·타결 상한율은
|
||||
담당자가 모달에서 다시 잡은 값이 있으면 그 값으로 만든다(regenerate_next_round)."""
|
||||
res = Res_CreateQuotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
@ -171,16 +192,23 @@ class BuildMixin:
|
||||
res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다."
|
||||
return res
|
||||
|
||||
return await self.regenerate_next_round(qt_uuid, supplier_ids, regen_label=regen_label)
|
||||
return await self.regenerate_next_round(
|
||||
qt_uuid, supplier_ids, regen_label=regen_label,
|
||||
card_ids=card_ids, target_price=target_price,
|
||||
end_time=end_time, done_ceiling_rate=done_ceiling_rate,
|
||||
)
|
||||
|
||||
async def _build_quotation(
|
||||
self, *,
|
||||
user_id: str, qt_setting_id, version_id, name: str, number: str,
|
||||
type_: int, status: int, round_: int, start_time, end_time,
|
||||
manager_name, manager_email, manager_contact_number, memo, md_price,
|
||||
item_ids: list, supplier_ids: list, card_ids: list,
|
||||
item_ids: list, supplier_ids: list,
|
||||
# None = 넘겨받은 version_id 를 그대로 쓴다(카드 승계). 리스트면 이 카드들로 새 버전을 만든다(빈 리스트=카드 없는 버전).
|
||||
card_ids: Optional[list],
|
||||
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
|
||||
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
|
||||
done_ceiling_rate: Optional[int] = None, # 협상 완료 상한율(‰) 견적 override. None 이면 세팅 기본값
|
||||
inherited_target_prices: Optional[dict] = None, # 재생성 시 직전 라운드 목표가 상속(KTC). 목표가만 — 앵커는 항상 재계산
|
||||
) -> Res_CreateQuotation:
|
||||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||||
@ -195,13 +223,15 @@ class BuildMixin:
|
||||
over_action = over_action or PriceGateAction.AWARD.value
|
||||
|
||||
# 목표가 계산 재료(가격·비율·회사 설정)를 먼저 모아온다.
|
||||
prices, fee, margin, hidden = await self._load_target_inputs(item_ids, qt_setting_id, user_id)
|
||||
prices, fee, margin, hidden, setting_ceiling_rate = await self._load_target_inputs(item_ids, qt_setting_id, user_id)
|
||||
# 완료 상한율(‰) — 견적 override 우선, 없으면 세팅 기본. 세션에 완료 상한가(원)로 박제한다.
|
||||
effective_ceiling_rate = done_ceiling_rate if done_ceiling_rate is not None else setting_ceiling_rate
|
||||
|
||||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||||
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
||||
version_obj = None
|
||||
link_rows = []
|
||||
if card_ids:
|
||||
if card_ids is not None:
|
||||
_err, card_types = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
@ -244,6 +274,7 @@ class BuildMixin:
|
||||
md_price=md_price,
|
||||
mid_action=mid_action,
|
||||
over_action=over_action,
|
||||
done_ceiling_rate=done_ceiling_rate, # 견적 override 원본 저장(None=세팅 따름)
|
||||
)
|
||||
|
||||
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
|
||||
@ -267,6 +298,11 @@ class BuildMixin:
|
||||
session_objs = []
|
||||
for iid in item_ids:
|
||||
tp = target_prices[iid]
|
||||
# 완료 상한가 = 목표가×(1+상한율/1000), 10원 반올림(앵커가와 동일한 정수 연산). 상한율 없으면 목표가로 폴백.
|
||||
ceiling_price = (
|
||||
int((tp * (1000 + effective_ceiling_rate) + 5000) // 10000) * 10
|
||||
if effective_ceiling_rate is not None else tp
|
||||
)
|
||||
for sid in supplier_ids:
|
||||
value, ap = anchors[(iid, sid)]
|
||||
session_objs.append(
|
||||
@ -281,6 +317,7 @@ class BuildMixin:
|
||||
target_price=tp,
|
||||
anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
|
||||
anchoring_value=value,
|
||||
done_ceiling_price=ceiling_price, # 박제 — 봇 종결·마감이 이 이하면 타결
|
||||
status=SessionStatus.CREATED.value,
|
||||
end_time=quotation.end_time,
|
||||
)
|
||||
|
||||
@ -76,7 +76,7 @@ class PricingMixin:
|
||||
|
||||
async def _load_target_inputs(
|
||||
self, item_ids: list[uuid.UUID], qt_setting_id, user_id
|
||||
) -> tuple[dict, float, float, set]:
|
||||
) -> tuple[dict, float, float, set, int | None]:
|
||||
"""목표가 계산에 필요한 값들을 한 번에 모아온다.
|
||||
|
||||
- prices: 상품마다 (인터넷최저가, 매입가, 판매가) — DB 조회
|
||||
@ -102,6 +102,7 @@ class PricingMixin:
|
||||
rates = rates if _err == ErrorType.SUCCESS else {}
|
||||
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
||||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||||
ceiling_rate = rates.get("done_ceiling_rate") # 회사 완료 상한율(‰), 미조회면 None
|
||||
user_uuid = uuid.UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
settings = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
@ -109,7 +110,7 @@ class PricingMixin:
|
||||
lambda s: self.quotation_crud.get_company_settings(s, user_uuid),
|
||||
)
|
||||
hidden = set(settings.get("hidden_fields") or [])
|
||||
return prices, fee, margin, hidden
|
||||
return prices, fee, margin, hidden, ceiling_rate
|
||||
|
||||
def _resolve_target_prices(
|
||||
self, *, qt_id, item_ids: list[uuid.UUID], prices: dict,
|
||||
@ -200,7 +201,7 @@ class PricingMixin:
|
||||
return res
|
||||
|
||||
# 산정 입력(재료)은 생성과 같은 로더를 공유 — 생성값과 표시값이 어긋나지 않는다.
|
||||
prices, fee, margin, hidden = await self._load_target_inputs(
|
||||
prices, fee, margin, hidden, _ceiling_rate = await self._load_target_inputs(
|
||||
[sess.item_id], quotation.qt_setting_id, quotation.user_id
|
||||
)
|
||||
internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None)
|
||||
|
||||
@ -66,6 +66,7 @@ class QuotationSettingService:
|
||||
user_id=uuid.UUID(user_id),
|
||||
target_margin_rate=req.target_margin_rate,
|
||||
card_count=req.card_count,
|
||||
done_ceiling_rate=req.done_ceiling_rate,
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotation_settings.DBType()],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
|
||||
|
||||
이식 명세: schedules/anchoring/docs/인수인계.md §1.
|
||||
- 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
|
||||
- 앵커가 = 목표가 × (1000 − anchoring_value), 10원 반올림(calc_anchoring_price), anchoring_value 동시 박제
|
||||
- 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
||||
견적 생성은 실패하지 않는다(규칙 6)
|
||||
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지)
|
||||
@ -11,7 +11,7 @@ from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.anchoring import calc_price_range_index
|
||||
from common.anchoring import calc_anchoring_price, calc_price_range_index
|
||||
from common.enums import QuotationType
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from router.v1.quotation.protocol import Req_CreateQuotation
|
||||
@ -32,7 +32,7 @@ async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engin
|
||||
assert res.result.success is True
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
assert rows == {item: (tp, tp * (1000 - BASE_VALUE) // 1000, BASE_VALUE)}
|
||||
assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)}
|
||||
|
||||
|
||||
async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id):
|
||||
@ -49,8 +49,8 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id)
|
||||
|
||||
assert res.result.success is True
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50)
|
||||
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE)
|
||||
assert rows[item_hit] == (tp_hit, calc_anchoring_price(tp_hit, 50), 50)
|
||||
assert rows[item_miss] == (tp_miss, calc_anchoring_price(tp_miss, BASE_VALUE), BASE_VALUE)
|
||||
|
||||
|
||||
async def test_supply_type_unset_uses_base_value(db_engine, company_id):
|
||||
@ -65,7 +65,7 @@ async def test_supply_type_unset_uses_base_value(db_engine, company_id):
|
||||
|
||||
assert res.result.success is True
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
assert rows == {item: (tp, tp * 990 // 1000, BASE_VALUE)}
|
||||
assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)}
|
||||
|
||||
|
||||
async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id):
|
||||
@ -79,14 +79,14 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa
|
||||
assert res1.result.success is True
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||
rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
|
||||
assert rows1 == {item: (tp, tp * 990 // 1000, BASE_VALUE)} # 1라운드는 시작값
|
||||
assert rows1 == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)} # 1라운드는 시작값
|
||||
|
||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
|
||||
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
|
||||
|
||||
assert res2.result.success is True
|
||||
rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
|
||||
assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
|
||||
assert rows2 == {item: (tp, calc_anchoring_price(tp, 50), 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
|
||||
|
||||
|
||||
def test_price_range_index_golden_vectors():
|
||||
|
||||
211
negodata/backend/tests/test_quotation_regenerate.py
Normal file
211
negodata/backend/tests/test_quotation_regenerate.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""견적 재생성 조정값 — 담당자가 다음 라운드에서만 바꾼 값(카드·목표가·마감기한·타결 상한율)이 반영되는지 검증.
|
||||
|
||||
기본 계약은 '미전송 = 원 견적/직전 라운드 승계'다(기존 동작). 보내면 그 값으로 라운드가 만들어진다.
|
||||
· card_ids — None=원본 카드 버전 재사용 / 리스트=그 카드들로 새 버전 / []=카드 없는 버전
|
||||
· target_price — 이번 라운드 전 상품의 목표가(세션 target_price + quotations.md_price)
|
||||
· end_time — 마감기한(견적·세션 공통). 미전송이면 원 견적과 같은 협상기간
|
||||
· done_ceiling_rate — 타결 상한율(‰) → 세션 done_ceiling_price 로 박제
|
||||
앵커링가는 어느 경우든 재계산이라 여기선 보지 않는다(test_quotation_anchoring 소관).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import QuotationStatus, QuotationType
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from router.v1.quotation.protocol import Req_CreateQuotation
|
||||
from services.quotation import QuotationService
|
||||
|
||||
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
|
||||
NEXT_DUE = "2999-06-01T00:00:00Z" # 재생성 때 다시 잡는 마감기한(프론트가 보내는 형태 = UTC ISO)
|
||||
TARGET = 100_000 # 1라운드 목표가(= MD 제시가 그대로)
|
||||
|
||||
|
||||
async def test_regenerate_without_overrides_inherits_everything(db_engine, client, auth_headers):
|
||||
"""검증: 조정값 없이 공급사만 보내 재생성.
|
||||
기대결과: 목표가·카드 버전·타결 상한율이 원 견적 그대로 승계되고 차수만 +1."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_plain", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])]})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert (q["round"], q["md_price"], q["done_ceiling_rate"]) == (2, TARGET, 50)
|
||||
assert q["version_id"] == ctx["version_id"] # 새 버전 안 만듦 — 원본 카드 버전 재사용
|
||||
s = await _session(db_engine, body["qt_id"])
|
||||
assert s["target_price"] == TARGET
|
||||
assert s["done_ceiling_price"] == 105_000 # 목표가 +5%
|
||||
|
||||
|
||||
async def test_regenerate_applies_target_price_and_ceiling(db_engine, client, auth_headers):
|
||||
"""검증: 목표가 9만원 + 타결 상한율 100‰(=10%)로 재생성.
|
||||
기대결과: 세션 목표가·견적 md_price 가 새 값, 타결 상한가는 새 목표가 기준으로 재계산(99,000)."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_target", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {
|
||||
"supplier_ids": [str(ctx["supplier"])],
|
||||
"target_price": 90_000,
|
||||
"done_ceiling_rate": 100,
|
||||
})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert (q["md_price"], q["done_ceiling_rate"]) == (90_000, 100)
|
||||
s = await _session(db_engine, body["qt_id"])
|
||||
assert (s["target_price"], s["done_ceiling_price"]) == (90_000, 99_000)
|
||||
|
||||
|
||||
async def test_regenerate_applies_end_time(db_engine, client, auth_headers):
|
||||
"""검증: 마감기한(UTC ISO)을 직접 지정해 재생성(원 견적 협상기간 승계 대신).
|
||||
기대결과: 견적·세션 end_time 이 보낸 시각 그대로. 미지정 경로(승계)와 달리 생성시각+기간이 아니다."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_due", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "end_time": NEXT_DUE})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
s = await _session(db_engine, body["qt_id"])
|
||||
due = datetime(2999, 6, 1, tzinfo=timezone.utc)
|
||||
assert q["end_time"] == due
|
||||
assert s["end_time"] == due
|
||||
|
||||
|
||||
async def test_regenerate_replaces_cards_with_new_version(db_engine, client, auth_headers):
|
||||
"""검증: 직전 라운드와 다른 카드 1장으로 재생성.
|
||||
기대결과: 원본과 다른 새 버전이 생기고 그 버전엔 보낸 카드만 매핑된다(원본 버전은 그대로 남음)."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_cards", ceiling_rate=50)
|
||||
new_card = await _seed_nego_card(db_engine)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": [str(new_card)]})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert q["version_id"] != ctx["version_id"]
|
||||
assert await _version_cards(db_engine, q["version_id"]) == {new_card}
|
||||
assert await _version_cards(db_engine, ctx["version_id"]) == {ctx["card_id"]} # 직전 라운드 카드 이력 보존
|
||||
|
||||
|
||||
async def test_regenerate_with_empty_cards_makes_cardless_version(db_engine, client, auth_headers):
|
||||
"""검증: 카드를 전부 해제(빈 리스트)한 채 재생성.
|
||||
기대결과: 원본 버전을 그대로 물려받지 않고, 카드가 하나도 안 걸린 새 버전으로 생성된다."""
|
||||
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_nocard", ceiling_rate=50)
|
||||
|
||||
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": []})
|
||||
|
||||
assert body["result"]["success"] is True
|
||||
q = await _quotation(db_engine, body["qt_id"])
|
||||
assert q["version_id"] != ctx["version_id"]
|
||||
assert await _version_cards(db_engine, q["version_id"]) == set()
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
|
||||
|
||||
async def _closed_round1(engine, client, auth_headers, login_id, *, ceiling_rate):
|
||||
"""재생성 대상(마감된 1라운드)을 만든다 — 카드 1장·공급사 1곳짜리 1:1 협상 견적.
|
||||
|
||||
생성은 서비스로(견적 생성 API 는 로그인 유저를 작성자로 박으므로 같은 유저로 맞춘다),
|
||||
재생성은 HTTP 로 태워 라우터→서비스 인자 전달까지 함께 본다.
|
||||
"""
|
||||
headers = await auth_headers(login_id)
|
||||
user_id = await _user_id(engine, login_id)
|
||||
item_id = await _seed_item(engine, await _company_of(engine, user_id))
|
||||
card_id = await _seed_nego_card(engine)
|
||||
supplier = uuid.uuid4()
|
||||
|
||||
req = Req_CreateQuotation(
|
||||
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(목표가는 md_price 로 확정)
|
||||
name="재생성원본",
|
||||
type=QuotationType.NEW_NEGO.value,
|
||||
end_time=FUTURE,
|
||||
md_price=TARGET,
|
||||
item_ids=[item_id],
|
||||
supplier_ids=[supplier],
|
||||
card_ids=[card_id],
|
||||
done_ceiling_rate=ceiling_rate,
|
||||
)
|
||||
res = await _service().create_quotation(str(user_id), req)
|
||||
assert res.result.success is True
|
||||
# 재생성은 마감 견적에서만 — 크론 마감을 기다리지 않고 상태만 CLOSED 로 돌린다.
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("UPDATE quotations SET status = :st WHERE qt_id = :qt"),
|
||||
{"st": QuotationStatus.CLOSED.value, "qt": res.qt_id},
|
||||
)
|
||||
original = await _quotation(engine, str(res.qt_id))
|
||||
return {"qt_id": str(res.qt_id), "headers": headers, "supplier": supplier,
|
||||
"card_id": card_id, "version_id": original["version_id"]}
|
||||
|
||||
|
||||
async def _regenerate(client, ctx, payload):
|
||||
r = await client.post(f"/v1/quotation/regenerate/{ctx['qt_id']}", json=payload, headers=ctx["headers"])
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _user_id(engine, login_id):
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
async def _company_of(engine, user_id):
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT company_id FROM users WHERE user_id = :uid"), {"uid": user_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
async def _seed_item(engine, company_id):
|
||||
"""상품 1건 시드. NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음)."""
|
||||
item_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO items (item_id, company_id, user_id, name, category_type, internet_lowest_price_yn) "
|
||||
"VALUES (:item_id, :company_id, :user_id, '상품', 1, false)"),
|
||||
{"item_id": item_id, "company_id": company_id, "user_id": uuid.uuid4()},
|
||||
)
|
||||
return item_id
|
||||
|
||||
|
||||
async def _seed_nego_card(engine):
|
||||
card_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
|
||||
"VALUES (:cid, :uid, '카드', 'N1', '멘트', 1)"),
|
||||
{"cid": card_id, "uid": uuid.uuid4()},
|
||||
)
|
||||
return card_id
|
||||
|
||||
|
||||
async def _quotation(engine, qt_id):
|
||||
async with engine.begin() as conn:
|
||||
row = (await conn.execute(
|
||||
text("SELECT round, version_id, md_price, done_ceiling_rate, end_time "
|
||||
"FROM quotations WHERE qt_id = :qt"),
|
||||
{"qt": uuid.UUID(qt_id)},
|
||||
)).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def _session(engine, qt_id):
|
||||
"""견적의 세션 1건(상품·공급사 1:1 시드라 단건)."""
|
||||
async with engine.begin() as conn:
|
||||
row = (await conn.execute(
|
||||
text("SELECT target_price, done_ceiling_price, end_time FROM sessions WHERE quotation_id = :qt"),
|
||||
{"qt": uuid.UUID(qt_id)},
|
||||
)).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def _version_cards(engine, version_id):
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.execute(
|
||||
text("SELECT nego_card_id FROM version_nego_cards WHERE version_id = :vid"),
|
||||
{"vid": version_id},
|
||||
)).scalars().all()
|
||||
return set(rows)
|
||||
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% 높은`.
|
||||
104
negodata/front/package-lock.json
generated
104
negodata/front/package-lock.json
generated
@ -36,6 +36,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^6.2.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@ -4421,6 +4422,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@ -4940,6 +4950,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@ -5059,6 +5082,15 @@
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -5193,6 +5225,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -6672,6 +6716,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@ -10949,6 +11002,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@ -12501,6 +12566,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@ -12594,6 +12677,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@ -40,6 +40,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^6.2.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@ -14,6 +14,7 @@ import type { CardUsageType } from './cardUsageType';
|
||||
import type { CardStatus } from './cardStatus';
|
||||
import type { CardDataCondition } from './cardDataCondition';
|
||||
import type { CardDataMemo } from './cardDataMemo';
|
||||
import type { CardDataTactic } from './cardDataTactic';
|
||||
import type { CardDataCreatedAt } from './cardDataCreatedAt';
|
||||
import type { CardDataUpdatedAt } from './cardDataUpdatedAt';
|
||||
|
||||
@ -31,6 +32,7 @@ export interface CardData {
|
||||
status?: CardStatus;
|
||||
condition?: CardDataCondition;
|
||||
memo?: CardDataMemo;
|
||||
tactic?: CardDataTactic;
|
||||
created_at?: CardDataCreatedAt;
|
||||
updated_at?: CardDataUpdatedAt;
|
||||
success_rate?: number;
|
||||
|
||||
8
negodata/front/src/api/generated/model/cardDataTactic.ts
Normal file
8
negodata/front/src/api/generated/model/cardDataTactic.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type CardDataTactic = unknown | null;
|
||||
@ -15,6 +15,7 @@ export * from './cardDataMemo';
|
||||
export * from './cardDataName';
|
||||
export * from './cardDataNumber';
|
||||
export * from './cardDataScript';
|
||||
export * from './cardDataTactic';
|
||||
export * from './cardDataUpdatedAt';
|
||||
export * from './cardDataUserId';
|
||||
export * from './cardStatus';
|
||||
@ -113,6 +114,7 @@ export * from './quotationData';
|
||||
export * from './quotationDataCloseReason';
|
||||
export * from './quotationDataCreatedAt';
|
||||
export * from './quotationDataCreatorName';
|
||||
export * from './quotationDataDoneCeilingRate';
|
||||
export * from './quotationDataEqualBidData';
|
||||
export * from './quotationDataEqualBidYn';
|
||||
export * from './quotationDataItemId';
|
||||
@ -154,6 +156,7 @@ export * from './reqCreateCardMemo';
|
||||
export * from './reqCreateCardName';
|
||||
export * from './reqCreateCardNumber';
|
||||
export * from './reqCreateCardScript';
|
||||
export * from './reqCreateCardTactic';
|
||||
export * from './reqCreateCompanyUser';
|
||||
export * from './reqCreateItem';
|
||||
export * from './reqCreateItemCategory';
|
||||
@ -176,6 +179,7 @@ export * from './reqCreateItemSellingPrice';
|
||||
export * from './reqCreateItemSpec';
|
||||
export * from './reqCreateItemVatYn';
|
||||
export * from './reqCreateQuotation';
|
||||
export * from './reqCreateQuotationDoneCeilingRate';
|
||||
export * from './reqCreateQuotationManagerContactNumber';
|
||||
export * from './reqCreateQuotationManagerEmail';
|
||||
export * from './reqCreateQuotationManagerName';
|
||||
@ -198,6 +202,10 @@ export * from './reqCreateSupplierManagerName';
|
||||
export * from './reqCreateSupplierTotalRevenue';
|
||||
export * from './reqLogin';
|
||||
export * from './reqRegenerateQuotation';
|
||||
export * from './reqRegenerateQuotationCardIds';
|
||||
export * from './reqRegenerateQuotationDoneCeilingRate';
|
||||
export * from './reqRegenerateQuotationEndTime';
|
||||
export * from './reqRegenerateQuotationTargetPrice';
|
||||
export * from './reqRejectRenegotiation';
|
||||
export * from './reqResetSupplierAccountPassword';
|
||||
export * from './reqResetSupplierAccountPasswordPassword';
|
||||
@ -209,6 +217,7 @@ export * from './reqUpdateCardName';
|
||||
export * from './reqUpdateCardNumber';
|
||||
export * from './reqUpdateCardScript';
|
||||
export * from './reqUpdateCardStatus';
|
||||
export * from './reqUpdateCardTactic';
|
||||
export * from './reqUpdateCardUsageType';
|
||||
export * from './reqUpdateCompanySettings';
|
||||
export * from './reqUpdateCompanySettingsSettings';
|
||||
@ -248,6 +257,7 @@ export * from './reqUpdateMeName';
|
||||
export * from './reqUpdateMePassword';
|
||||
export * from './reqUpdateQuotationSetting';
|
||||
export * from './reqUpdateQuotationSettingCardCount';
|
||||
export * from './reqUpdateQuotationSettingDoneCeilingRate';
|
||||
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
export * from './reqUpdateSupplier';
|
||||
export * from './reqUpdateSupplierAccountStatus';
|
||||
|
||||
@ -19,6 +19,7 @@ import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
|
||||
import type { QuotationDataCloseReason } from './quotationDataCloseReason';
|
||||
import type { QuotationDataMidAction } from './quotationDataMidAction';
|
||||
import type { QuotationDataOverAction } from './quotationDataOverAction';
|
||||
import type { QuotationDataDoneCeilingRate } from './quotationDataDoneCeilingRate';
|
||||
import type { QuotationDataItemId } from './quotationDataItemId';
|
||||
import type { QuotationDataItemName } from './quotationDataItemName';
|
||||
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
|
||||
@ -51,6 +52,7 @@ export interface QuotationData {
|
||||
close_reason?: QuotationDataCloseReason;
|
||||
mid_action?: QuotationDataMidAction;
|
||||
over_action?: QuotationDataOverAction;
|
||||
done_ceiling_rate?: QuotationDataDoneCeilingRate;
|
||||
participation_count?: number;
|
||||
item_id?: QuotationDataItemId;
|
||||
item_name?: QuotationDataItemName;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type QuotationDataDoneCeilingRate = number | null;
|
||||
@ -13,6 +13,7 @@ export interface QuotationSettingData {
|
||||
user_id?: QuotationSettingDataUserId;
|
||||
target_margin_rate: number;
|
||||
card_count: number;
|
||||
done_ceiling_rate: number;
|
||||
created_at?: QuotationSettingDataCreatedAt;
|
||||
updated_at?: QuotationSettingDataUpdatedAt;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import type { ReqCreateCardScript } from './reqCreateCardScript';
|
||||
import type { ReqCreateCardEditScript } from './reqCreateCardEditScript';
|
||||
import type { ReqCreateCardCondition } from './reqCreateCardCondition';
|
||||
import type { ReqCreateCardMemo } from './reqCreateCardMemo';
|
||||
import type { ReqCreateCardTactic } from './reqCreateCardTactic';
|
||||
|
||||
export interface ReqCreateCard {
|
||||
is_wildcard?: boolean;
|
||||
@ -22,4 +23,5 @@ export interface ReqCreateCard {
|
||||
status?: number;
|
||||
condition?: ReqCreateCardCondition;
|
||||
memo?: ReqCreateCardMemo;
|
||||
tactic?: ReqCreateCardTactic;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqCreateCardTactic = unknown | null;
|
||||
@ -13,6 +13,7 @@ import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
|
||||
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
|
||||
import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction';
|
||||
import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction';
|
||||
import type { ReqCreateQuotationDoneCeilingRate } from './reqCreateQuotationDoneCeilingRate';
|
||||
|
||||
export interface ReqCreateQuotation {
|
||||
qt_setting_id: string;
|
||||
@ -33,4 +34,5 @@ export interface ReqCreateQuotation {
|
||||
card_ids?: string[];
|
||||
mid_action?: ReqCreateQuotationMidAction;
|
||||
over_action?: ReqCreateQuotationOverAction;
|
||||
done_ceiling_rate?: ReqCreateQuotationDoneCeilingRate;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqCreateQuotationDoneCeilingRate = number | null;
|
||||
@ -8,4 +8,5 @@
|
||||
export interface ReqCreateQuotationSetting {
|
||||
target_margin_rate: number;
|
||||
card_count?: number;
|
||||
done_ceiling_rate?: number;
|
||||
}
|
||||
|
||||
@ -4,7 +4,15 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqRegenerateQuotationCardIds } from './reqRegenerateQuotationCardIds';
|
||||
import type { ReqRegenerateQuotationTargetPrice } from './reqRegenerateQuotationTargetPrice';
|
||||
import type { ReqRegenerateQuotationEndTime } from './reqRegenerateQuotationEndTime';
|
||||
import type { ReqRegenerateQuotationDoneCeilingRate } from './reqRegenerateQuotationDoneCeilingRate';
|
||||
|
||||
export interface ReqRegenerateQuotation {
|
||||
supplier_ids?: string[];
|
||||
card_ids?: ReqRegenerateQuotationCardIds;
|
||||
target_price?: ReqRegenerateQuotationTargetPrice;
|
||||
end_time?: ReqRegenerateQuotationEndTime;
|
||||
done_ceiling_rate?: ReqRegenerateQuotationDoneCeilingRate;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqRegenerateQuotationCardIds = string[] | null;
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqRegenerateQuotationDoneCeilingRate = number | null;
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqRegenerateQuotationEndTime = string | null;
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqRegenerateQuotationTargetPrice = number | null;
|
||||
@ -12,6 +12,7 @@ import type { ReqUpdateCardUsageType } from './reqUpdateCardUsageType';
|
||||
import type { ReqUpdateCardStatus } from './reqUpdateCardStatus';
|
||||
import type { ReqUpdateCardCondition } from './reqUpdateCardCondition';
|
||||
import type { ReqUpdateCardMemo } from './reqUpdateCardMemo';
|
||||
import type { ReqUpdateCardTactic } from './reqUpdateCardTactic';
|
||||
|
||||
export interface ReqUpdateCard {
|
||||
name?: ReqUpdateCardName;
|
||||
@ -22,4 +23,5 @@ export interface ReqUpdateCard {
|
||||
status?: ReqUpdateCardStatus;
|
||||
condition?: ReqUpdateCardCondition;
|
||||
memo?: ReqUpdateCardMemo;
|
||||
tactic?: ReqUpdateCardTactic;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqUpdateCardTactic = unknown | null;
|
||||
@ -6,8 +6,10 @@
|
||||
*/
|
||||
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
|
||||
import type { ReqUpdateQuotationSettingDoneCeilingRate } from './reqUpdateQuotationSettingDoneCeilingRate';
|
||||
|
||||
export interface ReqUpdateQuotationSetting {
|
||||
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
|
||||
card_count?: ReqUpdateQuotationSettingCardCount;
|
||||
done_ceiling_rate?: ReqUpdateQuotationSettingDoneCeilingRate;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqUpdateQuotationSettingDoneCeilingRate = number | null;
|
||||
@ -16,6 +16,7 @@ import QuotationPage from '../pages/quotation';
|
||||
import CardsPage from '../pages/cards';
|
||||
import MembersPage from '../pages/members';
|
||||
import SettingsPage from '../pages/settings';
|
||||
import DevSettingsPage from '../pages/dev-settings';
|
||||
import NotificationsPage from '../pages/notifications';
|
||||
import OnboardingPage from '../pages/onboarding';
|
||||
|
||||
@ -105,7 +106,7 @@ export const router = createBrowserRouter([
|
||||
Component: DevDesignPage,
|
||||
},
|
||||
{
|
||||
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
|
||||
// 최고관리자 전용. 공급사에게 보이는 브랜딩·안내 문구. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
|
||||
path: 'settings',
|
||||
loader: async () => {
|
||||
await initAuth();
|
||||
@ -113,6 +114,15 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
Component: SettingsPage,
|
||||
},
|
||||
{
|
||||
// 개발자 전용 고급 설정. 용어·커스텀 필드는 협상 동작·목표가 산정에 영향을 줘 관리자에게 열지 않는다.
|
||||
path: 'dev/settings',
|
||||
loader: async () => {
|
||||
await initAuth();
|
||||
return hasRole('개발자') ? null : redirect('/forbidden');
|
||||
},
|
||||
Component: DevSettingsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { variableLabel } from '@/features/cards/editor/variables';
|
||||
import { isKnownVariable, variableLabel } from '@/features/cards/editor/variables';
|
||||
|
||||
interface SlateLeaf {
|
||||
text: string;
|
||||
@ -46,7 +46,9 @@ export default function SlateRenderer({ nodes, variables = {} }: SlateRendererPr
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
// 값 미주입 토큰 — 변수 노드가 아닌 평문에 박힌 {name} 도 카탈로그에 있으면 {한글라벨} 로 표기(변수명 원문 노출 방지).
|
||||
return result.replace(/\{(\w+)\}/g, (token, name) =>
|
||||
isKnownVariable(name) ? `{${variableLabel(name)}}` : token);
|
||||
};
|
||||
|
||||
const renderLeaf = (leaf: SlateLeaf, key: string) => {
|
||||
|
||||
@ -13,6 +13,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
CARDS: '/cards',
|
||||
RENEGOTIATION: '/renegotiation',
|
||||
MEMBERS: '/members',
|
||||
DEV_SETTINGS: '/dev/settings', // /settings 보다 먼저 — startsWith 매칭이라 순서가 곧 우선순위
|
||||
SETTINGS: '/settings',
|
||||
DESIGN: '/dev/design',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
|
||||
@ -12,7 +12,9 @@ import { cn } from '@/lib/utils';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import { ActionBanner } from './ActionBanner';
|
||||
import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal';
|
||||
import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView';
|
||||
import {
|
||||
DEV_SETTINGS_TABS, OWNER_SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab,
|
||||
} from '@/features/settings/SettingsView';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BarChart3,
|
||||
@ -33,6 +35,7 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
BookOpen,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
@ -69,12 +72,13 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
|
||||
label: '관리',
|
||||
items: [
|
||||
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
|
||||
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '개발자',
|
||||
items: [
|
||||
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', devOnly: true },
|
||||
{ type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true },
|
||||
{ type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true },
|
||||
],
|
||||
},
|
||||
@ -99,6 +103,7 @@ const pageLabelMap: Record<PageType, string> = {
|
||||
RENEGOTIATION: '재협상 요청',
|
||||
MEMBERS: '회원관리',
|
||||
SETTINGS: '회사 설정',
|
||||
DEV_SETTINGS: '고급 설정',
|
||||
DESIGN: '디자인 시스템',
|
||||
NOTIFICATIONS: '알림',
|
||||
};
|
||||
@ -360,10 +365,14 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
setIsCmdOpen(false);
|
||||
}}
|
||||
onSelectSettings={(tab) => {
|
||||
navigate(`/settings?tab=${tab}`);
|
||||
// 탭이 어느 페이지 소속인지에 따라 경로가 갈린다(관리자=회사 설정, 개발자=고급 설정).
|
||||
navigate(`${OWNER_SETTINGS_TABS.includes(tab) ? '/settings' : '/dev/settings'}?tab=${tab}`);
|
||||
setIsCmdOpen(false);
|
||||
}}
|
||||
canSeeSettings={visibleItems.some((i) => i.type === 'SETTINGS')}
|
||||
settingsTabs={[
|
||||
...(visibleItems.some((i) => i.type === 'SETTINGS') ? OWNER_SETTINGS_TABS : []),
|
||||
...(visibleItems.some((i) => i.type === 'DEV_SETTINGS') ? DEV_SETTINGS_TABS : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
|
||||
@ -380,7 +389,7 @@ function CommandMenu({
|
||||
onSelect,
|
||||
onSelectGuide,
|
||||
onSelectSettings,
|
||||
canSeeSettings,
|
||||
settingsTabs,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@ -389,7 +398,7 @@ function CommandMenu({
|
||||
onSelect: (type: PageType) => void;
|
||||
onSelectGuide: (tab: GuideTab) => void;
|
||||
onSelectSettings: (tab: SettingsTab) => void;
|
||||
canSeeSettings: boolean;
|
||||
settingsTabs: readonly SettingsTab[];
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
@ -402,10 +411,11 @@ function CommandMenu({
|
||||
// 이용안내 탭도 이동 대상 — 대시보드로 가면서 ?guide=<탭> 을 붙여 해당 탭으로 바로 연다.
|
||||
const guideEntries = GUIDE_TABS.map((t) => ({ tab: t, label: `이용안내 · ${TAB_LABEL[t]}` }));
|
||||
const filteredGuides = q ? guideEntries.filter((g) => g.label.toLowerCase().includes(q)) : guideEntries;
|
||||
// 회사 설정 탭도 이동 대상(최고관리자만 — 메뉴와 같은 게이팅).
|
||||
const settingsEntries = canSeeSettings
|
||||
? SETTINGS_TABS.map((t) => ({ tab: t, label: `회사 설정 · ${SETTINGS_TAB_LABEL[t]}` }))
|
||||
: [];
|
||||
// 설정 탭도 이동 대상 — 볼 수 있는 페이지의 탭만(메뉴와 같은 게이팅).
|
||||
const settingsEntries = settingsTabs.map((t) => ({
|
||||
tab: t,
|
||||
label: `${OWNER_SETTINGS_TABS.includes(t) ? '회사 설정' : '고급 설정'} · ${SETTINGS_TAB_LABEL[t]}`,
|
||||
}));
|
||||
const filteredSettings = q ? settingsEntries.filter((e) => e.label.toLowerCase().includes(q)) : settingsEntries;
|
||||
|
||||
return (
|
||||
|
||||
@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||
import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
@ -102,7 +102,13 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo
|
||||
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||
const handleFile = async (file: File) => {
|
||||
const parsed = parseCsv(await file.text());
|
||||
let parsed: Record<string, string>[];
|
||||
try {
|
||||
parsed = await readSpreadsheetRows(file);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||
id: `row-${i + 1}`,
|
||||
rowNum: i + 2,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@ -13,16 +14,38 @@ import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
|
||||
import { CardUsageType } from '@/api/generated/model';
|
||||
import { CARD_USAGE_TYPE_LABEL, CARD_USAGE_TYPE_OPTIONS } from '@/lib/enumLabels';
|
||||
import type { CardInput } from '../hooks/useCards';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
CardScriptEditor,
|
||||
deserialize,
|
||||
serializeToText,
|
||||
serializeToMarker,
|
||||
hasConditionVariable,
|
||||
extractCondition,
|
||||
attachCondition,
|
||||
CONDITION_LABEL,
|
||||
} from '../editor';
|
||||
|
||||
// 제안가가 될 수 있는 변수 → 라벨. agent(tactics.OFFER_VARIABLES)와 동일 목록 — 스크립트의
|
||||
// 마지막 제안가 변수가 이 카드가 부를 금액이다(여기 없는 변수는 읽어주기 전용).
|
||||
const OFFER_VARIABLE_LABEL: Record<string, string> = {
|
||||
target_price: '목표가',
|
||||
anchoring_price: '앵커가',
|
||||
anchor_price: '앵커가',
|
||||
target_mid_price: '중간가 (앵커·목표의 중간)',
|
||||
middle_price: '절충가 (당사 직전 제안·제시가의 중간)',
|
||||
};
|
||||
|
||||
// 스크립트에 등장하는 제안가 변수들(등장 순서 그대로, 중복 포함) — agent parse_offer_variable 미러.
|
||||
// 자동 모드의 제안가 = 마지막 원소. 셀렉트 선택지는 이 목록(중복 제거)으로 제한한다
|
||||
// (멘트에 없는 변수를 고르면 문구와 계산이 어긋나는 사고가 되살아나므로 원천 차단).
|
||||
function parseOfferVariables(editorScript: Descendant[]): string[] {
|
||||
const marker = serializeToMarker(editorScript);
|
||||
return [...marker.matchAll(/\{([a-z_]+)\}/g)]
|
||||
.map((m) => m[1])
|
||||
.filter((name) => name in OFFER_VARIABLE_LABEL);
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
isWildcard: z.boolean(),
|
||||
usageType: z.number(),
|
||||
@ -35,6 +58,9 @@ const schema = z.object({
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']),
|
||||
triggerCondition: z.string(),
|
||||
memo: z.string(),
|
||||
closing: z.boolean(), // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
|
||||
minRound: z.number({ message: '최소 라운드를 숫자로 입력해 주세요.' }).int().min(1, '최소 라운드는 1 이상이어야 합니다.'),
|
||||
offerVariable: z.string(), // 제시 가격 변수. 'auto'=멘트에서 파싱(기본), 그 외=명시 지정(멘트에 있는 변수만)
|
||||
}).refine(
|
||||
// 조건 전략 칩을 넣었으면 조건 내용도 작성해야 한다(빈 상태로 저장 시 문구가 비어버림).
|
||||
(v) => !hasConditionVariable(v.editorScript) || serializeToText(v.conditionScript).trim().length > 0,
|
||||
@ -73,6 +99,9 @@ function buildDefaults(
|
||||
status: card.status,
|
||||
triggerCondition: card.triggerCondition || '',
|
||||
memo: card.memo || '',
|
||||
closing: card.tactic?.closing ?? false,
|
||||
minRound: card.tactic?.min_round ?? 1,
|
||||
offerVariable: card.tactic?.offer_variable ?? 'auto',
|
||||
};
|
||||
}
|
||||
const wild = activeTab === 'WILD';
|
||||
@ -86,6 +115,9 @@ function buildDefaults(
|
||||
status: 'ACTIVE',
|
||||
triggerCondition: '',
|
||||
memo: '',
|
||||
closing: false,
|
||||
minRound: 1,
|
||||
offerVariable: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
@ -117,6 +149,19 @@ export function CardFormSheet({
|
||||
const isWildcard = watch('isWildcard');
|
||||
// 본문에 조건 전략 칩이 있으면 조건 내용 입력용 별도 에디터를 노출한다.
|
||||
const showConditionEditor = hasConditionVariable(watch('editorScript') || []);
|
||||
// 제시 가격 — 기본은 스크립트 파싱(마지막 제안가 변수), 필요 시 멘트에 있는 변수 중에서 명시 선택.
|
||||
const offerVarsRaw = parseOfferVariables(watch('editorScript') || []);
|
||||
const offerVarOptions = [...new Set(offerVarsRaw)]; // 셀렉트 선택지(중복 제거)
|
||||
const autoOfferVar = offerVarsRaw.length ? offerVarsRaw[offerVarsRaw.length - 1] : null;
|
||||
const offerVariable = watch('offerVariable');
|
||||
// 멘트를 고쳐 선택했던 변수가 사라지면 자동으로 되돌린다 — 멘트에 없는 변수 지정은 불가.
|
||||
useEffect(() => {
|
||||
if (offerVariable !== 'auto' && !offerVarOptions.includes(offerVariable)) {
|
||||
setValue('offerVariable', 'auto');
|
||||
}
|
||||
// offerVarOptions 는 매 렌더 새 배열 — 내용 키로만 감지
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [offerVariable, offerVarOptions.join(',')]);
|
||||
|
||||
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
@ -142,6 +187,13 @@ export function CardFormSheet({
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
// 항상 풀 객체로 전송 — 부분 전송이면 해제가 DB에 안 남는다. 자동 모드는 offer_variable 키를 뺀다.
|
||||
// 종결 전용은 와일드카드만(종결 국면이 와일드카드 목록에서만 뽑음) — 협상카드는 항상 false.
|
||||
tactic: {
|
||||
min_round: v.minRound,
|
||||
closing: v.isWildcard ? v.closing : false,
|
||||
...(v.offerVariable !== 'auto' ? { offer_variable: v.offerVariable } : {}),
|
||||
},
|
||||
};
|
||||
const kind = v.isWildcard ? '와일드카드' : '협상카드';
|
||||
try {
|
||||
@ -358,6 +410,81 @@ export function CardFormSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 협상 전술 — 제시 가격(기본=멘트 파싱, 멘트에 있는 변수 중 명시 선택 가능) + 운영 규칙. */}
|
||||
<div className="space-y-3 p-3 bg-muted/40 rounded border border-border">
|
||||
<Typography variant="label" className="font-bold text-[10px] block">협상 전술</Typography>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">제시 가격</Typography>
|
||||
{offerVarOptions.length === 0 ? (
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">
|
||||
없음 — 설득 전용 (스크립트에 가격 변수를 넣으면 그 값을 제시합니다)
|
||||
</Typography>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name="offerVariable"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={(v) => field.onChange(v ?? 'auto')}>
|
||||
<SelectTrigger id="form-card-offer-variable" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === 'auto' || !value
|
||||
? `자동 — ${autoOfferVar ? OFFER_VARIABLE_LABEL[autoOfferVar] : ''} (멘트의 마지막 가격 변수)`
|
||||
: OFFER_VARIABLE_LABEL[String(value)] ?? String(value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
자동 — {autoOfferVar ? OFFER_VARIABLE_LABEL[autoOfferVar] : ''} (멘트의 마지막 가격 변수)
|
||||
</SelectItem>
|
||||
{offerVarOptions.map((name) => (
|
||||
<SelectItem key={name} value={name}>{OFFER_VARIABLE_LABEL[name]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
협력사에게 제시(수락 시 타결)할 금액입니다. 멘트에 넣은 가격 변수 중에서만 고를 수 있으며,
|
||||
제시 가격이 타결 상한을 넘거나 협력사 제시가보다 높거나 직전 당사 제안보다 낮으면 그 라운드에 발동하지 않습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* 종결 전용은 와일드카드에만 — 종결 국면은 와일드카드 목록에서만 카드를 뽑으므로
|
||||
협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. */}
|
||||
{isWildcard && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="small" className="font-semibold">종결 전용</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="closing"
|
||||
render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
|
||||
/>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
켜면 협상 중반엔 아껴두고, 라운드 상한·카드 소진 시 마지막 제안으로만 발동합니다.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">최소 라운드</Typography>
|
||||
<Input
|
||||
id="form-card-min-round"
|
||||
type="number"
|
||||
min={1}
|
||||
className="text-xs"
|
||||
{...register('minRound', { valueAsNumber: true })}
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
협력사가 가격을 이 횟수 이상 제시한 뒤부터 발동합니다. (기본 1 = 첫 제안부터)
|
||||
</Typography>
|
||||
{errors.minRound && <p className="text-[10px] text-rose-500">{errors.minRound.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wildcard-only fields */}
|
||||
{isWildcard && (
|
||||
<div className="space-y-4 p-3 bg-muted/40 rounded border border-border">
|
||||
|
||||
@ -15,6 +15,7 @@ export const CARD_VARIABLES: CardVariable[] = [
|
||||
{ name: 'product_name', label: '상품명' },
|
||||
{ name: 'input_price', label: '제시가' }, // 협력사가 이번에 제시한 가격
|
||||
{ name: 'prev_partner_price', label: '협력사 직전가' }, // 협력사의 직전 라운드 제시가
|
||||
{ name: 'prev_customer_price', label: '당사 직전가' }, // 당사의 직전 제안가(갑의 최신 포지션) — WC-05 절충 산식 기준
|
||||
{ name: 'counter_price', label: '제안가' }, // 당사가 이번에 제시하는 카운터 가격
|
||||
{ name: 'target_mid_price', label: '중간가' }, // 앵커·목표 중간값(역제안용)
|
||||
{ name: 'middle_price', label: '절충가' }, // 당사 직전가·협력사 제시가의 절충값
|
||||
@ -29,11 +30,19 @@ export const CONDITION_LABEL = '조건 전략';
|
||||
|
||||
export const isConditionVariable = (name: string): boolean => name === CONDITION_VARIABLE;
|
||||
|
||||
// 시드/구버전 별칭 — 툴바엔 안 올리고 인식·라벨만 지원. chat_engine.vars_for 가 본명과 같은 값으로 채운다.
|
||||
const VARIABLE_ALIASES: Record<string, string> = {
|
||||
anchoring_price: 'anchor_price', // DB 시드 카드·sessions 컬럼 표기
|
||||
};
|
||||
|
||||
const VARIABLE_NAMES = new Set([...CARD_VARIABLES.map((v) => v.name), CONDITION_VARIABLE]);
|
||||
|
||||
export const isKnownVariable = (name: string): boolean => VARIABLE_NAMES.has(name);
|
||||
export const isKnownVariable = (name: string): boolean =>
|
||||
VARIABLE_NAMES.has(name) || name in VARIABLE_ALIASES;
|
||||
|
||||
export const variableLabel = (name: string): string =>
|
||||
name === CONDITION_VARIABLE
|
||||
export const variableLabel = (name: string): string => {
|
||||
const canonical = VARIABLE_ALIASES[name] ?? name;
|
||||
return canonical === CONDITION_VARIABLE
|
||||
? CONDITION_LABEL
|
||||
: (CARD_VARIABLES.find((v) => v.name === name)?.label ?? name);
|
||||
: (CARD_VARIABLES.find((v) => v.name === canonical)?.label ?? canonical);
|
||||
};
|
||||
|
||||
@ -27,6 +27,7 @@ export type CardInput = {
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
tactic?: { min_round?: number; closing?: boolean; offer_variable?: string }; // 전술 운영 규칙. 제안가는 기본 멘트 파싱, offer_variable 로 명시 지정 가능
|
||||
};
|
||||
|
||||
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
|
||||
@ -50,6 +51,7 @@ function toReq(input: CardInput): ReqCreateCard {
|
||||
status: toCardStatusCode(input.status),
|
||||
condition: input.isWildcard ? input.triggerCondition : undefined,
|
||||
memo: input.isWildcard ? input.memo : undefined,
|
||||
tactic: input.tactic,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
creatorName: c.creator_name ?? undefined,
|
||||
successRate: c.success_rate ?? 0,
|
||||
usedCount: c.used_count ?? 0,
|
||||
tactic: (c.tactic as NegotiationCard['tactic']) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||
import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||
import { customFetch } from '@/api/mutator/custom-fetch';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -144,7 +144,13 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||
const handleFile = async (file: File) => {
|
||||
const parsed = parseCsv(await file.text());
|
||||
let parsed: Record<string, string>[];
|
||||
try {
|
||||
parsed = await readSpreadsheetRows(file);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||
id: `row-${i + 1}`,
|
||||
rowNum: i + 2,
|
||||
|
||||
@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||
import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||
import { customFetch } from '@/api/mutator/custom-fetch';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -344,7 +344,13 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
|
||||
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
|
||||
const handleFile = async (file: File) => {
|
||||
const parsed = parseCsv(await file.text());
|
||||
let parsed: Record<string, string>[];
|
||||
try {
|
||||
parsed = await readSpreadsheetRows(file);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
const pick = (r: Record<string, string>, c: UploadColumn): string => {
|
||||
for (const a of c.aliases) if (r[a] !== undefined) return r[a];
|
||||
return '';
|
||||
|
||||
@ -84,21 +84,26 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa
|
||||
sellingPrice: product.selling_price || 0,
|
||||
};
|
||||
}
|
||||
// 신규 등록 기본값 — 금액·코드·수량처럼 상품마다 다른 값은 비워 둔다.
|
||||
// (개발용 더미였던 code=PROD-BAT-###·price=1,000,000·minPrice=800,000·moq='10 EA'·leadTime=14·
|
||||
// origin='대한민국' 이 그대로 저장돼, 숨긴 필드는 화면에 안 보인 채 임의값이 DB 에 들어갔다.
|
||||
// price 는 회사 설정에 따라 협상 기준가로 쓰여 인하율 멘트의 분모가 되므로 특히 위험하다.)
|
||||
// 값을 채워 두는 건 선택형 필드뿐 — 보기 중 하나를 반드시 골라야 하는 항목들이다.
|
||||
return {
|
||||
name: '',
|
||||
code: `PROD-BAT-${Math.floor(100 + Math.random() * 900)}`,
|
||||
code: '',
|
||||
category: '',
|
||||
price: 1000000,
|
||||
minPrice: 800000,
|
||||
price: 0,
|
||||
minPrice: 0,
|
||||
modelName: '',
|
||||
specification: '',
|
||||
manufacturer: '',
|
||||
origin: '대한민국',
|
||||
origin: '',
|
||||
unit: 'EA',
|
||||
shippingType: 1,
|
||||
imageUrl: '',
|
||||
moq: '10 EA',
|
||||
leadTime: 14,
|
||||
moq: '',
|
||||
leadTime: 0,
|
||||
vatYn: true,
|
||||
deliveryFeeYn: false,
|
||||
internetLowestPriceYn: true,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||
@ -6,13 +6,13 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||||
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||
@ -20,42 +20,16 @@ import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||
import {
|
||||
is1v1,
|
||||
toQuotationType,
|
||||
awardStrategySummary,
|
||||
PriceGateAction,
|
||||
DEFAULT_MID_ACTION,
|
||||
DEFAULT_OVER_ACTION,
|
||||
type QuotationMode,
|
||||
} from '../types';
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { showToast } from '@/lib/notify';
|
||||
|
||||
const INTERNET_AVERAGE_FEE = 0.078;
|
||||
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
|
||||
|
||||
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
||||
function toKstLocalInput(date: Date): string {
|
||||
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
||||
return s.slice(0, 16).replace(' ', 'T');
|
||||
}
|
||||
|
||||
function nowKstLocalInput(): string {
|
||||
return toKstLocalInput(new Date());
|
||||
}
|
||||
|
||||
function defaultDueDateLocalInput(): string {
|
||||
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function isFutureLocalInput(value: string): boolean {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) && time > Date.now();
|
||||
}
|
||||
|
||||
function parsePercent(value: string | undefined): number {
|
||||
const n = Number(String(value ?? '').replace('%', '').trim());
|
||||
return Number.isFinite(n) ? n / 100 : 0;
|
||||
}
|
||||
import { defaultDueDateLocalInput, nowKstLocalInput, isFutureLocalInput } from './quotationForm.utils';
|
||||
import { SupplyTypeBadge, SelectedPartnerTable, SelectedCardTable, Segmented, AwardLinePicker } from './QuotationFormParts';
|
||||
import { useTargetPrice } from '../hooks/useTargetPrice';
|
||||
import { useCardGating } from '../hooks/useCardGating';
|
||||
|
||||
type QuotationCreateModalProps = {
|
||||
open: boolean;
|
||||
@ -95,11 +69,13 @@ export function QuotationCreateModal({
|
||||
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
|
||||
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [ceilingPct, setCeilingPct] = useState(''); // 협상 완료 상한율(%) 이 견적 override. 비우면 세팅 기본값
|
||||
const [ceilingTouched, setCeilingTouched] = useState(false); // 상한율을 직접 건드렸는지 — 안 건드렸으면 세팅값 표시
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
|
||||
// 매입가 네고율 차감 — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
|
||||
// 네고율 차감(매입가·판매가 공통) — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
|
||||
const [negoTouched, setNegoTouched] = useState(false);
|
||||
const [applyNego, setApplyNego] = useState(false); // 매입가에서 네고율 차감 여부
|
||||
const [applyNego, setApplyNego] = useState(false); // 네고율 차감 여부
|
||||
// 목표가로 채택한 후보 키 — null이면 최저 후보를 기본 채택.
|
||||
const [selectedCandidateKey, setSelectedCandidateKey] = useState<string | null>(null);
|
||||
|
||||
@ -141,7 +117,6 @@ export function QuotationCreateModal({
|
||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
||||
const supplyTypeBySupplier = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||
@ -177,153 +152,52 @@ export function QuotationCreateModal({
|
||||
});
|
||||
|
||||
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||
// ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ──
|
||||
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
||||
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
||||
const conditionUnfilled = (c: NegotiationCard) =>
|
||||
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
|
||||
// (2) 인터넷 최저가({internet_lowest_price}) 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
|
||||
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
|
||||
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
|
||||
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
||||
const lowestPriceLeak = (c: NegotiationCard) =>
|
||||
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
||||
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
||||
const blockReason = (c: NegotiationCard): string | null =>
|
||||
c.isWildcard && c.status !== 'ACTIVE'
|
||||
? '미승인 와일드카드'
|
||||
: conditionUnfilled(c)
|
||||
? `${CONDITION_LABEL} 미작성`
|
||||
: lowestPriceLeak(c)
|
||||
? '인터넷 최저가 미수집'
|
||||
: null;
|
||||
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
||||
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
||||
const rankedCards = cardRows
|
||||
.slice()
|
||||
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
||||
const cardOptions: ComboOption[] = rankedCards
|
||||
.map((card, i) => {
|
||||
const reason = blockReason(card);
|
||||
return {
|
||||
id: card.id,
|
||||
label: card.title,
|
||||
disabled: !!reason,
|
||||
node: (
|
||||
<div className={reason ? 'opacity-60' : undefined}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{card.usedCount > 0 && (
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
|
||||
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{card.isWildcard ? '와일드' : '협상'}
|
||||
</span>
|
||||
{reason && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
||||
{reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
||||
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
||||
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
||||
const autoSelectedRef = useRef(false);
|
||||
|
||||
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoSelectedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
|
||||
autoSelectedRef.current = true;
|
||||
setCardDetails((m) => {
|
||||
const next = new Map(m);
|
||||
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
|
||||
return next;
|
||||
});
|
||||
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
|
||||
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, topRankedKey]);
|
||||
|
||||
// 이미 고른 카드 중, 상품을 최저가 미수집 상품으로 바꾸면 {internet_lowest_price} 인용 카드는 자동 해제.
|
||||
// (picklist disabled 는 신규 선택만 막으므로, 상품 변경 후 잔존 선택분을 여기서 정리해 노출을 막는다.)
|
||||
useEffect(() => {
|
||||
if (!lowestUnavailable) return;
|
||||
const leakIds = new Set(
|
||||
cardRows.filter((c) => (c.scriptPreview ?? '').includes('{internet_lowest_price}')).map((c) => c.id),
|
||||
);
|
||||
if (leakIds.size === 0) return;
|
||||
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
|
||||
// productId 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lowestUnavailable, productId]);
|
||||
|
||||
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
||||
const selectedCardRows = selectedCardIds.map((id) => {
|
||||
const d = cardDetails.get(id);
|
||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||
// ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ──
|
||||
const {
|
||||
settingMarginPct,
|
||||
negoToggleAvailable,
|
||||
targetBreakdown,
|
||||
autoTarget,
|
||||
activeCandidateKey,
|
||||
effectiveApplyNego,
|
||||
effectiveMdPrice,
|
||||
mdRequired,
|
||||
targetReady,
|
||||
targetLimitExceeded,
|
||||
estimatedTargetPrice,
|
||||
submitMdPrice,
|
||||
settingCeilingRate,
|
||||
doneCeilingPrice,
|
||||
} = useTargetPrice({
|
||||
quotationSettings,
|
||||
settingId,
|
||||
productId,
|
||||
isReType,
|
||||
internetLowest,
|
||||
purchase,
|
||||
selling,
|
||||
unitPrice,
|
||||
mdPrice,
|
||||
mdTouched,
|
||||
negoTouched,
|
||||
applyNego,
|
||||
selectedCandidateKey,
|
||||
doneCeilingRateOverride: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null,
|
||||
});
|
||||
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
||||
const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율)
|
||||
const settingMarginPct = +(settingMargin * 100).toFixed(1);
|
||||
// 매입가 네고율 차감 여부 — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정.
|
||||
const negoAvailable = settingMargin > 0;
|
||||
const effectiveApplyNego = negoTouched ? applyNego : negoAvailable;
|
||||
const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로
|
||||
const negoLabel = (base: string) =>
|
||||
margin > 0 ? `${base} × (1−네고율 ${+(margin * 100).toFixed(1)}%)` : `${base} (네고율 미적용)`;
|
||||
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
|
||||
const targetBreakdown = [
|
||||
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
|
||||
{ key: 'purchase_price', label: negoLabel(label('item.purchase_price')), raw: purchase, rate: margin, show: isReType && purchase != null },
|
||||
{ key: 'selling_price', label: negoLabel(label('item.selling_price')), raw: selling, rate: margin, show: isReType && selling != null },
|
||||
]
|
||||
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
|
||||
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
|
||||
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
|
||||
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
|
||||
// 기본 채택 후보 = 최저(동률이면 첫 후보). 네고율 체크박스는 매입가 후보(없으면 판매가)에 붙인다.
|
||||
const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null;
|
||||
const negoCandidateKey = negoAvailable
|
||||
? (targetBreakdown.find((c) => c.key === 'purchase_price')?.key ?? targetBreakdown.find((c) => c.key === 'selling_price')?.key ?? null)
|
||||
: null;
|
||||
// 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백.
|
||||
const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined;
|
||||
const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey;
|
||||
const targetFromCandidate = pickedCandidate ? pickedCandidate.value : autoTarget;
|
||||
// 구매담당자 제시가 필드엔 채택 후보값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
|
||||
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
|
||||
const effectiveMdPrice = mdTouched ? mdPrice : (targetFromCandidate != null ? String(targetFromCandidate) : mdPrice);
|
||||
const mdNum = Number(effectiveMdPrice) || 0;
|
||||
// 기본(최저·세팅네고)에서 벗어난 선택/토글이면 그 목표가를 md_price 로 박아 서버 저장값과 화면을 일치시킨다.
|
||||
// (서버는 세팅 rate·최저로 재계산하므로, 오버라이드를 안 보내면 후보 화면과 저장 목표가가 어긋난다.)
|
||||
const divergedFromDefault = negoTouched || (!!pickedCandidate && pickedCandidate.key !== minCandidateKey);
|
||||
const submitMdPrice = mdTouched && mdPrice
|
||||
? Number(mdPrice)
|
||||
: divergedFromDefault && targetFromCandidate != null
|
||||
? targetFromCandidate
|
||||
: null;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
const mdRequired = !!productId && !hasItemCandidate;
|
||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 채택 후보값.
|
||||
const estimatedTargetPrice = mdNum > 0 ? mdNum : targetFromCandidate;
|
||||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||||
: null;
|
||||
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
||||
|
||||
// ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ──
|
||||
const { blockReason, cardOptions, selectedCardRows } = useCardGating({
|
||||
cardRows,
|
||||
productId,
|
||||
internetLowest,
|
||||
estimatedTargetPrice,
|
||||
open,
|
||||
selectedCardIds,
|
||||
cardDetails,
|
||||
setSelectedCardIds,
|
||||
setCardDetails,
|
||||
});
|
||||
if (!open) return null;
|
||||
|
||||
const selectMode = (next: QuotationMode) => {
|
||||
@ -400,6 +274,8 @@ export function QuotationCreateModal({
|
||||
mdPrice: submitMdPrice,
|
||||
midAction: oneToOne ? midAction : undefined,
|
||||
overAction: oneToOne ? overAction : undefined,
|
||||
// 완료 상한율 override — 직접 건드렸을 때만 전송(‰). 비우면 서버가 세팅 기본값 사용.
|
||||
doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : undefined,
|
||||
});
|
||||
if (ok) onClose();
|
||||
} finally {
|
||||
@ -560,7 +436,7 @@ export function QuotationCreateModal({
|
||||
{(value) => {
|
||||
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
|
||||
return qs
|
||||
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
|
||||
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count} · 타결상한 +${qs.done_ceiling_rate / 10}%`
|
||||
: '';
|
||||
}}
|
||||
</SelectValue>
|
||||
@ -568,46 +444,27 @@ export function QuotationCreateModal({
|
||||
<SelectContent>
|
||||
{quotationSettings.map((qs) => (
|
||||
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
|
||||
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count}
|
||||
[{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count} · 타결상한 +{qs.done_ceiling_rate / 10}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{oneToOne ? (
|
||||
<>
|
||||
{/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */}
|
||||
<AwardLinePicker
|
||||
mid={midAction}
|
||||
over={overAction}
|
||||
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
|
||||
onMid={(v) => {
|
||||
setMidAction(v);
|
||||
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
|
||||
}}
|
||||
onOver={(v) => {
|
||||
setOverAction(v);
|
||||
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* 경매(1:N) — 낙찰 기준·협상카드 없음. 최저가 자동 낙찰 안내만. */
|
||||
<div className="rounded border border-border bg-muted/20 p-3 flex items-start gap-2.5">
|
||||
<Gavel size={18} className="text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
|
||||
1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 낙찰 기준·협상카드 설정이 없습니다.
|
||||
</Typography>
|
||||
{/* ── 타결 기준 (협상) — 봇이 어느 가격까지 합의하면 타결로 볼지 ── */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="grid place-items-center h-5 w-5 rounded-md bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400 shrink-0"><CheckCheck size={12} /></span>
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-bold block leading-tight">타결 기준 <span className="text-muted-foreground font-normal text-[10px]">· 협상</span></Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight">봇이 어느 가격까지 합의하면 타결로 볼지 정합니다</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 space-y-4">
|
||||
|
||||
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 매입가 후보 행의 체크박스로 네고율 차감 여부 조정. 숨김필드는 제외. */}
|
||||
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 네고율 차감 토글은 매입가·판매가 공통이라 리스트 상단에 둔다. 숨김필드는 제외. */}
|
||||
{productId && targetBreakdown.length > 0 && (
|
||||
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label">
|
||||
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
|
||||
@ -620,14 +477,29 @@ export function QuotationCreateModal({
|
||||
상품 상세에서 수정
|
||||
</button>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저).
|
||||
</Typography>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
후보를 선택하면 그 값이 목표가로 정해집니다 (기본: 최저).
|
||||
</Typography>
|
||||
{negoToggleAvailable && (
|
||||
<label className="flex items-center gap-1 shrink-0 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary cursor-pointer"
|
||||
checked={effectiveApplyNego}
|
||||
onChange={(e) => {
|
||||
setNegoTouched(true);
|
||||
setApplyNego(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground whitespace-nowrap">네고율 {settingMarginPct}% 차감</Typography>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1" role="radiogroup">
|
||||
{targetBreakdown.map((c) => {
|
||||
const isMin = autoTarget != null && c.value === autoTarget;
|
||||
const isActive = c.key === activeCandidateKey;
|
||||
const showNego = c.key === negoCandidateKey;
|
||||
return (
|
||||
<div
|
||||
key={c.key}
|
||||
@ -651,20 +523,6 @@ export function QuotationCreateModal({
|
||||
{isActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
|
||||
</span>
|
||||
<Typography as="span" variant="small" className="flex-1 min-w-0 truncate text-[11px] text-muted-foreground">{c.label}</Typography>
|
||||
{showNego && (
|
||||
<label className="flex items-center gap-1 shrink-0 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary cursor-pointer"
|
||||
checked={effectiveApplyNego}
|
||||
onChange={(e) => {
|
||||
setNegoTouched(true);
|
||||
setApplyNego(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground whitespace-nowrap">네고율 {settingMarginPct}% 차감</Typography>
|
||||
</label>
|
||||
)}
|
||||
<Typography as="span" variant="small" className="w-16 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/70">{c.raw.toLocaleString()}</Typography>
|
||||
<Typography as="span" variant="small" className={cn('w-20 shrink-0 text-right font-mono font-bold tabular-nums', isActive && 'text-primary')}>₩{c.value.toLocaleString()}</Typography>
|
||||
<span className="w-9 shrink-0 text-right">
|
||||
@ -711,6 +569,91 @@ export function QuotationCreateModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결, 초과하면 결렬. 비우면 세팅 기본율. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="label">타결 상한가</Typography>
|
||||
{/* OFF=세팅 기본율 그대로 · ON=이 견적만 직접 지정 */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">이 견적만 조정</Typography>
|
||||
<Switch
|
||||
checked={ceilingTouched}
|
||||
onCheckedChange={(on) => {
|
||||
setCeilingTouched(on);
|
||||
if (on && ceilingPct === '') setCeilingPct(String(settingCeilingRate / 10)); // 켤 때 세팅값에서 출발
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{ceilingTouched ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">목표가 +</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={0}
|
||||
className="h-8 w-20 text-xs text-right"
|
||||
value={ceilingPct}
|
||||
onChange={(e) => setCeilingPct(e.target.value)}
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">%</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
세팅 기본 <span className="font-semibold text-foreground">목표가 +{settingCeilingRate / 10}%</span> 적용
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
{doneCeilingPrice != null ? (
|
||||
<>최종 합의가가 <span className="font-bold text-foreground">₩{doneCeilingPrice.toLocaleString()}</span> 이하면 타결, 초과하면 결렬.</>
|
||||
) : '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 낙찰 기준 (마감) — 타결된 투찰가로 누구를 낙찰시킬지 ── */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="grid place-items-center h-5 w-5 rounded-md bg-primary/10 text-primary shrink-0"><Gavel size={12} /></span>
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-bold block leading-tight">낙찰 기준 <span className="text-muted-foreground font-normal text-[10px]">· 마감</span></Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight">타결된 투찰가로 마감 때 누구를 낙찰시킬지 정합니다</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{oneToOne ? (
|
||||
/* 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */
|
||||
<AwardLinePicker
|
||||
mid={midAction}
|
||||
over={overAction}
|
||||
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
|
||||
onMid={(v) => {
|
||||
setMidAction(v);
|
||||
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
|
||||
}}
|
||||
onOver={(v) => {
|
||||
setOverAction(v);
|
||||
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* 경매(1:N) — 최저가 자동 낙찰. */
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Gavel size={16} className="text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<Typography as="span" variant="small" className="font-semibold block">최저가 자동 낙찰</Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
|
||||
1:N 견적은 가장 낮은 투찰가가 자동 낙찰됩니다. 별도 낙찰 기준 설정이 없습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">협력사 안내 메모 (선택)</Typography>
|
||||
<textarea
|
||||
@ -815,235 +758,3 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
||||
|
||||
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
||||
function SupplyTypeBadge({ type }: { type?: number }) {
|
||||
if (type === undefined) {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
미정
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
|
||||
{supplierTypeLabel(type)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
||||
function SelectedPartnerTable({
|
||||
rows,
|
||||
supplyTypeBySupplier,
|
||||
showSupplyType,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; name: string; email: string }[];
|
||||
supplyTypeBySupplier: Map<string, number>;
|
||||
showSupplyType: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
||||
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
||||
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
||||
function SelectedCardTable({
|
||||
rows,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
||||
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{r.isWildcard ? '와일드' : '협상'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||||
function Segmented({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string; sub?: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'rounded border p-2.5 text-left transition-all cursor-pointer',
|
||||
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
|
||||
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
|
||||
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
|
||||
function AwardLinePicker({
|
||||
mid, over, onMid, onOver,
|
||||
}: {
|
||||
mid: number;
|
||||
over: number;
|
||||
onMid: (v: number) => void;
|
||||
onOver: (v: number) => void;
|
||||
}) {
|
||||
const A = PriceGateAction.AWARD;
|
||||
const O = PriceGateAction.OPEN;
|
||||
const zones = [
|
||||
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
|
||||
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
|
||||
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label">낙찰 기준</Typography>
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
|
||||
</Typography>
|
||||
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
|
||||
<div className="flex rounded-md overflow-hidden border border-border text-center">
|
||||
{zones.map((z, i) => {
|
||||
const body = (
|
||||
<>
|
||||
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
|
||||
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
|
||||
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
|
||||
return z.locked ? (
|
||||
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
|
||||
) : (
|
||||
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
|
||||
<div className="relative h-6">
|
||||
{[
|
||||
{ left: '33.3333%', label: '앵커링가' },
|
||||
{ left: '66.6667%', label: '목표가' },
|
||||
].map((mk) => (
|
||||
<span
|
||||
key={mk.label}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
|
||||
style={{ left: mk.left }}
|
||||
>
|
||||
<span className="leading-none">▲</span>
|
||||
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
|
||||
{(() => {
|
||||
const t = awardStrategySummary(mid, over);
|
||||
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
|
||||
return (
|
||||
<Typography as="p" variant="small" className="text-[11px]">
|
||||
<span className="font-bold text-primary">{t.strategy}</span>
|
||||
<span className="text-muted-foreground"> · {t.desc}</span>
|
||||
{isBasic && (
|
||||
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||
import { maskPrices } from '@/lib/utils';
|
||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { renderEmphasis } from '@/lib/emphasis';
|
||||
import { renderCardScriptPreview } from '@/features/cards/editor';
|
||||
|
||||
// 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙).
|
||||
// target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심.
|
||||
@ -386,7 +387,7 @@ function UsedCardBox({
|
||||
</div>
|
||||
) : usedCard.script ? (
|
||||
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
||||
{maskPrices(usedCard.script)}
|
||||
{renderCardScriptPreview(maskPrices(usedCard.script))}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -1,42 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { X, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { X, RefreshCw, Loader2, CheckCheck, Lightbulb } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { type Partner, sessionStatusLabel } from '../../types';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||
import { CloseReason } from '@/api/generated/model';
|
||||
import { useCardGating } from '../../hooks/useCardGating';
|
||||
import type { RegenerateInput } from '../../hooks/useQuotations';
|
||||
import { SelectedCardTable } from '../QuotationFormParts';
|
||||
import { kstLocalInputAfter, isFutureLocalInput } from '../quotationForm.utils';
|
||||
import { type Partner, type SessionView, type NegotiationCard, sessionStatusLabel } from '../../types';
|
||||
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||
|
||||
// 마감 사유별로 이번 라운드에서 손볼 레버를 짚어준다(안내만 — 선택값은 바꾸지 않는다).
|
||||
const REGEN_HINT: Record<CloseReason, string> = {
|
||||
[CloseReason.AWARDED]: '낙찰로 마감된 건입니다 — 같은 조건으로 한 라운드 더 열면 직전 낙찰과 중복될 수 있습니다.',
|
||||
[CloseReason.OPEN_PRICE]: '목표가를 넘겨 개찰됐습니다 — 목표가·타결 상한을 다시 잡거나, 가격 근거 카드를 교체해 보세요.',
|
||||
[CloseReason.OPEN_EQUAL]: '동가로 개찰됐습니다 — 동가 업체만 다시 부르고, 가격 외 조건(납기·수량) 카드를 넣어 보세요.',
|
||||
[CloseReason.OPEN_NOSHOW]: '전원 미응찰로 개찰됐습니다 — 마감기한을 늘리거나 부를 공급사를 바꿔 보세요.',
|
||||
[CloseReason.OPEN_REJECT]: '협상 거부로 개찰됐습니다 — 거부 사유를 확인하고 카드를 교체해 보세요.',
|
||||
};
|
||||
|
||||
type RegenerateModalProps = {
|
||||
open: boolean;
|
||||
/** 직전 라운드(원 견적) — 결과 요약 밴드·협상기간·상한율 기본값의 근거. */
|
||||
quotation: QuotationData;
|
||||
/** 직전 라운드 세션 — 공급사별 투찰가·상태와 승계 목표가를 읽는다. */
|
||||
sessionViews: SessionView[];
|
||||
/** 현재 견적에 연결된 공급사만. */
|
||||
partners: Partner[];
|
||||
/** 공급사별 협상 단계(세션 상태 코드) — 행에 함께 표시. */
|
||||
sessionStatusBySupplier?: Record<string, number>;
|
||||
/** 회사 협상카드 카탈로그(재선택 후보). */
|
||||
cards: NegotiationCard[];
|
||||
/** 직전 라운드에서 실제 쓴 카드 id — 기본 선택 + '직전 사용' 배지. */
|
||||
previousCardIds: string[];
|
||||
/** 적용 중인 견적 세팅의 타결 상한율(‰) — 견적 override 가 없을 때의 기본값. */
|
||||
settingCeilingRate: number;
|
||||
/** 카드 게이팅용 상품 정보(시장가 인용 카드 판정). */
|
||||
productId: string;
|
||||
internetLowest: number | null;
|
||||
/** 기본 선택 = 원 라운드의 공급사들. */
|
||||
defaultSupplierIds: string[];
|
||||
/** 확정 → 재생성 호출. 성공(true) 시 모달 닫힘. */
|
||||
onConfirm: (supplierIds: string[]) => Promise<boolean> | boolean;
|
||||
onConfirm: (input: RegenerateInput) => Promise<boolean> | boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달.
|
||||
// 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다.
|
||||
export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) {
|
||||
// 마감된 견적의 '다음 라운드'를 만드는 모달.
|
||||
// 상품·견적번호·담당자는 원 견적에서 잠긴 채 이어받고, 직전 라운드가 깨진 원인에 해당하는
|
||||
// 레버(공급사·마감기한·목표가·타결 상한·협상카드)만 다시 잡게 한다.
|
||||
export function RegenerateModal({
|
||||
open,
|
||||
quotation,
|
||||
sessionViews,
|
||||
partners,
|
||||
cards,
|
||||
previousCardIds,
|
||||
settingCeilingRate,
|
||||
productId,
|
||||
internetLowest,
|
||||
defaultSupplierIds,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: RegenerateModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const inheritedTarget = sessionViews.find((s) => s.target_price > 0)?.target_price ?? null;
|
||||
const inheritedCeilingRate = quotation.done_ceiling_rate ?? settingCeilingRate;
|
||||
|
||||
const [selected, setSelected] = useState<string[]>(defaultSupplierIds);
|
||||
const [dueDate, setDueDate] = useState(() => kstLocalInputAfter(inheritedDurationMs(quotation)));
|
||||
const [targetPrice, setTargetPrice] = useState(inheritedTarget != null ? String(inheritedTarget) : '');
|
||||
const [ceilingTouched, setCeilingTouched] = useState(false); // OFF = 원 견적 상한율 그대로
|
||||
const [ceilingPct, setCeilingPct] = useState('');
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>(previousCardIds);
|
||||
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(
|
||||
() => new Map(cards.filter((c) => previousCardIds.includes(c.id)).map((c) => [c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }])),
|
||||
);
|
||||
const [cardQ, setCardQ] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const cardSearch = useListCards({ search: cardQ || undefined, size: 30 });
|
||||
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||
|
||||
// 공급사 1곳=재협상(1:1), 여러 곳=재견적(1:N) — 백엔드 타입 자동결정과 동일하게 미리 안내.
|
||||
// 협상카드는 1:1 에서만 발동하므로 카드 선택도 이 값으로 가른다.
|
||||
const nextIs1v1 = selected.length <= 1;
|
||||
const targetNum = Number(targetPrice);
|
||||
const targetValid = Number.isFinite(targetNum) && targetNum > 0;
|
||||
const effectiveCeilingRate = ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : inheritedCeilingRate;
|
||||
// 타결 상한가 = 목표가×(1+율/1000), 10원 반올림(백엔드 박제식과 동일).
|
||||
const doneCeilingPrice = targetValid ? Math.round((targetNum * (1000 + effectiveCeilingRate)) / 1000 / 10) * 10 : null;
|
||||
|
||||
const { blockReason, cardOptions, selectedCardRows } = useCardGating({
|
||||
cardRows,
|
||||
productId,
|
||||
internetLowest,
|
||||
estimatedTargetPrice: targetValid ? targetNum : null,
|
||||
open,
|
||||
selectedCardIds,
|
||||
cardDetails,
|
||||
setSelectedCardIds,
|
||||
setCardDetails,
|
||||
previousCardIds,
|
||||
});
|
||||
if (!open) return null;
|
||||
|
||||
const sessionBySupplier = new Map(sessionViews.map((s) => [s.supplier_id, s]));
|
||||
const previousSet = new Set(previousCardIds);
|
||||
const keptCardCount = selectedCardIds.filter((id) => previousSet.has(id)).length;
|
||||
const cardsChanged =
|
||||
nextIs1v1 && (selectedCardIds.length !== previousSet.size || selectedCardIds.some((id) => !previousSet.has(id)));
|
||||
const targetChanged = targetValid && inheritedTarget != null && targetNum !== inheritedTarget;
|
||||
const targetDeltaPct = targetChanged && inheritedTarget ? ((targetNum - inheritedTarget) / inheritedTarget) * 100 : null;
|
||||
const hint = quotation.close_reason != null ? REGEN_HINT[quotation.close_reason as CloseReason] : null;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]));
|
||||
|
||||
// 공급사 1곳=재협상(1:1), 여러 곳=재견적(1:N) — 백엔드 타입 자동결정과 동일하게 미리 안내.
|
||||
const nextTypeLabel = selected.length <= 1 ? '재협상 (1:1)' : '재견적 (1:N)';
|
||||
const toggleCard = (id: string) => {
|
||||
const row = cardRows.find((c) => c.id === id);
|
||||
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
|
||||
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const selectAllCards = () => {
|
||||
const rows = cardRows.filter((c) => !blockReason(c));
|
||||
setCardDetails((m) => {
|
||||
const next = new Map(m);
|
||||
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
|
||||
return next;
|
||||
});
|
||||
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
|
||||
};
|
||||
|
||||
const handle = async () => {
|
||||
if (submitting || selected.length === 0) return;
|
||||
if (!isFutureLocalInput(dueDate)) {
|
||||
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!targetValid) {
|
||||
showToast('목표가를 0보다 큰 값으로 입력해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ok = await onConfirm(selected);
|
||||
const ok = await onConfirm({
|
||||
supplierIds: selected,
|
||||
dueDate,
|
||||
// 승계와 같은 값은 안 보낸다 — 서버가 직전 라운드 값을 그대로 이어쓰게 둔다.
|
||||
targetPrice: targetChanged ? targetNum : null,
|
||||
cardIds: cardsChanged ? selectedCardIds : null,
|
||||
doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null,
|
||||
});
|
||||
if (ok) onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@ -45,57 +166,213 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[55] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
<div className="w-full max-w-3xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="text-foreground" size={16} />
|
||||
<Typography variant="small" className="font-bold">다음 견적 재생성</Typography>
|
||||
<Typography variant="small" className="font-bold">
|
||||
다음 견적 재생성 <span className="text-muted-foreground font-normal">· {quotation.round ?? 1}차 → {(quotation.round ?? 1) + 1}차</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="my-4 space-y-3 text-xs">
|
||||
<Typography as="span" variant="small" className="text-muted-foreground block leading-relaxed">
|
||||
상품 · 견적번호 · 협상기간 · 카드는 이 견적에서 이어받습니다. 다음 견적에 부를 공급사만 고르세요.
|
||||
</Typography>
|
||||
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||
{partners.map((part) => {
|
||||
const isChecked = selected.includes(part.id ?? '');
|
||||
return (
|
||||
<label
|
||||
key={part.id}
|
||||
className="flex items-center justify-between gap-2.5 p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggle(part.id ?? '')}
|
||||
className="accent-primary h-4 w-4 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground">
|
||||
이메일: {part.managerEmail}
|
||||
</Typography>
|
||||
<div className="my-4 space-y-4 text-xs">
|
||||
{/* ── 직전 라운드 결과 — 무엇을 바꿔야 할지의 근거 ── */}
|
||||
<Section title="직전 라운드 결과" sub={`${quotation.round ?? 1}차`}>
|
||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
||||
{hint && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20 px-2.5 py-2">
|
||||
<Lightbulb size={13} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
<Typography as="span" variant="small" className="text-[11px] leading-snug">{hint}</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 공급사 ── */}
|
||||
<Section title="다음 라운드에 부를 공급사" sub={`${selected.length}곳 · ${nextIs1v1 ? '재협상 (1:1)' : '재견적 (1:N)'}`}>
|
||||
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
||||
{partners.map((part) => {
|
||||
const sv = sessionBySupplier.get(part.id ?? '');
|
||||
return (
|
||||
<label
|
||||
key={part.id}
|
||||
className="flex items-center justify-between gap-2.5 p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(part.id ?? '')}
|
||||
onChange={() => toggle(part.id ?? '')}
|
||||
className="accent-primary h-4 w-4 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography>
|
||||
<Typography as="span" variant="small" className="text-muted-foreground">
|
||||
{sv?.reject_reason ? `거부 사유: ${sv.reject_reason}` : `이메일: ${part.managerEmail}`}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sessionStatusBySupplier?.[part.id ?? ''] != null && (
|
||||
<span className="shrink-0 text-[9px] font-mono px-1.5 py-0.5 rounded-full border border-border bg-muted text-muted-foreground">
|
||||
{sessionStatusLabel(sessionStatusBySupplier[part.id ?? ''])}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Typography as="span" variant="small" className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{sv?.bid_price != null ? `직전 투찰 ₩${sv.bid_price.toLocaleString()}` : '미응찰'}
|
||||
</Typography>
|
||||
{sv && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full border border-border bg-muted text-muted-foreground">
|
||||
{sessionStatusLabel(sv.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 이번 라운드 조건 ── */}
|
||||
<Section title="이번 라운드 조건" sub="비워두면 직전 라운드 값 그대로">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">마감기한</Typography>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="text-xs"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
기본값 = 원 견적과 같은 협상기간.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">목표가</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={targetPrice}
|
||||
onChange={(e) => setTargetPrice(e.target.value)}
|
||||
placeholder="직전 라운드 목표가"
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
{targetDeltaPct != null
|
||||
? `직전 목표가 ₩${inheritedTarget?.toLocaleString()} 대비 ${targetDeltaPct > 0 ? '+' : '−'}${Math.abs(targetDeltaPct).toFixed(1)}%`
|
||||
: '직전 라운드 목표가를 그대로 이어받습니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결. */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="label" variant="label">타결 상한가</Typography>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">이 라운드만 조정</Typography>
|
||||
<Switch
|
||||
checked={ceilingTouched}
|
||||
onCheckedChange={(on) => {
|
||||
setCeilingTouched(on);
|
||||
if (on && ceilingPct === '') setCeilingPct(String(inheritedCeilingRate / 10)); // 켤 때 원 견적값에서 출발
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>선택: <b className="text-foreground">{selected.length}</b>곳</span>
|
||||
<span>유형: <b className="text-foreground">{nextTypeLabel}</b></span>
|
||||
</div>
|
||||
{ceilingTouched ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">목표가 +</Typography>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={0}
|
||||
className="h-8 w-20 text-xs text-right"
|
||||
value={ceilingPct}
|
||||
onChange={(e) => setCeilingPct(e.target.value)}
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">%</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
원 견적 설정 <span className="font-semibold text-foreground">목표가 +{inheritedCeilingRate / 10}%</span> 유지
|
||||
</Typography>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
{doneCeilingPrice != null
|
||||
? <>최종 합의가가 <span className="font-bold text-foreground">₩{doneCeilingPrice.toLocaleString()}</span> 이하면 타결, 초과하면 결렬.</>
|
||||
: '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 협상카드 재선택 (1:1 전용) ── */}
|
||||
<Section
|
||||
title="협상카드"
|
||||
sub={nextIs1v1 ? `${selectedCardIds.length}장 · 직전 유지 ${keptCardCount}/${previousSet.size}` : '1:N 재견적은 카드 미발동'}
|
||||
>
|
||||
{nextIs1v1 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
직전 라운드 카드가 기본 선택돼 있습니다 — 같은 멘트를 다시 던지지 않으려면 교체하세요.
|
||||
</Typography>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
|
||||
<CheckCheck size={13} />
|
||||
현재 목록 전체선택
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground"
|
||||
onClick={() => setSelectedCardIds([])}
|
||||
disabled={selectedCardIds.length === 0}
|
||||
>
|
||||
<X size={13} />
|
||||
전체해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
variant="inline"
|
||||
multiple
|
||||
values={selectedCardIds}
|
||||
options={cardOptions}
|
||||
loading={cardSearch.isLoading}
|
||||
onQueryChange={setCardQ}
|
||||
onToggle={(opt) => toggleCard(opt.id)}
|
||||
searchPlaceholder="카드명·번호·스크립트 검색..."
|
||||
emptyText="협상카드가 없습니다"
|
||||
maxListHeight="max-h-60"
|
||||
/>
|
||||
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
|
||||
</div>
|
||||
) : (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
공급사를 2곳 이상 고르면 최저가 자동 낙찰(1:N)이라 협상카드가 쓰이지 않습니다. 카드는 원 견적 그대로 둡니다.
|
||||
</Typography>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 생성 직전 변경 요약 ── */}
|
||||
<div className="rounded border border-border bg-muted/30 px-3 py-2">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground block mb-1">
|
||||
{quotation.round ?? 1}차 대비 변경
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] leading-snug">
|
||||
{[
|
||||
`공급사 ${defaultSupplierIds.length}곳 → ${selected.length}곳`,
|
||||
targetChanged ? `목표가 ₩${inheritedTarget?.toLocaleString()} → ₩${targetNum.toLocaleString()}` : '목표가 유지',
|
||||
cardsChanged ? `카드 ${previousSet.size}장 → ${selectedCardIds.length}장` : '카드 유지',
|
||||
ceilingTouched && ceilingPct !== '' ? `타결 상한 +${ceilingPct}%` : '타결 상한 유지',
|
||||
].join(' · ')}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground mt-1 leading-snug">
|
||||
상품 · 견적번호 · 담당자는 원 견적에서 그대로 이어받습니다. 생성해도 초청 메일은 자동 발송되지 않습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -117,3 +394,24 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 원 견적의 협상기간(ms). 백엔드 승계식과 같은 1시간 하한을 적용한다. */
|
||||
function inheritedDurationMs(quotation: QuotationData): number {
|
||||
const start = new Date(quotation.start_time ?? '').getTime();
|
||||
const end = new Date(quotation.end_time ?? '').getTime();
|
||||
const span = Number.isFinite(start) && Number.isFinite(end) ? end - start : 0;
|
||||
return Math.max(span, 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** 모달 안의 한 섹션 — 제목줄 + 본문. */
|
||||
function Section({ title, sub, children }: { title: string; sub?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Typography as="span" variant="label">{title}</Typography>
|
||||
{sub && <Typography as="span" variant="small" className="text-[10px] text-muted-foreground">{sub}</Typography>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,11 +23,13 @@ import {
|
||||
mapServerSessionView,
|
||||
mapServerCardView,
|
||||
chainRoundState,
|
||||
type NegotiationCard,
|
||||
} from '../../types';
|
||||
import { QuotationStatus } from '@/api/generated/model';
|
||||
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||
import { RoundTimeline } from './RoundTimeline';
|
||||
import { RegenerateModal } from './RegenerateModal';
|
||||
import type { RegenerateInput } from '../../hooks/useQuotations';
|
||||
import { SessionsStatusTab } from './SessionsStatusTab';
|
||||
import { TargetPriceModal } from './TargetPriceModal';
|
||||
import { QuotationCardsTab } from './QuotationCardsTab';
|
||||
@ -42,8 +44,10 @@ type QuotationDetailSheetProps = {
|
||||
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
|
||||
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
||||
onSwitchRound: (qtId: string) => void;
|
||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
|
||||
onRegenerate: (qtId: string, supplierIds: string[]) => Promise<string | null>;
|
||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사·기한·목표가·카드 재선택). 성공 시 새 qt_id 반환. */
|
||||
onRegenerate: (qtId: string, input: RegenerateInput) => Promise<string | null>;
|
||||
/** 재생성 모달의 협상카드 재선택 후보(회사 카드 카탈로그). */
|
||||
cards: NegotiationCard[];
|
||||
/** 협상 초청 메일 — 견적 단위(미발송 세션 전체) 발송. */
|
||||
onNotify: (qtId: string) => Promise<void>;
|
||||
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
|
||||
@ -55,6 +59,7 @@ type QuotationDetailSheetProps = {
|
||||
|
||||
export function QuotationDetailSheet({
|
||||
quotation,
|
||||
cards,
|
||||
onCloseQuotation,
|
||||
onAward,
|
||||
onSwitchRound,
|
||||
@ -98,11 +103,15 @@ export function QuotationDetailSheet({
|
||||
const serverCards = cardsQuery.data?.cards ?? [];
|
||||
// 재생성 모달 기본 선택 = 이 라운드에 부른 공급사들(세션 distinct supplier).
|
||||
const currentSupplierIds = [...new Set(serverSessions.map((s) => s.supplier_id))];
|
||||
// 재생성 모달엔 '현재 견적에 연결된 공급사'만 + 각자의 협상 단계(세션 상태)를 함께 보여준다.
|
||||
// 재생성 모달엔 '현재 견적에 연결된 공급사'만 + 각자의 협상 단계·직전 투찰가를 함께 보여준다.
|
||||
const connectedPartners = partners.filter((p) => currentSupplierIds.includes(p.id ?? ''));
|
||||
const sessionStatusBySupplier: Record<string, number> = Object.fromEntries(
|
||||
serverSessions.map((s) => [s.supplier_id, s.status]),
|
||||
);
|
||||
// 재생성 카드 재선택 기본값 = 이 라운드가 실제로 쓴 카드.
|
||||
const previousCardIds = serverCards
|
||||
.map((c) => c.nego_card_id ?? c.wild_card_id ?? '')
|
||||
.filter((id): id is string => !!id);
|
||||
// 타결 상한율(‰) — 견적 override 가 없으면 적용 중인 견적 세팅값이 기본.
|
||||
const settingCeilingRate =
|
||||
quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id)?.done_ceiling_rate ?? 50;
|
||||
|
||||
// 재생성 버튼은 '체인의 마지막 차수(마감됨)'에서만 노출. 체인 로딩 끝난 뒤 판정해 옛 라운드에서 깜빡임 방지.
|
||||
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
|
||||
@ -351,14 +360,21 @@ export function QuotationDetailSheet({
|
||||
);
|
||||
})()}
|
||||
|
||||
{regenOpen && (
|
||||
{/* 카드·세션이 도착한 뒤에 열어야 '직전 라운드 카드' 기본선택과 결과 요약이 제 값으로 뜬다. */}
|
||||
{regenOpen && !cardsQuery.isLoading && !sessionsQuery.isLoading && (
|
||||
<RegenerateModal
|
||||
open
|
||||
quotation={quotation}
|
||||
sessionViews={sessionViews}
|
||||
partners={connectedPartners}
|
||||
sessionStatusBySupplier={sessionStatusBySupplier}
|
||||
cards={cards}
|
||||
previousCardIds={previousCardIds}
|
||||
settingCeilingRate={settingCeilingRate}
|
||||
productId={itemId}
|
||||
internetLowest={currentItem?.internet_lowest_price ?? null}
|
||||
defaultSupplierIds={currentSupplierIds}
|
||||
onConfirm={async (ids) => {
|
||||
const newId = await onRegenerate(qtId, ids);
|
||||
onConfirm={async (input) => {
|
||||
const newId = await onRegenerate(qtId, input);
|
||||
if (newId) {
|
||||
onSwitchRound(newId); // 새 라운드 상세로 전환
|
||||
return true;
|
||||
|
||||
@ -0,0 +1,237 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { awardStrategySummary, PriceGateAction } from '../types';
|
||||
|
||||
// 견적 등록 모달의 프레젠테이션 하위 컴포넌트 모음 — 상태 없음(순수 props). QuotationCreateModal 에서 분리.
|
||||
|
||||
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
||||
export function SupplyTypeBadge({ type }: { type?: number }) {
|
||||
if (type === undefined) {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
미정
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
|
||||
{supplierTypeLabel(type)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
||||
export function SelectedPartnerTable({
|
||||
rows,
|
||||
supplyTypeBySupplier,
|
||||
showSupplyType,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; name: string; email: string }[];
|
||||
supplyTypeBySupplier: Map<string, number>;
|
||||
showSupplyType: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
||||
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
||||
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
||||
export function SelectedCardTable({
|
||||
rows,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
||||
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{r.isWildcard ? '와일드' : '협상'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||||
export function Segmented({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: { value: string; label: string; sub?: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'rounded border p-2.5 text-left transition-all cursor-pointer',
|
||||
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
|
||||
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
|
||||
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
|
||||
export function AwardLinePicker({
|
||||
mid, over, onMid, onOver,
|
||||
}: {
|
||||
mid: number;
|
||||
over: number;
|
||||
onMid: (v: number) => void;
|
||||
onOver: (v: number) => void;
|
||||
}) {
|
||||
const A = PriceGateAction.AWARD;
|
||||
const O = PriceGateAction.OPEN;
|
||||
const zones = [
|
||||
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
|
||||
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
|
||||
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label">낙찰 기준</Typography>
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
|
||||
</div>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
|
||||
</Typography>
|
||||
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
|
||||
<div className="flex rounded-md overflow-hidden border border-border text-center">
|
||||
{zones.map((z, i) => {
|
||||
const body = (
|
||||
<>
|
||||
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
|
||||
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
|
||||
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
|
||||
return z.locked ? (
|
||||
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
|
||||
) : (
|
||||
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
|
||||
<div className="relative h-6">
|
||||
{[
|
||||
{ left: '33.3333%', label: '앵커링가' },
|
||||
{ left: '66.6667%', label: '목표가' },
|
||||
].map((mk) => (
|
||||
<span
|
||||
key={mk.label}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
|
||||
style={{ left: mk.left }}
|
||||
>
|
||||
<span className="leading-none">▲</span>
|
||||
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
|
||||
{(() => {
|
||||
const t = awardStrategySummary(mid, over);
|
||||
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
|
||||
return (
|
||||
<Typography as="p" variant="small" className="text-[11px]">
|
||||
<span className="font-bold text-primary">{t.strategy}</span>
|
||||
<span className="text-muted-foreground"> · {t.desc}</span>
|
||||
{isBasic && (
|
||||
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,16 +28,18 @@ export function QuotationSettingsModal({
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const [targetMargin, setTargetMargin] = useState('');
|
||||
const [cardUseCount, setCardUseCount] = useState('');
|
||||
const [doneCeilingRate, setDoneCeilingRate] = useState('5'); // 협상 완료 상한율(%) 기본 5
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// 세팅은 목표 마진율·카드 사용 횟수만. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
|
||||
// 세팅은 목표 마진율·카드 사용 횟수·완료 상한율. 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2)로 대체.
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ok = onAdd({ targetMargin, cardUseCount });
|
||||
const ok = onAdd({ targetMargin, cardUseCount, doneCeilingRate });
|
||||
if (ok) {
|
||||
setTargetMargin('');
|
||||
setCardUseCount('');
|
||||
setDoneCeilingRate('5');
|
||||
}
|
||||
};
|
||||
|
||||
@ -65,13 +67,14 @@ export function QuotationSettingsModal({
|
||||
<TableRow>
|
||||
<TableHead className="p-2">{label('target_margin')}</TableHead>
|
||||
<TableHead className="p-2">카드 사용 횟수</TableHead>
|
||||
<TableHead className="p-2">타결 상한율</TableHead>
|
||||
<TableHead className="p-2 text-center w-12">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border bg-background">
|
||||
{settings.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="p-6 text-center text-muted-foreground">
|
||||
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
|
||||
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -80,6 +83,7 @@ export function QuotationSettingsModal({
|
||||
<TableRow key={qs.qt_setting_id} className="hover:bg-muted/30">
|
||||
<TableCell className="p-2 font-bold text-primary">{qs.target_margin}</TableCell>
|
||||
<TableCell className="p-2 text-muted-foreground">{qs.card_use_count}</TableCell>
|
||||
<TableCell className="p-2 text-muted-foreground">{qs.done_ceiling_rate / 10}%</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
@ -109,6 +113,11 @@ export function QuotationSettingsModal({
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
|
||||
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">타결 상한율 (%)</Typography>
|
||||
<Input type="number" step="0.5" value={doneCeilingRate} onChange={(e) => setDoneCeilingRate(e.target.value)} placeholder="예: 5" />
|
||||
<Typography as="p" variant="muted" className="text-[9px] leading-tight">목표가를 이 폭까지 넘어도 타결로 인정(목표가×(1+%)). 초과하면 결렬.</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
// 견적 등록 폼 공용 헬퍼 — datetime-local(KST) 변환과 퍼센트 파싱. QuotationCreateModal 에서 분리.
|
||||
|
||||
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
||||
function toKstLocalInput(date: Date): string {
|
||||
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
||||
return s.slice(0, 16).replace(' ', 'T');
|
||||
}
|
||||
|
||||
export function nowKstLocalInput(): string {
|
||||
return toKstLocalInput(new Date());
|
||||
}
|
||||
|
||||
export function defaultDueDateLocalInput(): string {
|
||||
return kstLocalInputAfter(60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 지금부터 ms 뒤 시각. 재생성 모달이 '원 견적과 같은 협상기간'을 마감기한 기본값으로 채울 때 쓴다.
|
||||
export function kstLocalInputAfter(ms: number): string {
|
||||
return toKstLocalInput(new Date(Date.now() + ms));
|
||||
}
|
||||
|
||||
export function isFutureLocalInput(value: string): boolean {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) && time > Date.now();
|
||||
}
|
||||
|
||||
export function parsePercent(value: string | undefined): number {
|
||||
const n = Number(String(value ?? '').replace('%', '').trim());
|
||||
return Number.isFinite(n) ? n / 100 : 0;
|
||||
}
|
||||
147
negodata/front/src/features/quotations/hooks/useCardGating.tsx
Normal file
147
negodata/front/src/features/quotations/hooks/useCardGating.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { type ComboOption } from '@/components/ui/combobox';
|
||||
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||||
import type { NegotiationCard } from '../types';
|
||||
|
||||
type CardDetail = { code: string; title: string; isWildcard: boolean };
|
||||
|
||||
type UseCardGatingParams = {
|
||||
cardRows: NegotiationCard[];
|
||||
productId: string;
|
||||
internetLowest: number | null;
|
||||
estimatedTargetPrice: number | null;
|
||||
open: boolean;
|
||||
selectedCardIds: string[];
|
||||
cardDetails: Map<string, CardDetail>;
|
||||
setSelectedCardIds: Dispatch<SetStateAction<string[]>>;
|
||||
setCardDetails: Dispatch<SetStateAction<Map<string, CardDetail>>>;
|
||||
/** 직전 라운드에서 쓴 카드 id — 목록에 '직전 사용' 배지를 달아 같은 멘트를 또 던지는 걸 눈에 띄게 한다(재생성 전용). */
|
||||
previousCardIds?: string[];
|
||||
};
|
||||
|
||||
// 카드 선택 게이팅 — 협상 멘트에 변수 토큰이 그대로 노출되거나 논리가 모순되는 카드를 선택 단계에서 막는다.
|
||||
// 추천 상위 3개 자동 선택·부적합 카드 자동 해제 effect 도 여기서 소유한다(상태는 컴포넌트가 보유, 세터로 갱신).
|
||||
export function useCardGating({
|
||||
cardRows,
|
||||
productId,
|
||||
internetLowest,
|
||||
estimatedTargetPrice,
|
||||
open,
|
||||
selectedCardIds,
|
||||
cardDetails,
|
||||
setSelectedCardIds,
|
||||
setCardDetails,
|
||||
previousCardIds,
|
||||
}: UseCardGatingParams) {
|
||||
const previousSet = new Set(previousCardIds ?? []);
|
||||
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
||||
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
||||
const conditionUnfilled = (c: NegotiationCard) =>
|
||||
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
|
||||
// 시장가(인터넷최저가) 인용 카드({internet_lowest_price} 토큰)인지 — (2)·(4) 공용 술어.
|
||||
const citesInternetLowest = (c: NegotiationCard) => (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
||||
// (2) 인터넷 최저가 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
|
||||
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
|
||||
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
|
||||
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
||||
const lowestPriceLeak = (c: NegotiationCard) => lowestUnavailable && citesInternetLowest(c);
|
||||
// (4) 시장가 논리 모순 — 인터넷최저가 ≥ 목표가면 "시장가가 더 싸다" 근거가 성립 안 한다.
|
||||
// 이런 상품에 시장가 인용 카드를 넣으면 목표가보다 높은 값을 근거로 깎으라는 모순 멘트가 나간다.
|
||||
const lowestAboveTarget = internetLowest != null && estimatedTargetPrice != null && internetLowest >= estimatedTargetPrice;
|
||||
const marketContradiction = (c: NegotiationCard) => lowestAboveTarget && citesInternetLowest(c);
|
||||
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
||||
const blockReason = (c: NegotiationCard): string | null =>
|
||||
c.isWildcard && c.status !== 'ACTIVE'
|
||||
? '미승인 와일드카드'
|
||||
: conditionUnfilled(c)
|
||||
? `${CONDITION_LABEL} 미작성`
|
||||
: lowestPriceLeak(c)
|
||||
? '인터넷 최저가 미수집'
|
||||
: marketContradiction(c)
|
||||
? '시장가 근거 성립 불가'
|
||||
: null;
|
||||
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
||||
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
||||
const rankedCards = cardRows
|
||||
.slice()
|
||||
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
||||
const cardOptions: ComboOption[] = rankedCards
|
||||
.map((card, i) => {
|
||||
const reason = blockReason(card);
|
||||
return {
|
||||
id: card.id,
|
||||
label: card.title,
|
||||
disabled: !!reason,
|
||||
node: (
|
||||
<div className={reason ? 'opacity-60' : undefined}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{card.usedCount > 0 && (
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
|
||||
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{card.isWildcard ? '와일드' : '협상'}
|
||||
</span>
|
||||
{previousSet.has(card.id) && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-violet-50 text-violet-700">
|
||||
직전 사용
|
||||
</span>
|
||||
)}
|
||||
{reason && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
||||
{reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
||||
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
||||
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
||||
const autoSelectedRef = useRef(false);
|
||||
|
||||
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoSelectedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
|
||||
autoSelectedRef.current = true;
|
||||
setCardDetails((m) => {
|
||||
const next = new Map(m);
|
||||
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
|
||||
return next;
|
||||
});
|
||||
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
|
||||
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, topRankedKey]);
|
||||
|
||||
// 이미 고른 카드 중, 시장가 인용 카드({internet_lowest_price})가 부적합해지면 자동 해제.
|
||||
// 부적합 = 최저가 미수집(lowestUnavailable) 또는 인터넷최저가 ≥ 목표가(lowestAboveTarget, 시장가 근거 모순).
|
||||
// (picklist disabled 는 신규 선택만 막으므로, 상품·목표가 변경 후 잔존 선택분을 여기서 정리해 노출·모순을 막는다.)
|
||||
useEffect(() => {
|
||||
if (!lowestUnavailable && !lowestAboveTarget) return;
|
||||
const leakIds = new Set(cardRows.filter(citesInternetLowest).map((c) => c.id));
|
||||
if (leakIds.size === 0) return;
|
||||
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
|
||||
// 상품·목표가 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lowestUnavailable, lowestAboveTarget, productId]);
|
||||
|
||||
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
||||
const selectedCardRows = selectedCardIds.map((id) => {
|
||||
const d = cardDetails.get(id);
|
||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||
});
|
||||
|
||||
return { blockReason, cardOptions, selectedCardRows };
|
||||
}
|
||||
@ -45,11 +45,22 @@ export type CreateQuotationInput = {
|
||||
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
|
||||
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
|
||||
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
|
||||
doneCeilingRate?: number; // 협상 완료 상한율(‰) 이 견적 override. 미전송이면 세팅 기본값
|
||||
};
|
||||
|
||||
// 재생성 확정값 — 공급사·마감기한은 항상 확정해 보내고, 나머지는 바꾼 것만(null=원 견적 값 승계).
|
||||
export type RegenerateInput = {
|
||||
supplierIds: string[];
|
||||
dueDate: string; // datetime-local 원본값
|
||||
targetPrice: number | null;
|
||||
cardIds: string[] | null;
|
||||
doneCeilingRate: number | null; // ‰
|
||||
};
|
||||
|
||||
export type SettingInput = {
|
||||
targetMargin: string;
|
||||
cardUseCount: string;
|
||||
doneCeilingRate: string; // 협상 완료 상한율(%) 입력값 — 저장 시 ‰(×10)로 변환
|
||||
};
|
||||
|
||||
// 견적 화면 데이터 허브.
|
||||
@ -163,14 +174,20 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
const addSetting = (input: SettingInput): boolean => {
|
||||
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
|
||||
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
|
||||
const ceilingPct = Number(String(input.doneCeilingRate).replace('%', '').trim());
|
||||
if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) {
|
||||
showToast(`${label('target_margin')}·카드 사용 횟수를 숫자로 입력해야 합니다.`, 'error');
|
||||
return false;
|
||||
}
|
||||
if (!Number.isFinite(ceilingPct) || ceilingPct < 0) {
|
||||
showToast('타결 상한율을 0 이상 숫자로 입력해야 합니다.', 'error');
|
||||
return false;
|
||||
}
|
||||
createSettingMutation.mutate(
|
||||
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수만.
|
||||
// 낙찰 정책은 견적 생성으로 이관, 앵커링은 칸 rate(v1.2) → 세팅은 목표 마진율·카드 사용 횟수·완료 상한율.
|
||||
{ data: {
|
||||
target_margin_rate: marginPct / 100, card_count: cardCount,
|
||||
done_ceiling_rate: Math.round(ceilingPct * 10), // % → ‰
|
||||
} },
|
||||
{
|
||||
onSuccess: () => {
|
||||
@ -236,6 +253,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
|
||||
mid_action: input.midAction ?? undefined,
|
||||
over_action: input.overAction ?? undefined,
|
||||
done_ceiling_rate: input.doneCeilingRate ?? undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -258,15 +276,25 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
}
|
||||
};
|
||||
|
||||
// 마감된 견적을 골라 다음 라운드를 수동 생성한다(공급사는 프론트 선택, 상품·번호·기간은 원 견적 승계).
|
||||
// 마감된 견적을 골라 다음 라운드를 수동 생성한다(상품·번호는 원 견적 승계).
|
||||
// 공급사·마감기한은 모달이 항상 확정해 보내고, 목표가·카드·타결 상한율은 바꾼 것만 보낸다(null=승계).
|
||||
// 성공 시 새 라운드 qt_id 반환, 실패/검증오류 시 null.
|
||||
const regenerateQuotation = async (qtId: string, supplierIds: string[]): Promise<string | null> => {
|
||||
if (supplierIds.length === 0) {
|
||||
const regenerateQuotation = async (qtId: string, input: RegenerateInput): Promise<string | null> => {
|
||||
if (input.supplierIds.length === 0) {
|
||||
showToast('다음 견적에 부를 공급사를 한 곳 이상 선택해 주세요.', 'error');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const res = await regenerateQuotationMutation.mutateAsync({ qtId, data: { supplier_ids: supplierIds } });
|
||||
const res = await regenerateQuotationMutation.mutateAsync({
|
||||
qtId,
|
||||
data: {
|
||||
supplier_ids: input.supplierIds,
|
||||
end_time: new Date(input.dueDate).toISOString(),
|
||||
target_price: input.targetPrice ?? undefined,
|
||||
card_ids: input.cardIds ?? undefined,
|
||||
done_ceiling_rate: input.doneCeilingRate ?? undefined,
|
||||
},
|
||||
});
|
||||
const newId = res?.qt_id ?? null;
|
||||
if (!res?.result?.success || !newId) {
|
||||
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
|
||||
|
||||
122
negodata/front/src/features/quotations/hooks/useTargetPrice.ts
Normal file
122
negodata/front/src/features/quotations/hooks/useTargetPrice.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||
import type { QuotationSetting } from '../types';
|
||||
import { parsePercent } from '../components/quotationForm.utils';
|
||||
|
||||
const INTERNET_AVERAGE_FEE = 0.078;
|
||||
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
|
||||
|
||||
type UseTargetPriceParams = {
|
||||
quotationSettings: QuotationSetting[];
|
||||
settingId: string;
|
||||
productId: string;
|
||||
isReType: boolean;
|
||||
// 상품 가격 속성(읽기전용) — 상품 미선택/미수집이면 null.
|
||||
internetLowest: number | null;
|
||||
purchase: number | null;
|
||||
selling: number | null;
|
||||
unitPrice: number | null;
|
||||
// 목표가 입력·후보 선택 상태.
|
||||
mdPrice: string;
|
||||
mdTouched: boolean;
|
||||
negoTouched: boolean;
|
||||
applyNego: boolean;
|
||||
selectedCandidateKey: string | null;
|
||||
doneCeilingRateOverride: number | null; // 협상 완료 상한율(‰) 견적 override. null 이면 세팅 기본값
|
||||
};
|
||||
|
||||
// 견적 목표가 산정 — 세팅 네고율·상품 후보(인터넷최저가/매입가/판매가)에서 목표가 후보와 채택값을 파생한다.
|
||||
// 순수 파생(부작용 없음). 카드 게이팅이 estimatedTargetPrice 를 참조하므로 그보다 먼저 호출한다.
|
||||
export function useTargetPrice({
|
||||
quotationSettings,
|
||||
settingId,
|
||||
productId,
|
||||
isReType,
|
||||
internetLowest,
|
||||
purchase,
|
||||
selling,
|
||||
unitPrice,
|
||||
mdPrice,
|
||||
mdTouched,
|
||||
negoTouched,
|
||||
applyNego,
|
||||
selectedCandidateKey,
|
||||
doneCeilingRateOverride,
|
||||
}: UseTargetPriceParams) {
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
||||
|
||||
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
||||
const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율)
|
||||
const settingMarginPct = +(settingMargin * 100).toFixed(1);
|
||||
// 네고율 차감 여부(매입가·판매가 공통) — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정.
|
||||
const negoAvailable = settingMargin > 0;
|
||||
const effectiveApplyNego = negoTouched ? applyNego : negoAvailable;
|
||||
const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로
|
||||
const negoLabel = (base: string) =>
|
||||
margin > 0 ? `${base} × (1−네고율 ${+(margin * 100).toFixed(1)}%)` : `${base} (네고율 미적용)`;
|
||||
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
|
||||
const targetBreakdown = [
|
||||
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
|
||||
{ key: 'purchase_price', label: negoLabel(label('item.purchase_price')), raw: purchase, rate: margin, show: isReType && purchase != null },
|
||||
{ key: 'selling_price', label: negoLabel(label('item.selling_price')), raw: selling, rate: margin, show: isReType && selling != null },
|
||||
]
|
||||
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
|
||||
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
|
||||
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
|
||||
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
|
||||
// 기본 채택 후보 = 최저(동률이면 첫 후보).
|
||||
const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null;
|
||||
// 네고율 토글 노출 여부 — 세팅 네고율이 있고, 네고율이 걸리는 후보(매입가/판매가)가 하나라도 보일 때. 두 후보에 공통 적용이라 행이 아닌 리스트 상단에 노출.
|
||||
const negoToggleAvailable = negoAvailable
|
||||
&& targetBreakdown.some((c) => c.key === 'purchase_price' || c.key === 'selling_price');
|
||||
// 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백.
|
||||
const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined;
|
||||
const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey;
|
||||
const targetFromCandidate = pickedCandidate ? pickedCandidate.value : autoTarget;
|
||||
// 구매담당자 제시가 필드엔 채택 후보값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
|
||||
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
|
||||
const effectiveMdPrice = mdTouched ? mdPrice : (targetFromCandidate != null ? String(targetFromCandidate) : mdPrice);
|
||||
const mdNum = Number(effectiveMdPrice) || 0;
|
||||
// 기본(최저·세팅네고)에서 벗어난 선택/토글이면 그 목표가를 md_price 로 박아 서버 저장값과 화면을 일치시킨다.
|
||||
// (서버는 세팅 rate·최저로 재계산하므로, 오버라이드를 안 보내면 후보 화면과 저장 목표가가 어긋난다.)
|
||||
const divergedFromDefault = negoTouched || (!!pickedCandidate && pickedCandidate.key !== minCandidateKey);
|
||||
const submitMdPrice = mdTouched && mdPrice
|
||||
? Number(mdPrice)
|
||||
: divergedFromDefault && targetFromCandidate != null
|
||||
? targetFromCandidate
|
||||
: null;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
const mdRequired = !!productId && !hasItemCandidate;
|
||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 채택 후보값.
|
||||
const estimatedTargetPrice = mdNum > 0 ? mdNum : targetFromCandidate;
|
||||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||||
: null;
|
||||
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
||||
|
||||
// 협상 완료 상한 — 견적 override(‰) 우선, 없으면 세팅 기본. 상한가 = 목표가×(1+율/1000), 10원 반올림(백엔드 박제와 동일).
|
||||
const settingCeilingRate = Number(selectedSetting?.done_ceiling_rate ?? 50);
|
||||
const effectiveCeilingRate = doneCeilingRateOverride ?? settingCeilingRate;
|
||||
const doneCeilingPrice = estimatedTargetPrice != null
|
||||
? Math.round((estimatedTargetPrice * (1000 + effectiveCeilingRate)) / 1000 / 10) * 10
|
||||
: null;
|
||||
|
||||
return {
|
||||
settingMarginPct,
|
||||
negoToggleAvailable,
|
||||
targetBreakdown,
|
||||
autoTarget,
|
||||
activeCandidateKey,
|
||||
effectiveApplyNego,
|
||||
effectiveMdPrice,
|
||||
mdRequired,
|
||||
targetReady,
|
||||
targetLimitExceeded,
|
||||
estimatedTargetPrice,
|
||||
submitMdPrice,
|
||||
settingCeilingRate,
|
||||
effectiveCeilingRate,
|
||||
doneCeilingPrice,
|
||||
};
|
||||
}
|
||||
@ -37,6 +37,7 @@ export interface QuotationSetting {
|
||||
user_id: string;
|
||||
target_margin: string;
|
||||
card_use_count: string;
|
||||
done_ceiling_rate: number; // 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000). 견적생성 모달이 상한가 계산에 직접 사용
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted: boolean;
|
||||
@ -67,6 +68,7 @@ export function mapSetting(s: QuotationSettingData): QuotationSetting {
|
||||
user_id: s.user_id || '',
|
||||
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
|
||||
card_use_count: `${s.card_count ?? 0}회`,
|
||||
done_ceiling_rate: Number(s.done_ceiling_rate ?? 50), // ‰
|
||||
created_at: s.created_at ?? '',
|
||||
updated_at: s.updated_at ?? '',
|
||||
deleted: false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ElementType } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react';
|
||||
import { Palette, Tags, ListPlus, MessageSquareText, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -13,31 +13,50 @@ import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
LABEL_CATALOG,
|
||||
LABEL_DEFAULTS,
|
||||
HIDEABLE_ITEM_FIELDS,
|
||||
CUSTOM_FIELD_TYPE_LABEL,
|
||||
DEFAULT_GUIDE_NOTICES,
|
||||
NEGO_BASELINE_OPTIONS,
|
||||
DEFAULT_NEGO_BASELINE_FIELD,
|
||||
type CompanySettings,
|
||||
type CustomFieldDef,
|
||||
type CustomFieldType,
|
||||
type NegoBaselineField,
|
||||
} from './catalog';
|
||||
import { useCompanySettings } from './useCompanySettings';
|
||||
|
||||
export const SETTINGS_TABS = ['branding', 'labels', 'fields'] as const;
|
||||
export const SETTINGS_TABS = ['branding', 'portal', 'labels', 'fields'] as const;
|
||||
export type SettingsTab = (typeof SETTINGS_TABS)[number];
|
||||
export const SETTINGS_TAB_LABEL: Record<SettingsTab, string> = {
|
||||
branding: '브랜딩(CI)',
|
||||
portal: '공급사 포털 안내',
|
||||
labels: '용어(라벨)',
|
||||
fields: '커스텀 필드',
|
||||
};
|
||||
|
||||
export function SettingsView() {
|
||||
// 화면에 보이는 것(로고·안내 문구)은 회사 관리자가, 협상 동작·데이터 구조를 바꾸는 것은 개발자가 다룬다.
|
||||
// 용어를 바꾸면 협상 멘트 호칭까지 같이 바뀌고, 커스텀 필드 탭의 협상 기준가·필드 숨김은 목표가 산정과
|
||||
// 학습 기준에 영향을 주므로 개발자 쪽에 둔다.
|
||||
export const OWNER_SETTINGS_TABS: readonly SettingsTab[] = ['branding', 'portal'];
|
||||
export const DEV_SETTINGS_TABS: readonly SettingsTab[] = ['labels', 'fields'];
|
||||
|
||||
const SETTINGS_TAB_ICON: Record<SettingsTab, ElementType> = {
|
||||
branding: Palette,
|
||||
portal: MessageSquareText,
|
||||
labels: Tags,
|
||||
fields: ListPlus,
|
||||
};
|
||||
|
||||
export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly SettingsTab[] }) {
|
||||
const { settings, isLoading, save } = useCompanySettings();
|
||||
|
||||
// 탭은 ?tab=<탭> 으로 URL 에 남긴다 — 링크 공유·새로고침·메뉴 빠른이동에서 같은 탭으로 열리게.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get('tab');
|
||||
const tab: SettingsTab = (SETTINGS_TABS as readonly string[]).includes(tabParam ?? '')
|
||||
const tab: SettingsTab = tabs.includes((tabParam ?? '') as SettingsTab)
|
||||
? (tabParam as SettingsTab)
|
||||
: 'branding';
|
||||
: tabs[0];
|
||||
const setTab = (next: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('tab', next);
|
||||
@ -58,14 +77,21 @@ export function SettingsView() {
|
||||
const handleSave = async () => {
|
||||
// 빈 문자열 라벨/브랜딩은 "기본값 사용"이므로 저장 전 제거해 문서를 깨끗하게 유지한다.
|
||||
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 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 hiddenFields = [...new Set((draft.hidden_fields ?? []).filter((k) => k.trim()))];
|
||||
const guideNotices = (draft.guide_notices ?? []).map((l) => l.trim()).filter(Boolean);
|
||||
const next: CompanySettings = {
|
||||
...draft,
|
||||
hidden_fields: hiddenFields,
|
||||
guide_notices: guideNotices,
|
||||
labels,
|
||||
branding,
|
||||
item_fields: itemFields,
|
||||
@ -115,8 +141,10 @@ export function SettingsView() {
|
||||
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 } }));
|
||||
const setHelpdesk = (lines: string[]) =>
|
||||
setDraft((d) => ({ ...d, branding: { ...d.branding, helpdesk: lines } }));
|
||||
const setLabel = (key: string, value: string) =>
|
||||
setDraft((d) => ({ ...d, labels: { ...d.labels, [key]: value } }));
|
||||
|
||||
@ -125,15 +153,14 @@ export function SettingsView() {
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<TabsList>
|
||||
<TabsTrigger value="branding" className="gap-1.5 px-3">
|
||||
<Palette size={13} /> 브랜딩(CI)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="labels" className="gap-1.5 px-3">
|
||||
<Tags size={13} /> 용어(라벨)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="fields" className="gap-1.5 px-3">
|
||||
<ListPlus size={13} /> 커스텀 필드
|
||||
</TabsTrigger>
|
||||
{tabs.map((t) => {
|
||||
const Icon = SETTINGS_TAB_ICON[t];
|
||||
return (
|
||||
<TabsTrigger key={t} value={t} className="gap-1.5 px-3">
|
||||
<Icon size={13} /> {SETTINGS_TAB_LABEL[t]}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* 저장 바 — 변경이 있을 때만 활성화 */}
|
||||
@ -214,6 +241,37 @@ export function SettingsView() {
|
||||
</SectionCard>
|
||||
</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">
|
||||
<SectionCard
|
||||
@ -271,6 +329,20 @@ export function SettingsView() {
|
||||
|
||||
{/* ---- 커스텀 필드 ---- */}
|
||||
<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
|
||||
title="상품 필드 숨김"
|
||||
desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다."
|
||||
@ -346,22 +418,156 @@ function mergeSettings(base: CompanySettings, incoming: CompanySettings): Compan
|
||||
};
|
||||
const pickFields = (b?: CustomFieldDef[], i?: CustomFieldDef[]) =>
|
||||
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 {
|
||||
...base,
|
||||
labels: mergeMap(base.labels, incoming.labels),
|
||||
branding: mergeMap(
|
||||
base.branding as Record<string, string> | undefined,
|
||||
incoming.branding as Record<string, string> | undefined,
|
||||
) as CompanySettings['branding'],
|
||||
branding: mergedBranding,
|
||||
hidden_fields: Array.isArray(incoming.hidden_fields) && incoming.hidden_fields.length > 0
|
||||
? incoming.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),
|
||||
supplier_fields: pickFields(base.supplier_fields, incoming.supplier_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 }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-5">
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
|
||||
export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select';
|
||||
|
||||
export type NegoBaselineField = 'price' | 'purchase_price';
|
||||
|
||||
export type CustomFieldDef = {
|
||||
key: string; // custom JSONB 의 키 (영문 snake_case)
|
||||
label: string; // 화면 표시명
|
||||
@ -16,9 +18,16 @@ export type CompanySettings = {
|
||||
logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트
|
||||
primary_color?: string; // 브랜드 색 (hex)
|
||||
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
|
||||
helpdesk?: string[]; // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 공급사 포털 3곳(로그인·메뉴·안내팝업)이 그대로 출력
|
||||
};
|
||||
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
|
||||
supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom
|
||||
session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력)
|
||||
@ -56,19 +65,15 @@ export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
|
||||
where: '상품 목록·등록, 엑셀 양식',
|
||||
calcNote: '재견적·재협상 목표가 후보(그대로)',
|
||||
},
|
||||
{
|
||||
key: 'internet_lowest_price',
|
||||
label: '인터넷 최저가(최저한도)',
|
||||
where: '상품 목록·등록, 엑셀 양식',
|
||||
calcNote: '신규·재 모두의 목표가 후보(최저가 × (1 − 수수료율)) — 숨기면 신규 견적 산정 근거가 사라진다',
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: '상품 단가(공급가)',
|
||||
where: '상품 목록·등록, 엑셀 양식',
|
||||
calcNote: '목표가 산식엔 안 쓰이나 상품 등록 필수값 — 숨기면 신규 등록 시 입력 경로가 사라진다',
|
||||
calcNote: '협상 기준가로 고른 경우 숨기지 말 것 — 인하율 멘트의 기준값이 비게 된다',
|
||||
},
|
||||
];
|
||||
// internet_lowest_price 는 숨김 대상에서 뺀다 — 신규 견적의 유일한 목표가 후보라
|
||||
// 숨기면 신규 견적마다 구매담당자가 목표가를 직접 입력해야만 생성된다.
|
||||
|
||||
export type LabelCatalogEntry = {
|
||||
key: string;
|
||||
@ -120,6 +125,8 @@ export const LABEL_CATALOG: LabelCatalogEntry[] = [
|
||||
{ key: 'quotation.due_date', base: '마감기한', where: '견적 목록·생성·상세', group: '견적·협상' },
|
||||
{ key: 'quotation.supplier_count', 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: '견적·협상' },
|
||||
];
|
||||
|
||||
@ -127,6 +134,38 @@ export const LABEL_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||
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> = {
|
||||
text: '텍스트',
|
||||
number: '숫자',
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
// 범용 엑셀(CSV) 내보내기. 클라이언트에서 Blob 다운로드 — 의존성 없음.
|
||||
// 한글이 Excel에서 깨지지 않도록 UTF-8 BOM을 붙인다. 여러 페이지에서 재사용.
|
||||
// 진짜 .xlsx(서식/다중시트)가 필요해지면 이 함수 시그니처 유지한 채 SheetJS로 교체 가능.
|
||||
// 범용 엑셀 내보내기(CSV, UTF-8 BOM)와 업로드 읽기(.xlsx/.xls/.csv). 여러 페이지에서 재사용.
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export type ExcelColumn<T> = {
|
||||
header: string;
|
||||
@ -63,6 +62,24 @@ function tokenizeCsv(text: string): string[][] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 업로드 파일 → 행 객체 배열. 진짜 엑셀(.xlsx/.xls)은 SheetJS 로, CSV 는 CP949 폴백으로 읽는다.
|
||||
export async function readSpreadsheetRows(file: File): Promise<Record<string, string>[]> {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const isBinary = (buf[0] === 0x50 && buf[1] === 0x4b) || (buf[0] === 0xd0 && buf[1] === 0xcf); // .xlsx=ZIP, .xls=OLE
|
||||
if (isBinary) {
|
||||
const wb = XLSX.read(buf, { type: 'array' });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
return sheet ? parseCsv(XLSX.utils.sheet_to_csv(sheet)) : [];
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
||||
} catch {
|
||||
text = new TextDecoder('euc-kr').decode(buf); // 한국 Excel 의 CSV 기본 인코딩
|
||||
}
|
||||
return parseCsv(text);
|
||||
}
|
||||
|
||||
// CSV 텍스트 → 헤더 키로 매핑된 객체 배열. 첫 행을 헤더로 본다. 여러 페이지 업로드에서 재사용.
|
||||
export function parseCsv(text: string): Record<string, string>[] {
|
||||
const rows = tokenizeCsv(text);
|
||||
|
||||
13
negodata/front/src/pages/dev-settings.tsx
Normal file
13
negodata/front/src/pages/dev-settings.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { DEV_SETTINGS_TABS, SettingsView } from '@/features/settings/SettingsView';
|
||||
|
||||
// 고급 설정(개발자 전용) — 용어(라벨)·커스텀 필드.
|
||||
// 용어를 바꾸면 협상 멘트 호칭까지 같이 바뀌고, 커스텀 필드 탭의 협상 기준가·상품 필드 숨김은
|
||||
// 목표가 산정과 학습 기준에 영향을 주므로 회사 관리자에게 열지 않는다.
|
||||
export default function DevSettingsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<SettingsView tabs={DEV_SETTINGS_TABS} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@ -240,6 +240,7 @@ export default function QuotationPage() {
|
||||
<QuotationDetailSheet
|
||||
key={activeQuotation.qt_id}
|
||||
quotation={activeQuotation}
|
||||
cards={cards}
|
||||
onCloseQuotation={closeQuotation}
|
||||
onAward={awardQuotation}
|
||||
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
import { SettingsView } from '@/features/settings/SettingsView';
|
||||
import { OWNER_SETTINGS_TABS, SettingsView } from '@/features/settings/SettingsView';
|
||||
|
||||
// 회사 설정(최고관리자 전용) — 브랜딩(CI)/용어(라벨)/커스텀 필드. 라우트 loader 가 OWNER 를 게이트한다.
|
||||
// 회사 설정(최고관리자) — 공급사에게 보이는 브랜딩·안내 문구만 다룬다.
|
||||
// 협상 동작·데이터 구조를 바꾸는 용어/커스텀 필드는 개발자 설정(pages/dev-settings)으로 분리.
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<SettingsView />
|
||||
<SettingsView tabs={OWNER_SETTINGS_TABS} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -35,6 +35,14 @@ export interface NegotiationCard {
|
||||
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
|
||||
successRate: number; // 카드 성공률(사용 세션 중 타결 비율). #12 순위
|
||||
usedCount: number; // 카드 사용 세션 수(표본)
|
||||
tactic?: CardTactic; // 전술 운영 규칙. 제안가는 스크립트 변수 파싱으로 결정(agent)
|
||||
}
|
||||
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
// 카드 전술 운영 규칙(card.*.tactic JSONB) — 스크립트로 알 수 없는 것만 담는다.
|
||||
export interface CardTactic {
|
||||
min_round?: number; // 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준)
|
||||
closing?: boolean; // 종결 전용 — 라운드 상한·카드 소진 때의 마지막 한 방으로만
|
||||
offer_variable?: string; // 제시 가격 변수 명시 지정. 없으면 멘트 파싱(마지막 가격 변수) — 멘트에 있는 변수만 허용
|
||||
}
|
||||
|
||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DEV_SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';
|
||||
|
||||
21
postgres-init/alters/2026-08-03-done-ceiling.sql
Normal file
21
postgres-init/alters/2026-08-03-done-ceiling.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- 2026-08-03 · 협상 완료 상한(목표가 초과 허용) 컬럼 추가 (기존 DB 보정)
|
||||
-- 요구: 엑셀 4번(EST-495F/AE71) — 목표가 초과라는 이유만으로 결렬되던 협상을, 정한 폭까지는 타결로 인정.
|
||||
-- 완료 상한 = 목표가 × (1 + rate/1000). 회사 기본율은 quotation_settings, 견적별 override 는 quotations,
|
||||
-- 생성 시 확정 금액은 sessions 에 박제(봇 종결·마감이 앵커가처럼 직접 읽는다).
|
||||
-- 정본은 init-data/init.sql(신규 설치). 이 파일은 동일 최종본을 기존 DB 에 반영한다.
|
||||
-- 멱등: ADD COLUMN IF NOT EXISTS — 여러 번 실행해도 안전.
|
||||
-- 적용: psql -h <host> -p <port> -U <user> -d <db> -f postgres-init/alters/2026-08-03-completion-ceiling.sql
|
||||
|
||||
\connect negosium_db
|
||||
|
||||
-- 회사 기본 완료 상한율(‰). 기본 50‰(5%).
|
||||
ALTER TABLE quotation.quotation_settings
|
||||
ADD COLUMN IF NOT EXISTS done_ceiling_rate SMALLINT NOT NULL DEFAULT 50;
|
||||
|
||||
-- 견적별 override(‰). NULL 이면 세팅 기본값을 따른다.
|
||||
ALTER TABLE quotation.quotations
|
||||
ADD COLUMN IF NOT EXISTS done_ceiling_rate SMALLINT NULL;
|
||||
|
||||
-- 생성 시 박제한 완료 상한가(원) = 목표가 × (1+rate/1000), 10원 반올림. 봇 종결·마감 판정 기준.
|
||||
ALTER TABLE negotiation.sessions
|
||||
ADD COLUMN IF NOT EXISTS done_ceiling_price BIGINT NULL;
|
||||
70
postgres-init/alters/2026-08-04-card-tactic.sql
Normal file
70
postgres-init/alters/2026-08-04-card-tactic.sql
Normal file
@ -0,0 +1,70 @@
|
||||
-- 2026-08-04 · 카드 전술 데이터화(tactic JSONB) + 카드번호 정본화 (기존 DB 보정)
|
||||
-- 배경: IMK 요청 2건(동일 카드 연속 사용 방지 · 중간값 절충 계산 확인) 대응 —
|
||||
-- 카드 전술을 코드 하드코딩(agent tactics.py _TACTICS)에서 DB 로 옮긴다.
|
||||
-- 제안가는 스크립트 변수 파싱으로 결정하고, 문장으로 알 수 없는 운영 규칙만 tactic 에 둔다:
|
||||
-- {"min_round": N, "closing": bool} -- NULL/빈 객체 = 기본값(1라운드부터·비종결)
|
||||
-- 멱등: ADD COLUMN IF NOT EXISTS + 조건 가드 UPDATE — 여러 번 실행해도 안전.
|
||||
-- 적용: psql -h <host> -p <port> -U <user> -d <db> -f postgres-init/alters/2026-08-04-card-tactic.sql
|
||||
-- (구번호 '1'~'11'/'1'~'5' DB 는 이 파일이 정본 번호로 먼저 정규화한 뒤,
|
||||
-- 2026-07-24-nego-card-scripts.sql 을 이어서 적용해야 스크립트 개정까지 맞는다.)
|
||||
|
||||
\connect negosium_db
|
||||
|
||||
-- 1) 전술 컬럼
|
||||
ALTER TABLE card.nego_cards ADD COLUMN IF NOT EXISTS tactic JSONB NULL;
|
||||
ALTER TABLE card.wild_cards ADD COLUMN IF NOT EXISTS tactic JSONB NULL;
|
||||
COMMENT ON COLUMN card.nego_cards.tactic IS '카드 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱으로 결정 — agent tactics.build_card_spec';
|
||||
COMMENT ON COLUMN card.wild_cards.tactic IS '카드 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱으로 결정 — agent tactics.build_card_spec';
|
||||
|
||||
-- 2) 카드번호 정본화 — 초기 시드(구번호 '1'~'11'/'1'~'5')를 정본 표기(NGC-0xx/WC-0x)로.
|
||||
-- agent 전술 스펙·backend 카드 UUID 역조회·negodata 표기가 전부 번호 기준이라 표기를 통일한다.
|
||||
-- (정본 번호가 이미 들어간 DB 는 정규식 가드에 안 걸려 no-op.)
|
||||
UPDATE card.nego_cards
|
||||
SET number = 'NGC-' || lpad(number, 3, '0'), updated_at = now()
|
||||
WHERE number ~ '^[0-9]{1,2}$' AND deleted = FALSE;
|
||||
UPDATE card.wild_cards
|
||||
SET number = 'WC-' || lpad(number, 2, '0'), updated_at = now()
|
||||
WHERE number ~ '^[0-9]{1,2}$' AND deleted = FALSE;
|
||||
|
||||
-- 3) 구시드 WC-01 스크립트의 미정의 변수 {customer_reference} 제거 — 치환표에 없어 토큰이
|
||||
-- 협력사 화면에 그대로 노출된다. 정본 문구(시장 상황과 거래 조건)로 교체.
|
||||
UPDATE card.wild_cards
|
||||
SET script = replace(script, '{customer_reference}을(를)', '시장 상황과 거래 조건을'),
|
||||
edit_script = replace(edit_script::text, '{customer_reference}을(를)', '시장 상황과 거래 조건을')::jsonb,
|
||||
updated_at = now()
|
||||
WHERE number = 'WC-01' AND deleted = FALSE AND script LIKE '%{customer_reference}%';
|
||||
|
||||
-- 4) 전술 값 — 문장으로 알 수 없는 운영 규칙만.
|
||||
-- WC-03 최종 통보 · WC-05 중간값 절충 = 종결 전용(라운드 상한·카드 소진 때의 마지막 한 방).
|
||||
-- WC-04 단계적 인하 제안 = 2라운드부터("당초 적정가 → 한발 물러서" 서사가 첫 제안엔 성립 안 함).
|
||||
UPDATE card.wild_cards SET tactic = '{"closing": true}'::jsonb, updated_at = now()
|
||||
WHERE number IN ('WC-03', 'WC-05') AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
UPDATE card.wild_cards SET tactic = '{"min_round": 2}'::jsonb, updated_at = now()
|
||||
WHERE number = 'WC-04' AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
|
||||
-- 5) 구시드 WC-03(최종 통보) 멘트 정본화 — 구멘트엔 가격 변수가 없어 "최종 제안 금액이 안 보이는 채
|
||||
-- 수락 버튼"이 뜨고, 전술 파싱도 설득 카드로 오판한다(종결에서 스킵 → 항상 최후통첩 폴백).
|
||||
-- 정본(init-data.sql)의 {target_price} 포함 멘트로 교체. 멱등: 가격 변수 없는 행만.
|
||||
UPDATE card.wild_cards
|
||||
SET script = '합리적인 기준에 근거하여 당사의 최종 목표 가격 {target_price}원(VAT별도)을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다.
|
||||
|
||||
이번 협상이 결렬되는 경우 우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.
|
||||
|
||||
본 금액을 수락하시면 해당 가격으로 최종 확정되며, 재검토가 필요하시면 다른 제안 가격을 입력해 주시기 바랍니다.',
|
||||
edit_script = '[{"type": "paragraph", "children": [{"text": "합리적인 기준에 근거하여 당사의 최종 목표 가격 "}, {"type": "variable", "name": "target_price", "label": "목표가격(고객사 지향가)", "children": [{"text": ""}], "suffix": "원(VAT별도)", "style": {"bold": true, "color": "red"}}, {"text": "을 제안 드립니다. 귀사의 기존 제안 가격으로는 긍정적인 합의가 어려울 것으로 예상됩니다."}]}, {"type": "paragraph", "children": [{"text": "이번 협상이 결렬되는 경우 "}, {"text": "우선 협상권을 보장하기 어려우며, 다른 공급 업체를 선정하기 위한 검토가 진행될 수 있습니다.", "bold": true}]}, {"type": "paragraph", "children": [{"text": "본 금액을 수락하시면 해당 가격으로 최종 확정되며, 재검토가 필요하시면 다른 제안 가격을 입력해 주시기 바랍니다."}]}]'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE number = 'WC-03' AND deleted = FALSE
|
||||
AND script NOT LIKE '%{target_price}%';
|
||||
|
||||
-- 6) 구시드 NGC-010(향후 거래 연계) 멘트 정본화 — 구멘트의 {customer_condition} 토큰은 조건 미작성 시
|
||||
-- 협상 채팅에 원형 노출된다(정본은 "2년 장기계약" 평문). edit_script 는 비워 평문 폴백(재편집 시 복원).
|
||||
UPDATE card.nego_cards
|
||||
SET script = '이번 거래의 가격을 {target_price}원으로 조정하는 대신, 2년 장기계약을 함께 검토해 주실 것을 제안 드립니다.
|
||||
|
||||
당장의 단가 한 건만 보기보다 향후 이어질 거래까지 함께 고려한다면, 양사 모두에게 더 큰 가치를 만들 수 있습니다. 이번 합의를 장기적 관계의 출발점으로 삼아, 서로에게 이익이 되는 구조를 함께 설계하기를 바랍니다.',
|
||||
edit_script = NULL,
|
||||
updated_at = now()
|
||||
WHERE number = 'NGC-010' AND deleted = FALSE
|
||||
AND script LIKE '%{customer_condition}%';
|
||||
@ -192,6 +192,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards (
|
||||
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=common(공통), 2=new(신규견적전용), 3=reuse(재견적전용)
|
||||
tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
|
||||
strategy_type SMALLINT NULL, -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
|
||||
tactic JSONB NULL, -- 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent tactics)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -210,6 +211,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards (
|
||||
condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함
|
||||
available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가
|
||||
memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모
|
||||
tactic JSONB NULL, -- 전술 운영 규칙 {"min_round": N, "closing": bool}. 제안가는 script 변수 파싱(agent tactics)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -241,6 +243,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
|
||||
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
|
||||
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
||||
done_ceiling_rate SMALLINT NOT NULL DEFAULT 50, -- 협상 완료 상한율(‰). 완료 상한=목표가×(1+값/1000). 목표가 초과여도 여기까지는 타결
|
||||
-- 낙찰 정책(mid/over/regen)은 견적 단위로 이관, 앵커링은 칸 rate(anchoring v1.2)로 대체 → 세팅 컬럼 없음
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -273,6 +276,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
||||
close_reason SMALLINT NULL, -- 마감 사유(CloseReason): 1=낙찰, 5=가격개찰, 6=동가개찰, 7=미응찰개찰, 8=거부개찰. 미마감이면 NULL
|
||||
mid_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 앵커링가<투찰가≤목표가 처리. 1:1 협상만 사용자 선택, 1:N 경매는 AWARD 강제
|
||||
over_action SMALLINT NOT NULL DEFAULT 1, -- 낙찰 기준(PriceGateAction 1=낙찰/2=개찰): 목표가<투찰가 처리(1:1 협상은 항상 개찰). 투찰가≤앵커링가는 항상 낙찰
|
||||
done_ceiling_rate SMALLINT NULL, -- 협상 완료 상한율(‰) 견적별 override. NULL 이면 quotation_settings 값 사용
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -291,6 +295,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
||||
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
|
||||
target_price BIGINT NOT NULL, -- 목표가(원)
|
||||
anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
|
||||
done_ceiling_price BIGINT NULL, -- 협상 완료 상한가(원) — 생성 시 박제 = 목표가×(1+완료상한율/1000). 봇 종결·마감이 이 이하면 타결
|
||||
anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제
|
||||
last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
|
||||
used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
|
||||
|
||||
@ -215,3 +215,11 @@ SELECT * FROM (VALUES
|
||||
1, NULL::varchar, TRUE, NULL::varchar, 4, 6)
|
||||
) AS v(user_id, name, number, script, edit_script, usage_type, condition, available, memo, tone, strategy_type)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM card.wild_cards WHERE user_id IS NULL AND deleted = FALSE);
|
||||
|
||||
-- 와일드카드 전술 운영 규칙(tactic) — init-data.sql 과 동일(멱등).
|
||||
UPDATE card.wild_cards SET tactic = '{"closing": true}'::jsonb
|
||||
WHERE number IN ('WC-03', 'WC-05') AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
UPDATE card.wild_cards SET tactic = '{"min_round": 2}'::jsonb
|
||||
WHERE number = 'WC-04' AND user_id IS NULL AND deleted = FALSE
|
||||
AND (tactic IS NULL OR tactic = '{}'::jsonb);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user