Merge branch 'feature/negodata'

This commit is contained in:
Mina Choi 2026-08-06 13:13:11 +09:00
commit 17a698a7af
103 changed files with 3949 additions and 962 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
{
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
"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": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
}

View File

@ -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": [],

View File

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

View File

@ -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",

View 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())

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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 == ["협력사배송", "지정택배배송", "픽업배송"]

View File

@ -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). 한 줄 = 담당자 한 명")

View File

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

View File

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

View File

@ -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 ?? [],
}
}

View File

@ -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 가 없어도 이 회사로 보이게 한다
})

View File

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

View File

@ -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>
&nbsp;
<span className="font-semibold text-neutral-90">
Negosium
</span>
.
</Bullet>
<Bullet>
&nbsp;
<span className="font-semibold text-neutral-90">
,
</span>
.
</Bullet>
<Bullet>
&nbsp;
<span className="font-semibold text-negative">
{vat} {deliveryFee}
</span>
&nbsp;,&nbsp;
<span className="font-semibold text-neutral-90">
</span>
.
</Bullet>
<Bullet>
,
3 , .
</Bullet>
<Bullet>
, .
</Bullet>
<Bullet> .</Bullet>
{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>
)
}

View File

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

View File

@ -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) # 입찰가(원)

View File

@ -0,0 +1,38 @@
"""협상 기준가 판정 — 회사 설정에서 '이 회사가 관리하는 지불 단가' 컬럼을 고른다.
값은 협상 인하율 멘트의 분모이자 RL 가격 수용률의 기준가이고, 인터넷 최저가 검색의
가격 힌트로도 나간다. 회사마다 다르다 매입해서 되파는 곳은 공급가(items.price),
매입만 하는 곳은 매입가(items.purchase_price) 실제 지불 단가다.
같은 규칙을 agent(negotiation/chat/infra/repository/nego_context_crud.py `_resolve_baseline`)
루트 backend(services/chat_service.py) 쓴다. 곳이 어긋나면 화면값과 협상 멘트가 갈리므로
판정식을 바꿀 때는 반드시 같이 고친다.
"""
from typing import Optional
NEGO_BASELINE_FIELDS = ("price", "purchase_price")
DEFAULT_NEGO_BASELINE_FIELD = "price"
def resolve_baseline_field(settings: Optional[dict]) -> str:
"""회사 설정 → 협상 기준가로 쓸 items 컬럼명.
1순위는 관리자가 설정 화면에서 고른 (features.nego_baseline_field).
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면(hidden_fields) 회사는
공급가를 관리하지 않는다는 뜻이므로 매입가로 폴백한다 설정 화면이 생기기 전에
만들어진 회사를 위한 안전망.
"""
settings = settings or {}
chosen = (settings.get("features") or {}).get("nego_baseline_field")
if chosen in NEGO_BASELINE_FIELDS:
return chosen
hidden = set(settings.get("hidden_fields") or [])
if "price" in hidden and "purchase_price" not in hidden:
return "purchase_price"
return DEFAULT_NEGO_BASELINE_FIELD
def resolve_baseline_price(item, settings: Optional[dict]) -> int:
"""상품의 협상 기준가(원). 값이 없으면 0."""
return int(getattr(item, resolve_baseline_field(settings), None) or 0)

View File

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

View File

@ -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 순위용

View File

@ -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 # 대표 상품명. 목록 조인으로 채움.

View File

@ -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로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----

View File

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

View File

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

View File

@ -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("/")

View File

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

View File

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

View File

@ -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()],

View File

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

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

View File

@ -0,0 +1,111 @@
# IMK 0803 가격협상 요청 정리
원본: `0803_가격협상 우선 적용 및 논의 정리.xlsx` (12건). 시트의 `우선/논의` 구분 대신
**스펙이 확정돼 바로 착수 가능한 것 / 결정이 있어야 착수 가능한 것**으로 다시 갈랐다.
시트와 다른 3건 — ⑪은 수정으로, ③은 논의로, ④는 코드가 아니라 회사설정으로.
---
## 수정 (스펙 확정)
### ④ 부가세 입력칸 — 코드 문제 아님
`vat_yn` 은 상품폼(`ProductFormSheet.tsx`)·업로드 양식(`ExcelUploadModal.tsx`) 둘 다 이미 있다.
IMK 회사설정이 `hidden_fields = [made_in, delivery_fee_yn, selling_price, vat_yn]` 라 화면·양식에서
같이 빠진 것. 설정에서 체크 해제하면 끝.
### ⑥ 유의사항 문구 / 헬프데스크
- 유의사항의 VAT·배송비 문구는 `GuideContent.tsx` 에서 상품별 동적 표기로 이미 바뀐 상태 → 요청대로 삭제.
- 헬프데스크 연락처 실사용처 3곳, 전부 placeholder였음:
`frontend/src/features/chat/components/menu/Contact.tsx`,
`frontend/src/features/chat/components/popup/GuideContent.tsx`,
`frontend/src/pages/LoginPage.tsx`. 초청 메일엔 없음.
- **반영**: `companies.settings.branding.helpdesk: string[]` 로 회사별 설정화. 아래 "헬프데스크 배선" 참조.
### ⑦ 공급가–매입가 일원화
목표가 후보엔 이미 공급가가 없다(internet·purchase·selling — `pricing.py`). 잔여 2건:
1. 화면의 공급가(`item.price`, IMK 라벨 "공급가") 노출 제거
2. 신규 견적이 아직 인터넷최저가만 씀 → 매입가 후보 추가
⚠️ **`items.price` 를 hidden 으로 감추면 안 된다.** `hidden_fields` 는 목록·등록폼·엑셀양식 3곳을
동시에 감추므로 신규 상품의 `price` 가 NULL 로 쌓이고, 협상 멘트의 인하율이 통째로 빠진다.
`items.price` 소비처 → `items-price-is-nego-baseline` 메모 참조.
### ⑫ 시장가 산식 `(상품가+배송비)/1.1`
인터넷 최저가는 몰 판매가(VAT 포함·배송비 별도)로 수집되는데 우리 매입가·공급사 견적가는 VAT 별도라
축이 어긋난 채 비교 중. 배송비를 더해 실구매 총액을 만들고 1.1로 나눠 VAT를 벗긴다(약 9% 낮아짐).
미결 2개:
- 배송비를 대부분 모른다(네이버 쇼핑 API 미제공, 실측 39/39 null). 0으로 칠지 / 미상이면 환산 스킵할지
- 적용 범위 — 카드 멘트 인용값만인지, 목표가 후보(`internet × (1수수료율)`)에도 거는지.
후자면 인터넷 기준 목표가가 9% 내려간다.
### ⑪ 인터넷최저가 ≥ 목표가 → 시장가 카드 차단 (절반 완료)
견적생성 시 선택 게이팅은 반영됨(`useCardGating.tsx`). 잔여 = 협상 진행 중 런타임 차단.
### ⑤ 상품 일괄등록 유효성 오류
재현 케이스(어느 필드가 오탐인지) 확보 후 수정. 엑셀 원본을 받는 게 빠름.
### ② 목표가 초과 낙찰 허용
상한 그릇은 들어감(`sessions.done_ceiling_price`, 커밋 91b8dc77).
**협상 엔진이 아직 안 읽는다** — `agent/`·루트 `backend/` 어디에도 `done_ceiling` 참조 없음.
타결 판정에 배선하면 EST-202607-973E 케이스 해소.
조건: 기존 단가 > 견적가 > 목표가 > 앵커링가면 타결. 여기서 "기존 단가" = `items.price`.
---
## 논의 (결정 필요)
### ③ 중간값 로직
②의 짝. "목표가 초과 제시 안 함" 상한을 푸는 건 맞는데 **대신 무엇을 상한으로 쓸지**
`done_ceiling_price`(목표가×(1+율))인지 기존 단가인지.
IMK 예시(목표 39,800 / 필요 40,200)는 +1.0% 수준이고 현 기본율은 +5%.
### ⑧ 세팅 횟수만큼 카드 소진
목표가에 이미 근접했는데도 남은 카드를 다 태울지. 라운드가 늘면 결렬 위험·시간도 는다.
"최소 사용 횟수 보장" vs "조기 타결 우선" 중 택.
### ⑨ 앵커 제안가 단조성
후속 카드 제안가가 앞 카드보다 낮아지는 건(16,980 → 16,810) 카드마다 고정 인하율을 쓰기 때문.
제안가를 카드가 아니라 **라운드에 종속**시켜 단조 상향으로 바꾸는 구조 변경 → 이 중 유일하게 범위가 큼.
### ⑩ 와일드카드 + 최종제안 중복
종결 국면은 현재 **필수 관문**이다(`chat_engine.py` `check_iteration_limit`):
1. `closing_played` false → `force_closing` → 종결 전용 와일드카드 또는 폴백 최후통첩(목표가 제시)
2. `closing_played` true → 제시가 ≤ 목표가면 타결, 초과면 결렬
스킵하면 마지막 우리 카운터가 안 나가고 공급사 마지막 제시가로 즉시 판정 → **결렬률 상승**.
구현 시 `closing_played = True` 는 반드시 세워야 무한루프를 피한다.
단 중복 방지는 이미 양쪽에 걸려 있다 — 진입 단계(`chat_engine.py`)도 종결 단계(`chat_service.py`)도
`is_played` 로 쓴 카드를 건너뛴다. 코드상 같은 카드가 2회 나올 수 없으므로 IMK가 본 것이
진짜 동일 카드인지 확인이 먼저(dev DB엔 EST-202607-5947 없음 — 운영 데이터).
### ⑬ SG별 앵커링
**SG = `items.category`** (IMK 라벨 "SG명"). 현재 SG는 앵커링에도 목표가 산정에도 안 들어간다.
앵커링 축은 3개 — `(company_id, supplier_type, price_range_index)`:
| 축 | 값 |
|---|---|
| `company_id` | 회사 |
| `supplier_type` | 1 유통 / 2 제조 / 3 총판 — 개별 협력사가 아니라 **유형** |
| `price_range_index` | 목표가 기준 46개 자릿수 구간 |
SG 축을 추가하면 셀이 46×3×SG수로 쪼개져 셀당 표본 10건(`SAMPLE_THRESHOLD`)을 못 채우고
평가가 스킵·이월되어 **학습이 사실상 멈춘다**. 이게 실제 결정 포인트.
---
## 헬프데스크 배선 (⑥ 반영분)
`companies.settings.branding.helpdesk: string[]` — 한 줄 = 담당자 한 명, 자유 문자열.
| 경로 | 파일 |
|---|---|
| 편집 UI | `negodata/front/src/features/settings/SettingsView.tsx` (브랜딩 탭) |
| 타입 | `negodata/front/src/features/settings/catalog.ts` |
| 로그인 후 전달 | `backend/services/auth_service.py` `me()``branding` dict 통째로 내려 자동 포함 |
| 로그인 전 전달 | `backend/services/auth_service.py` `session_branding()` + `protocol.py` `Res_SessionBranding` |
| 표시 | `frontend/``Contact.tsx` · `GuideContent.tsx` · `LoginPage.tsx` |
값이 비면 각 표시부는 연락처 줄을 렌더하지 않는다(placeholder 노출 금지).

View File

@ -0,0 +1,251 @@
# 협상 기준가 회사별 선택 — 변경 내역과 검증 보고서
작성 2026-08-05. 대상 = IMK 0803 요청 ⑥(유의사항·헬프데스크)·⑦(공급가–매입가 일원화).
핵심은 **협상 기준가**(인하율 멘트의 분모이자 RL 가격 수용률의 기준가)를 회사가 고르게 한 것이다.
매입해서 되파는 회사는 공급가(`items.price`)가, 매입만 하는 회사는 매입가(`items.purchase_price`)가
실제 지불 단가이므로, 컬럼을 합치는 대신 **어느 컬럼을 쓸지 회사 설정으로 지정**한다.
---
## 1. 판정 규칙
```
1순위 settings.features.nego_baseline_field ('price' | 'purchase_price')
2순위 price 만 hidden_fields 에 있으면 → purchase_price (설정 화면 이전 회사 안전망)
기본 → price
```
같은 규칙을 세 앱이 쓴다. **판정식을 바꿀 때는 반드시 세 곳을 같이 고친다.**
| 앱 | 위치 |
|---|---|
| negodata backend | `common/nego_baseline.py` `resolve_baseline_field` (정본) |
| agent | `negotiation/chat/infra/repository/nego_context_crud.py` `_resolve_baseline` |
| negosium backend | `services/chat_service.py` `chat_init` 내 인라인 판정 |
---
## 2. 회사 유형별 설정 방법
어느 가격이 "우리가 공급사에 지불하는 단가"인지는 **회사마다 다르다.** 그래서 컬럼을 합치지 않고
회사가 고르게 했다. 설정 위치는 전부 **negodata → 회사 설정(`/settings`, 최고관리자 전용)**.
### 유형 A — 매입만 하는 회사 (사서 쓰고, 되팔지 않음)
관리하는 가격이 매입가 하나뿐인 회사.
| 탭 | 설정 |
|---|---|
| 커스텀 필드 → 협상 기준가 | **매입가** 선택 |
| 커스텀 필드 → 상품 필드 숨김 | `상품 단가(공급가)`·`판매가` 체크 |
| 용어(라벨) | 필요하면 `item.purchase_price` 를 자기 용어로(예: 구매단가) |
결과 — 상품 등록·엑셀 양식에 매입가 칸만 남고, 협상 멘트는 "기존 매입가 대비 N% 인하",
인터넷 최저가 검색 힌트도 매입가로 나간다. 목표가는 인터넷최저가·매입가 후보로 산정된다.
### 유형 B — 매입해서 되파는 회사 (유통·구매대행)
공급사에서 사서(매입가) 고객사에 넘기는(공급가) 회사. **기본값이라 아무것도 안 해도 된다.**
| 탭 | 설정 |
|---|---|
| 커스텀 필드 → 협상 기준가 | **상품 단가** 선택 (미설정 시 기본값) |
| 커스텀 필드 → 상품 필드 숨김 | 안 씀 |
| 용어(라벨) | 필요하면 `item.price` 를 자기 용어로(IMK 는 "공급가") |
결과 — 공급가가 협상 출발점, 매입가는 재견적 목표가 후보로 계속 쓰인다.
### 공통 주의
- **기준가로 고른 필드는 숨기지 말 것.** 숨기면 신규 상품 등록 화면에 그 칸이 없어 값이 비고,
인하율 멘트가 통째로 사라진다. 설정 화면이 그 조합을 고르면 경고를 띄운다.
- **인터넷 최저가는 숨길 수 없다.** 신규 견적의 유일한 목표가 후보라 숨김 목록에서 제외했다.
- **협상 이력이 쌓인 뒤에는 바꾸지 말 것.** 기준가는 RL 가격 수용률의 분모라 학습 상태 인덱스에
들어간다. 바꾸면 Q테이블에 두 기준이 섞이고 되돌려도 복구되지 않는다. 회사 온보딩 때 정한다.
- **용어를 바꾸면 협상 멘트 호칭도 같이 바뀐다.** 용어 탭의 `item.price`/`item.purchase_price`
라벨이 그대로 공급사에게 나가는 문장에 쓰인다(조사는 받침에 맞춰 자동 보정).
### 현재 IMK 설정
```
features.nego_baseline_field = "purchase_price" → 매입가 기준 (유형 A)
hidden_fields = [made_in, delivery_fee_yn, selling_price, vat_yn, price]
labels = { "item.price": "공급가", … }
```
---
## 3. 수정한 곳
### agent (협상 엔진)
| 파일 | 내용 |
|---|---|
| `negotiation/chat/infra/repository/nego_context_crud.py` | `_ITEMS``purchase_price`·`company_id` 추가, `_COMPANIES` 테이블 신설. `get_item_price`**`get_item_baseline`** 로 교체 — items⋈companies 한 쿼리로 `(기준가, 호칭, 회사 용어사전)` 반환. `_resolve_baseline` 판정 함수 |
| `negotiation/chat/service/negotiation_context_loader.py` | `NegotiationDbContext``item_price_label`·`labels` 추가 (세션 시작 시 박제) |
| `services/chat_service.py` | 세션 컨텍스트에 `item_price_label`·`labels` 적재. 데모 경로 폴백 `_DEFAULT_ITEM_PRICE_LABEL = "상품 단가"` |
| `negotiation/chat/service/chat_engine.py` | `discount_phrase` 3분기의 `"공급가"` 하드코딩 → 컨텍스트 호칭. `_SCRIPT_LABELS` 용어 토큰, `_josa`/`_has_batchim` 조사 자동 보정. **`input_options` 도 변수 치환을 타게 수정**(안 고쳤으면 `{label_delivery_type_1}` 토큰이 사용자에게 노출) |
| `negotiation/chat/service/script_naturalizer.py` | docstring 문구 |
| `tenants/_base/resources/scripts_renegotiation.json` | "협력사 간"·"협력사 포털"·"협력사 관리 시스템" → `{label_supplier}` |
| `tenants/_base/resources/scripts_requote.json` | 위 + "신규 공급사를 선정"·"공급사 선정에 반영"·"배송 형태를 선택"·배송 보기 3개 |
| `tenants/_base/resources/scripts_cards.json` | "목표 매입가" ×2 → `{label_target_price}`, "다른 협력사들의" → `{label_supplier}`. 4번 카드의 `{discount_rate}%` 수치 인용 제거(기준가 없을 때 "인하율 약 0.0%는 의미 있는 진전" 모순 방지) |
| `tenants/_base/resources/scripts_wildcard.json` | "목표 매입가는" → `{label_target_price}` |
| `tests/test_context_loader.py` | 더블·단언을 새 시그니처로 |
### negosium backend (공급사 포털 API)
| 파일 | 내용 |
|---|---|
| `services/chat_service.py` | 협상 화면 `item_price` 를 기준가 규칙으로. 요약의 배송형태 라벨을 `DeliveryType.label_of()` 하드코딩 대신 회사 용어 우선 |
| `services/auth_service.py` | `me()`·`session_branding()` 에 헬프데스크·유의사항 전달 |
| `router/v1/auth/protocol.py` | `Res_Me.guide_notices`, `Res_SessionBranding.helpdesk` |
### negodata backend
| 파일 | 내용 |
|---|---|
| `common/nego_baseline.py` | **신규.** 기준가 판정 정본 (`resolve_baseline_field` / `resolve_baseline_price`) |
| `services/lps_sync_service.py` | 인터넷 최저가 검색의 가격 힌트를 `items.price` 고정 → 기준가 규칙. 호출부가 설정을 안 넘기면 상품의 소속 회사 설정을 직접 조회 |
### negodata front (어드민)
| 파일 | 내용 |
|---|---|
| `features/settings/catalog.ts` | `features.nego_baseline_field` 타입·선택지, `branding.helpdesk`, `guide_notices`, `DEFAULT_GUIDE_NOTICES`. 용어 카탈로그에 `target_price`·`supplier` 추가. **숨김 가능 목록에서 `internet_lowest_price` 제외**(신규 견적의 유일한 목표가 후보) |
| `features/settings/SettingsView.tsx` | **공급사 포털 안내** 탭 신설(협상 유의사항·헬프데스크). 커스텀 필드 탭 최상단에 **협상 기준가** 라디오(`NegoBaselinePicker`) — 선택지마다 실제로 나갈 문장 미리보기 + 학습 데이터 경고. `LineListEditor`. 저장·JSON 병합 경로에 `features`·`guide_notices` 반영 |
| `features/products/components/ProductFormSheet.tsx` | 신규 등록 기본값에서 개발용 더미 제거 — `code: PROD-BAT-###`·`price: 1,000,000`·`minPrice: 800,000`·`origin: 대한민국`·`moq: 10 EA`·`leadTime: 14` → 빈 값/0. 선택형(단위·배송형태·부가세)만 유지 |
### negosium front (공급사 포털 화면)
| 파일 | 내용 |
|---|---|
| `apis/auth/auth.type.ts` | `Branding.helpdesk`, `MeResponse.guide_notices`, `AuthUser.guideNotices` |
| `features/auth/hooks/usePreLoginBranding.ts` | 로그인 전 헬프데스크 수신 |
| `features/chat/components/menu/Contact.tsx` | 하드코딩 연락처 → 회사 설정, 미등록이면 섹션 숨김 |
| `features/chat/components/popup/GuideContent.tsx` | 유의사항 불릿 5개 하드코딩 → 회사 설정(미설정 시 기본 문구). **VAT·배송비 불릿 삭제**(IMK ⑥ 요청) |
| `pages/LoginPage.tsx` | 하드코딩 연락처 → 회사 설정 |
---
## 4. 경우의 수 검증 — 12조합 × 3경로
상품 = 산업용 베어링 6204 (공급가 8,900 / 매입가 7,200), 공급사 제시가 8,000.
agent·negosium backend·negodata backend 를 **각각 실제로 호출**해 측정.
| 기준가 설정 | 숨김 | 포털 표시 | LPS 가격 힌트 | 협상 멘트 |
|---|---|---|---|---|
| 미설정 | 없음 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| 미설정 | `price` | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 |
| 미설정 | `purchase_price` | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| 미설정 | 둘 다 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| `=price` | 없음 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| `=price` | `price` | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| `=price` | `purchase_price` | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| `=price` | 둘 다 | 8,900 | `price=8900` | 기존 상품 단가 대비 약 10.1% 인하 |
| `=purchase` | 없음 | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 |
| `=purchase` | `price` | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 |
| `=purchase` | `purchase_price` | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 |
| `=purchase` | 둘 다 | 7,200 | `purchase_price=7200` | 기존 매입가(7200원)보다 약 11.1% 높은 |
**12/12 세 경로가 같은 값을 쓴다. 어긋나는 조합 없음.**
- 명시 설정이 항상 이긴다 — `=price` 인데 `price` 를 숨겨도 8,900
- 숨김 폴백은 `price` 만 숨겼을 때만 발동
- 둘 다 숨겨도 `price` 로 폴백해 협상이 안 깨진다
### 용어·조사 검증
| 조건 | 출력 |
|---|---|
| 용어 미설정 + `price` | 기존 **상품 단가** 대비 약 3.2% 인하된 금액입니다 |
| `item.price="공급가"` | 기존 **공급가** 대비 … |
| `item.purchase_price="기준매입단가"` | 기존 **기준매입단가** 대비 약 4.2% 인하된 금액입니다 |
| `item.purchase_price="기준값"`(받침) + 제시가=기준가 | 기존 **기준값과** 동일한 수준의 금액입니다 |
| 기준가 컬럼 NULL | 인하율 문장 **생략**(0원 대비 계산 안 나감) |
용어를 전부 바꾼 회사(`supplier=공급업체`, `target_price=목표단가`)로 전 흐름 실행:
```
"본 서비스는 아이마켓코리아와 공급업체 간 물품 공급 가격 협상을 위한 것으로…"
"본 안내는 공급업체 포털에 등록된 담당자에게 발송되었습니다."
"아이마켓코리아는 아래 상품에 대해 신규 공급업체를 선정하고 있으며…"
배송형태선택 → options: ['직납', 'IMK물류(배송)', 'IMK물류(집배송)']
```
### 목표가 산정 (숨김 축, 기준가 설정과 무관)
인터넷최저가 8,500 / 매입가 7,200 / 판매가 9,800 · 수수료 7.8% · 네고율 12%
| 숨김 | 신규 | 재견적 |
|---|---|---|
| 없음 | 7,840 | 6,340 |
| `price` | 7,840 | 6,340 |
| `purchase_price` | 7,840 | **7,840** (가장 싼 후보가 빠짐) |
| 둘 다 | 7,840 | **7,840** |
`price` 는 원래 목표가 후보가 아니라 숨겨도 무영향.
### 포털 API 실측 (`GET /v1/negotiation/sessions/{id}/chat/init`)
`global` 계정 로그인 후 4조합 확인 — 위 표의 포털 열이 그 결과. 회사 용어 12개, 배송형태 라벨(`직납`) 정상 전달.
### 자동 테스트
`agent` 176건 전부 통과. (테스트가 `learning` 스키마를 TRUNCATE 하므로 백업 후 실행·복원)
---
## 5. negodata / negosium 영향 — 문제 있는 곳
### negodata (어드민)
| 화면 | 설정에 따라 달라지는 것 | 문제 |
|---|---|---|
| 회사 설정 | 협상 기준가 라디오·상품 필드 숨김·용어·포털 안내 탭 | 없음 |
| 상품 목록·등록·엑셀 양식 | 숨긴 필드가 세 곳에서 동시에 빠짐 | 없음 |
| 엑셀 업로드 | 숨긴 열은 파일에 있어도 **읽지 않고 무시**(양식 생성과 파싱이 같은 컬럼 정의 공유) | 없음 |
| 견적 생성 | 목표가 후보에서 숨긴 가격 제외. 후보가 없으면 프론트가 생성 차단 | 없음 |
| 인터넷 최저가 검색 | 가격 힌트가 기준가 규칙을 따름 | 없음 |
| 통계 | 목표가·낙찰가·앵커가 기반이라 무관 | 없음 |
### negosium (공급사 포털·챗)
| 화면 | 설정에 따라 달라지는 것 | 문제 |
|---|---|---|
| 협상 챗 멘트 | "기존 OO 대비 N% 인하" 의 분모와 호칭 | 없음 |
| 협상 화면 상단 | 기준 단가 표시값 | 없음 |
| 협상 카드 멘트 | 회사 용어(`{label_supplier}`·`{label_target_price}` 등) 치환 | 없음 |
| 배송형태 선택지 | 회사 용어로 치환(IMK: 직납·IMK물류) | 없음 |
| 유의사항 팝업 | 회사 설정 항목, 미설정 시 기본 문구 | 없음 |
| 헬프데스크 | 회사 설정 연락처, 미등록 시 영역 숨김 | 없음 |
| 로그인 화면 | 로그인 전에도 회사 헬프데스크 노출 | 없음 |
### 안 바뀌는 곳
목표가 산정(기준가 설정과 무관) · 앵커링가 · 앵커링 학습(축 = 회사·협력사유형·가격대) ·
통계 전부 · 진행 중인 협상(세션 시작 시 기준가를 박제하므로 라운드 중간에 안 바뀜).
### 유일하게 되돌릴 수 없는 것
**RL 가격 수용률** — 학습 상태 인덱스에 들어가므로, 협상 이력이 쌓인 뒤 기준가를 바꾸면
Q테이블에 두 기준이 섞이고 설정을 되돌려도 복구되지 않는다. 온보딩 때 정한다.
---
## 6. 확인하지 않은 것
- **설정 화면 렌더를 눈으로 보지 않았다.** tsc·eslint 통과. 다만 관리자가 그 화면에서 저장한 값이
DB 에 정상 반영된 것으로 렌더·저장 경로는 실증됐다(`features`·`hidden_fields`·`guide_notices`).
- **negodata backend 테스트 미실행.** 컨테이너에 pytest 미설치이고 `negosium_test_db` 가 없어 dev DB 를
truncate 할 위험이 있어 돌리지 않았다. 다만 이번에 바꾼 `common/nego_baseline.py`(신규)·
`lps_sync_service.py` 를 호출하는 기존 테스트는 없다(`test_scheduler.py` 가 잡 이름만 확인).
- `global` 계정 비밀번호를 dev DB 에서 `1234` 로 리셋했다.
### 오해였던 것 (기록)
- 검증 중 `learning` 스키마 행이 늘어 "테스트 협상이 학습을 오염시켰다"고 봤으나, 실제로는 **pytest 산출물**이었다.
학습 데이터의 `company_id` 는 테스트마다 만든 랜덤 UUID 42개이고 **IMK 스코프는 0건**이다.
- "IMK 매입가에 원가가 들어 있어 협상 멘트가 전부 인상으로 나온다"고 봤으나, 테스트 제시가를 매입가보다
높게 넣어서 생긴 착시였다. 매입가 이하를 제시하면 정상적으로 인하율이 나온다:
`7,000 → 2.8% 인하` · `6,500 → 9.7% 인하` · `8,000 → 11.1% 높은`.

View File

@ -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",

View File

@ -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"
},

View File

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

View 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;

View File

@ -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';

View File

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

View 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 QuotationDataDoneCeilingRate = number | null;

View File

@ -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;
}

View File

@ -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;
}

View 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 ReqCreateCardTactic = unknown | null;

View File

@ -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;
}

View 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 ReqCreateQuotationDoneCeilingRate = number | null;

View File

@ -8,4 +8,5 @@
export interface ReqCreateQuotationSetting {
target_margin_rate: number;
card_count?: number;
done_ceiling_rate?: number;
}

View File

@ -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;
}

View 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 ReqRegenerateQuotationCardIds = string[] | null;

View 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 ReqRegenerateQuotationDoneCeilingRate = number | null;

View 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 ReqRegenerateQuotationEndTime = string | null;

View 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 ReqRegenerateQuotationTargetPrice = number | null;

View File

@ -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;
}

View 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 ReqUpdateCardTactic = unknown | null;

View File

@ -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;
}

View 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 ReqUpdateQuotationSettingDoneCeilingRate = number | null;

View File

@ -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,
},
],
},
{

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

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

View File

@ -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 '';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}

View 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 };
}

View File

@ -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 ?? '서버 오류';

View 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,
};
}

View File

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

View File

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

View File

@ -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: '숫자',

View File

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

View 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>
);
}

View File

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

View File

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

View File

@ -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';

View 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;

View 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}%';

View File

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

View File

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