Compare commits
61 Commits
feat/landi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69e4240039 | |||
| 8112394aef | |||
| 1a859b198c | |||
| d33648327c | |||
| dce2c42d74 | |||
| 0e4ddf0f43 | |||
| 6eb4dc26f8 | |||
| d02ba20520 | |||
| ac6366a898 | |||
| f1e924931f | |||
| e1e519c20c | |||
| e816bfbba4 | |||
| 37fa65707f | |||
| 86d50c1291 | |||
| 436384fa36 | |||
| a405e00bf9 | |||
|
|
7dc925bcd4 | ||
|
|
0c6c431f0a | ||
|
|
67612b477c | ||
|
|
6e401615eb | ||
| 11b29b45e3 | |||
| 4ed23c56b8 | |||
|
|
6ddb2c4504 | ||
|
|
95086cb3f6 | ||
|
|
d1d1ed6ec5 | ||
|
|
74db218410 | ||
|
|
b278f58d9c | ||
|
|
1e966f7121 | ||
|
|
04b697a9eb | ||
|
|
14bcf1481b | ||
|
|
152aed9908 | ||
|
|
f483839b11 | ||
|
|
8d15fe6bb0 | ||
|
|
9380760869 | ||
|
|
3bdff74ceb | ||
|
|
b403ca21c9 | ||
| 17a698a7af | |||
|
|
7b9d895ead | ||
|
|
08afc7d4ad | ||
|
|
7b1bcb6d24 | ||
|
|
377389f495 | ||
| a416668256 | |||
| 54f8f7a6a1 | |||
|
|
042e2d3012 | ||
|
|
b2b5708a6a | ||
|
|
8574df4c16 | ||
| c56cf8e3af | |||
|
|
6c7ff5af51 | ||
|
|
5c9567919f | ||
|
|
b7fc327779 | ||
|
|
18fe18dd39 | ||
| 6f5d69b5cb | |||
| 71ef28110b | |||
|
|
3b345929d4 | ||
|
|
495e16b951 | ||
|
|
f80cece9c1 | ||
|
|
23c1c48634 | ||
|
|
55c18e6a83 | ||
|
|
005ebc3d76 | ||
| 91b8dc77a0 | |||
| 2bf91d64f5 |
1
.gitignore
vendored
@ -31,3 +31,4 @@ CLAUDE.md
|
||||
|
||||
/mobile.mov
|
||||
.gstack/
|
||||
.playwright-mcp/
|
||||
|
||||
BIN
0729~30_테스트.xlsx
Normal file
BIN
0803_가격협상 우선 적용 및 논의 정리.xlsx
Normal file
BIN
260727_AIO2O 테스트 및 요청사항.xlsx
Normal file
@ -1,120 +1,240 @@
|
||||
"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층).
|
||||
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
||||
|
||||
카드 멘트에 이미 설계된 카운터 가격 제시({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 우선, 코드 폴백"으로 바꾼다.
|
||||
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
|
||||
성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||||
"""
|
||||
|
||||
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_]+)\}")
|
||||
|
||||
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
|
||||
_MID_VARIABLES = ("middle_price", "target_mid_price")
|
||||
|
||||
|
||||
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:
|
||||
|
||||
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 + 타결상한율)) — 목표가를 조금
|
||||
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다.
|
||||
"""
|
||||
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
||||
|
||||
|
||||
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||
|
||||
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을
|
||||
마지막에 하기 때문이다.
|
||||
"""
|
||||
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 ()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Offer:
|
||||
"""확정된 제안 한 건 — 금액과 그 금액을 만든 재료를 함께 들고 다닌다.
|
||||
|
||||
멘트 치환이 재료를 다시 계산하지 않게 하기 위한 것 — 재계산하면 그 사이 갱신된
|
||||
prev_customer 를 읽어 문장이 자기모순이 된다.
|
||||
"""
|
||||
|
||||
price: int # 협력사에게 제시할 금액(수락 시 타결가)
|
||||
variable: str # 이 금액을 만든 멘트 변수
|
||||
prev_customer: int # 계산에 쓴 당사 직전 제안
|
||||
prev_partner: int # 계산에 쓴 협력사 제시가
|
||||
|
||||
|
||||
def record_offer(context: Dict[str, Any], offer: Offer) -> None:
|
||||
"""확정 제안을 세션에 기록한다 — 수락 판정용 금액과 멘트 치환용 재료를 한 자리에서 쓴다.
|
||||
|
||||
두 키를 항상 함께 써야 표시가와 타결가가 갈라지지 않으므로 기록 지점을 여기 하나로 묶는다.
|
||||
"""
|
||||
context["pending_counter_price"] = offer.price
|
||||
context["pending_offer"] = {
|
||||
"price": offer.price, "variable": offer.variable,
|
||||
"prev_customer": offer.prev_customer, "prev_partner": offer.prev_partner,
|
||||
}
|
||||
context["prev_customer_price"] = offer.price # 갑의 최신 포지션 — 다음 라운드 계산·역행 금지 기준
|
||||
|
||||
|
||||
def compute_offer_detail(spec: CardSpec, context: Dict[str, Any]) -> Optional[Offer]:
|
||||
"""카드가 제시할 금액 + 그 계산에 쓴 재료. 쓸 수 없는 상황이면 None."""
|
||||
price = int(float(context.get("input_price") or 0))
|
||||
prev_customer = int(float(context.get("prev_customer_price") or context.get("anchor_price") or 0))
|
||||
value = compute_offer(spec, context)
|
||||
if value is None:
|
||||
return None
|
||||
return Offer(price=value, variable=spec.offer_variable or "", prev_customer=prev_customer, prev_partner=price)
|
||||
|
||||
|
||||
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||
|
||||
변수 공통 유효조건 (전부 만족해야 발동):
|
||||
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동
|
||||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||||
"""
|
||||
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
|
||||
value = calc(target, anchor, price, prev_customer)
|
||||
if not value or value <= 0:
|
||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||
if value > settle_ceiling(context):
|
||||
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||
if variable in _MID_VARIABLES and value >= target:
|
||||
# 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
|
||||
# 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
|
||||
return None
|
||||
if variable in ("target_price", "anchoring_price", "anchor_price"):
|
||||
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
|
||||
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
|
||||
offer = int(value)
|
||||
else:
|
||||
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 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||||
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||||
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관.
|
||||
"""
|
||||
if not available(spec, context, closing_phase=closing_phase):
|
||||
return False
|
||||
if not spec.offer_variable:
|
||||
return True
|
||||
return compute_offer(spec, context) is not None
|
||||
|
||||
|
||||
def is_played(context: Dict[str, Any], number: Optional[str]) -> bool:
|
||||
"""이 카드를 이 협상에서 이미 썼는지. 와일드 진입·종결·협상카드가 같은 이력을 본다."""
|
||||
return bool(number) and str(number) in (context.get("played_card_numbers") or [])
|
||||
|
||||
|
||||
def mark_played(context: Dict[str, Any], number: Optional[str]) -> None:
|
||||
"""카드를 실제로 내보낸 시점에 이력에 남긴다(노출되지 않은 후보는 남기지 않는다)."""
|
||||
if not number:
|
||||
return
|
||||
played = list(context.get("played_card_numbers") or [])
|
||||
if str(number) not in played:
|
||||
played.append(str(number))
|
||||
context["played_card_numbers"] = played
|
||||
|
||||
@ -23,6 +23,7 @@ _SESSIONS = table(
|
||||
"sessions",
|
||||
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
|
||||
column("qt_type"), column("target_price"), column("anchoring_price"),
|
||||
column("done_ceiling_price"), # 타결 상한가 — 견적 생성 시 박제(목표가×(1+타결상한율))
|
||||
column("qt_setting_id"),
|
||||
column("deleted"),
|
||||
schema="negotiation",
|
||||
@ -33,8 +34,32 @@ _QUOTATION_SETTINGS = table(
|
||||
column("qt_setting_id"), column("card_count"), column("deleted"),
|
||||
schema="quotation",
|
||||
)
|
||||
_ITEMS = table("items", column("item_id"), column("name"), column("price"),
|
||||
column("internet_lowest_price"), column("deleted"), schema="partner")
|
||||
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
|
||||
column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
|
||||
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼을 여기서 정한다.
|
||||
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
|
||||
|
||||
# 협상 기준가 후보 컬럼. 어느 컬럼을 고르든 공급사 화면 호칭은 '공급가'로 고정한다 —
|
||||
# 같은 돈을 고객사는 매입가·상품 단가 등으로 부르지만 챗은 공급사가 보는 화면이라
|
||||
# 공급사 관점 용어 하나만 쓴다. 회사 용어 사전(labels)은 관리자 화면 전용.
|
||||
_BASELINE_PRICE = "price"
|
||||
_BASELINE_PURCHASE = "purchase_price"
|
||||
_SUPPLIER_PRICE_LABEL = "공급가"
|
||||
|
||||
|
||||
def _resolve_baseline(settings: dict) -> str:
|
||||
"""회사 설정 → 협상 기준가로 쓸 items 컬럼명.
|
||||
|
||||
1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
|
||||
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
|
||||
않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
|
||||
chosen = (settings.get("features") or {}).get("nego_baseline_field")
|
||||
if chosen in (_BASELINE_PRICE, _BASELINE_PURCHASE):
|
||||
return 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,13 @@ _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"),
|
||||
column("available"),
|
||||
schema="card",
|
||||
)
|
||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||
@ -72,12 +98,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 +154,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 +165,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 +178,29 @@ 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, _SUPPLIER_PRICE_LABEL, {})
|
||||
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 = _resolve_baseline(settings)
|
||||
value = purchase_price if field == _BASELINE_PURCHASE else price
|
||||
return ErrorType.SUCCESS, (int(value or 0), _SUPPLIER_PRICE_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,
|
||||
@ -334,6 +375,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
_VERSION_WILD_CARDS.c.version_id == version_id,
|
||||
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||
_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||
# 협상 적용 여부(카드 설정 '적용 대기(수동)') — 꺼진 카드는 견적에 담겨 있어도 발동 금지
|
||||
_WILD_CARDS.c.available == True, # noqa: E712
|
||||
)
|
||||
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
||||
)
|
||||
@ -342,8 +385,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
return w_err, ([], [])
|
||||
|
||||
return ErrorType.SUCCESS, (
|
||||
[str(r) for r in n_rows if r is not None],
|
||||
[str(r) for r in w_rows if r is not None],
|
||||
[(str(r[0]), r[1], r[2]) for r in n_rows if r[0] is not None],
|
||||
[(str(r[0]), r[1], r[2]) for r in w_rows if r[0] is not None],
|
||||
)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -10,10 +10,16 @@ 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 (
|
||||
OFFER_VARIABLES, Offer, available, compute_offer_detail, is_played, mark_played, playable,
|
||||
record_offer, settle_ceiling, spec_from_context,
|
||||
)
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
|
||||
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
||||
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외).
|
||||
_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price")
|
||||
|
||||
_PRICE_MODES = ("price",)
|
||||
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
||||
|
||||
@ -43,6 +49,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", "상품명"),
|
||||
}
|
||||
# 협상 기준가 호칭 — 공급사 화면 고정 용어. 회사 용어 사전(labels)을 타지 않는다(그건 관리자 화면 전용).
|
||||
# 실제 값은 loader 가 컨텍스트에 박제하고, DB 컨텍스트가 없는 데모/직접호출 경로만 이 폴백을 쓴다.
|
||||
_SUPPLIER_PRICE_LABEL = "공급가"
|
||||
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {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
|
||||
@ -96,8 +135,9 @@ class ChatEngine:
|
||||
if price is None:
|
||||
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
||||
session.context["input_price"] = price
|
||||
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기.
|
||||
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기.
|
||||
session.context.pop("pending_counter_price", None)
|
||||
session.context.pop("pending_offer", None)
|
||||
session.context["prev_partner_price"] = price
|
||||
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
||||
session.context.setdefault("first_offer_price", price)
|
||||
@ -115,6 +155,7 @@ class ChatEngine:
|
||||
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
|
||||
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
|
||||
pending = session.context.pop("pending_counter_price", None)
|
||||
session.context.pop("pending_offer", None)
|
||||
if pending and user_input in _ACCEPT_INPUTS:
|
||||
session.context["input_price"] = float(pending)
|
||||
return self._render(session, nxt)
|
||||
@ -169,6 +210,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 +231,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)
|
||||
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
||||
# 상한 이내면 타결한다.
|
||||
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
|
||||
@ -197,32 +261,42 @@ class ChatEngine:
|
||||
|
||||
def _pick_wildcard(self, session: ChatSession) -> str:
|
||||
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
||||
앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다.
|
||||
|
||||
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 —
|
||||
견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채
|
||||
'와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가
|
||||
노출되는 오작동이 있었다.
|
||||
앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고,
|
||||
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
|
||||
"""
|
||||
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% 카드에도
|
||||
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
|
||||
prev_customer = ctx.get("prev_customer_price") or 0
|
||||
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
|
||||
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["offer_1pct"] = offer_1pct
|
||||
record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct",
|
||||
prev_customer=int(prev_customer or anchor), prev_partner=int(price)))
|
||||
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_detail(spec, ctx)
|
||||
if offer is not None:
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["pending_counter_price"] = counter
|
||||
ctx["active_wild_card_number"] = str(number)
|
||||
record_offer(ctx, offer)
|
||||
ctx["active_wild_card_number"] = number
|
||||
mark_played(ctx, number)
|
||||
return "wild_card_dynamic"
|
||||
return "가격협상"
|
||||
|
||||
@ -240,6 +314,19 @@ 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)
|
||||
# 기준가 호칭은 회사 용어가 아니라 공급사 관점 고정 — loader 박제값(없으면 '공급가').
|
||||
price_word = str(ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL)
|
||||
out["label_item_price"] = price_word
|
||||
for form in ("는", "가", "를", "와"):
|
||||
out[f"label_item_price_{form}"] = _josa(price_word, form)
|
||||
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
|
||||
if ctx.get("partner_name"):
|
||||
out["partner_name"] = str(ctx["partner_name"])
|
||||
@ -253,43 +340,51 @@ 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:
|
||||
out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"])
|
||||
prev_customer = ctx.get("prev_customer_price") or anchor
|
||||
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다.
|
||||
# prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가
|
||||
# "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다.
|
||||
pending_offer = ctx.get("pending_offer") or {}
|
||||
prev_customer = int(pending_offer.get("prev_customer") or ctx.get("prev_customer_price") or anchor or 0)
|
||||
partner_price = int(pending_offer.get("prev_partner") or ctx.get("prev_partner_price") or ctx.get("input_price") or 0)
|
||||
offer_price = int(pending_offer.get("price") or ctx.get("pending_counter_price") or 0)
|
||||
offer_variable = pending_offer.get("variable") or ""
|
||||
if prev_customer:
|
||||
out["prev_customer_price"] = int(prev_customer)
|
||||
if anchor and target:
|
||||
out["target_mid_price"] = int(round((anchor + target) / 2))
|
||||
if prev_customer and "input_price" in ctx:
|
||||
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 의
|
||||
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
|
||||
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
|
||||
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
|
||||
pending_i = int(ctx["pending_counter_price"])
|
||||
out["counter_price"] = pending_i
|
||||
out["middle_price"] = pending_i
|
||||
out["target_mid_price"] = pending_i
|
||||
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
|
||||
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||
out["prev_customer_price"] = prev_customer
|
||||
if partner_price:
|
||||
out["prev_partner_price"] = partner_price
|
||||
if offer_price:
|
||||
out["counter_price"] = offer_price
|
||||
# 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과
|
||||
# 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다.
|
||||
# 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다.
|
||||
# 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다.
|
||||
for name in _DERIVED_PRICE_VARIABLES:
|
||||
if offer_price and (name == offer_variable or not offer_variable):
|
||||
out[name] = offer_price
|
||||
continue
|
||||
value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer)
|
||||
if value:
|
||||
out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일
|
||||
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 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 _SUPPLIER_PRICE_LABEL
|
||||
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 +401,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+타결상한율)), 미박제면 목표가.
|
||||
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다.
|
||||
# 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
||||
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 +422,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 +438,13 @@ class ChatEngine:
|
||||
)
|
||||
|
||||
def _error(self, session: ChatSession, msg: str) -> StepView:
|
||||
# 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
|
||||
# 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
|
||||
node = self.scripts.get(session.step, {})
|
||||
step_vars = self._vars(session)
|
||||
return StepView(
|
||||
step=session.step, script=node.get("script", ""),
|
||||
input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []),
|
||||
step=session.step, script=self.repo.format_script(node.get("script", ""), step_vars),
|
||||
input_mode=node.get("next_input_mode", "null"),
|
||||
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
|
||||
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
|
||||
)
|
||||
|
||||
@ -19,6 +19,7 @@ from typing import Optional
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from negotiation.cards.domain.tactics import build_card_spec
|
||||
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
|
||||
from negotiation.qtable.domain.model.snapshot import PartnerType
|
||||
|
||||
@ -38,7 +39,10 @@ class NegotiationDbContext:
|
||||
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
|
||||
target_price: int # 목표 매입가(원) — sessions.target_price
|
||||
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
||||
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
|
||||
done_ceiling_price: int # 타결 상한가 — sessions.done_ceiling_price(생성 시 박제). 없으면 target
|
||||
item_price: int # 협상 기준가(고객사가 관리하는 가격 — 공급가 또는 매입가) — 인하율 멘트용. 없으면 0
|
||||
item_price_label: str # 협상 멘트에서 기준가를 부르는 말(회사 용어 설정 → 없으면 카탈로그 기본값)
|
||||
labels: dict # 회사 용어 사전(companies.settings.labels) — 스크립트 {label_*} 토큰 치환용
|
||||
internet_lowest_price: int # 인터넷 최저가(items.internet_lowest_price, LPS 대표값) — 카드 {internet_lowest_price} 치환용. 미수집이면 0
|
||||
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
|
||||
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
|
||||
@ -48,6 +52,9 @@ class NegotiationDbContext:
|
||||
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
|
||||
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
|
||||
card_count: Optional[int] # 협상카드 사용 횟수 상한(quotation_settings.card_count). None=상한 미적용
|
||||
# 카드번호 → 전술 {offer_variable, min_round, closing}. 스크립트 파싱 + tactic JSONB 로 시작 시 1회 확정 —
|
||||
# 진행 중 협상은 카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다(세션 컨텍스트에 박제).
|
||||
card_specs: dict
|
||||
|
||||
|
||||
class NegotiationContextLoader:
|
||||
@ -67,8 +74,10 @@ class NegotiationContextLoader:
|
||||
err, row = await self.crud.get_session_row(s, sid)
|
||||
if err != ErrorType.SUCCESS or row is None:
|
||||
return None
|
||||
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row
|
||||
qt_type, target_price, anchoring_price, done_ceiling_price, item_id, quotation_id, supplier_id = row
|
||||
target = int(target_price or 0)
|
||||
# 타결 상한가: 견적 생성 시 박제(목표가×(1+타결상한율)). 옛 세션은 NULL → 목표가로 폴백.
|
||||
ceiling = int(done_ceiling_price or 0) or target
|
||||
|
||||
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
|
||||
@ -85,8 +94,9 @@ class NegotiationContextLoader:
|
||||
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
|
||||
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
|
||||
|
||||
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
||||
_, item_price = await self.crud.get_item_price(s, item_id)
|
||||
# 협상 기준가 + 그 호칭 — 어느 컬럼을 쓸지는 고객사 설정(hidden_fields)이 정한다(crud).
|
||||
# 없으면 0(인하율 멘트 미표시).
|
||||
_, (item_price, item_price_label, labels) = await self.crud.get_item_baseline(s, item_id)
|
||||
|
||||
# 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환).
|
||||
_, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id)
|
||||
@ -106,7 +116,19 @@ class NegotiationContextLoader:
|
||||
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
|
||||
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
|
||||
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
|
||||
selected_nego_cards, selected_wild_cards = selected_cards
|
||||
nego_rows, wild_rows = selected_cards
|
||||
selected_nego_cards = [number for number, _script, _tactic in nego_rows]
|
||||
selected_wild_cards = [number for number, _script, _tactic in wild_rows]
|
||||
# 카드 전술 확정 — "스크립트에 꽂힌 변수가 곧 전술"(제안가 파싱) + tactic JSONB(min_round·closing).
|
||||
card_specs = {}
|
||||
for number, script, tactic in [*nego_rows, *wild_rows]:
|
||||
spec = build_card_spec(script, tactic if isinstance(tactic, dict) else None)
|
||||
card_specs[number] = {
|
||||
"offer_variable": spec.offer_variable,
|
||||
"min_round": spec.min_round,
|
||||
"closing": spec.closing,
|
||||
"requires": list(spec.requires), # 세션-의존 변수 결측 시 미발동(available)
|
||||
}
|
||||
|
||||
# 협상카드 사용 횟수 상한(견적 설정). 없으면 None → 상한 미적용(선택 카드 수로만 캡).
|
||||
_, card_count = await self.crud.get_card_count(s, sid)
|
||||
@ -115,7 +137,10 @@ class NegotiationContextLoader:
|
||||
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
|
||||
target_price=target,
|
||||
anchor_price=anchor,
|
||||
done_ceiling_price=ceiling,
|
||||
item_price=item_price,
|
||||
item_price_label=item_price_label,
|
||||
labels=labels,
|
||||
internet_lowest_price=internet_lowest_price,
|
||||
partner_name=partner_name,
|
||||
product_name=product_name,
|
||||
@ -125,6 +150,7 @@ class NegotiationContextLoader:
|
||||
selected_nego_card_numbers=selected_nego_cards,
|
||||
selected_wild_card_numbers=selected_wild_cards,
|
||||
card_count=card_count,
|
||||
card_specs=card_specs,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@ -121,7 +121,7 @@ class ScriptNaturalizer:
|
||||
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
|
||||
|
||||
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 기존 공급가 대비.
|
||||
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 협상 기준가 대비.
|
||||
"""
|
||||
out: Dict[str, Any] = {}
|
||||
rnd = context.get("round") or 0
|
||||
|
||||
@ -13,7 +13,9 @@ 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 (
|
||||
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
|
||||
)
|
||||
from negotiation.chat.service.chat_engine import (
|
||||
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
||||
)
|
||||
@ -41,6 +43,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 +112,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 +134,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,20 +304,24 @@ 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
|
||||
if counter is not None:
|
||||
session.context["pending_counter_price"] = counter
|
||||
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(middle_price 기준)
|
||||
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
||||
spec = spec_from_context(session.context, card_id)
|
||||
offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None
|
||||
counter = offer.price if offer else None
|
||||
if offer is not None:
|
||||
record_offer(session.context, offer)
|
||||
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)
|
||||
session.context["last_state"] = idx
|
||||
session.context["last_action"] = decision.action_id
|
||||
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, decision.propensity, done=False)
|
||||
await self._log(repo, session, idx, decision.action_id, card_id, snap, reward, done=False,
|
||||
decision=decision, policy=policy)
|
||||
|
||||
res.card_id = card_id
|
||||
res.policy = policy.name
|
||||
@ -352,20 +370,31 @@ 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
|
||||
if counter is None:
|
||||
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
||||
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
||||
closing_number, closing_offer = 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_detail(spec, ctx)
|
||||
if offer is not None:
|
||||
closing_number, closing_offer = n, offer
|
||||
break
|
||||
if closing_offer 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:
|
||||
price = int(ctx.get("input_price") or 0)
|
||||
if 0 < target < price:
|
||||
closing_offer = Offer(price=target, variable="target_price",
|
||||
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
|
||||
prev_partner=price)
|
||||
if closing_offer is None:
|
||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||
ctx["pending_counter_price"] = counter
|
||||
ctx["prev_customer_price"] = counter
|
||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||
record_offer(ctx, closing_offer)
|
||||
|
||||
template = None
|
||||
if closing_number:
|
||||
@ -392,7 +421,7 @@ class ChatService:
|
||||
policy.update(Transition(state_index=last_state, action_id=last_action, reward=reward.total, done=True))
|
||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, last_state, last_action)
|
||||
await self._log(repo, session, last_state, last_action,
|
||||
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True)
|
||||
self._card_id_for_action(engine, session, last_action), snap, reward, done=True)
|
||||
res.updated_q = float(policy.qtable.q[last_state, last_action])
|
||||
|
||||
@staticmethod
|
||||
@ -441,10 +470,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,
|
||||
)
|
||||
@ -469,13 +500,26 @@ class ChatService:
|
||||
prior[a] = 0.3 * (n - rank) / n
|
||||
return prior if prior.any() else None
|
||||
|
||||
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
|
||||
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, done,
|
||||
decision=None, policy=None):
|
||||
data = {
|
||||
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,
|
||||
"card_id": card_id, "snapshot": snap.to_dict(), "propensity": propensity,
|
||||
"card_id": card_id, "snapshot": snap.to_dict(),
|
||||
"turn": snap.round_number, "reward": reward.total, "done": done,
|
||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||
}
|
||||
if decision is not None and policy is not None:
|
||||
# 선택 근거(Q값·UCB·방문수)를 그 턴 값 그대로 박제한다 — 사후에 q_values 를 읽으면 이미 갱신된 뒤라
|
||||
# "그때 왜 이 카드였나"를 복원할 수 없다. negodata 협상 학습 화면이 이 컬럼들을 읽는다.
|
||||
# 종료 로그(카드 선택 없는 done 행)는 decision 이 없어 NULL — 화면 집계(avg/max)가 무시한다.
|
||||
data.update({
|
||||
"propensity": decision.propensity,
|
||||
"available_actions": decision.available_actions,
|
||||
"q_value_at_selection": decision.q_value,
|
||||
"ucb_score_at_selection": decision.ucb_score,
|
||||
"visit_count_at_selection": int(policy.qtable.visits[state_index, action_id]),
|
||||
"total_visits_at_selection": int(policy.qtable.state_visits(state_index)),
|
||||
})
|
||||
try:
|
||||
await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
||||
except Exception as ex:
|
||||
|
||||
@ -97,10 +97,10 @@ class NegotiationService:
|
||||
|
||||
# 7) experience_logs 기록
|
||||
if req.log:
|
||||
res.logged = await self._log(engine, session_id, idx, decision, snap, reward)
|
||||
res.logged = await self._log(engine, session_id, idx, decision, snap, reward, policy)
|
||||
return res
|
||||
|
||||
async def _log(self, engine, session_id, idx, decision, snap, reward) -> bool:
|
||||
async def _log(self, engine, session_id, idx, decision, snap, reward, policy) -> bool:
|
||||
repo = LearningRepository(engine.company_id)
|
||||
data = {
|
||||
"session_id": session_id, "state_index": idx, "action_id": decision.action_id,
|
||||
@ -108,6 +108,8 @@ class NegotiationService:
|
||||
"turn": snap.round_number, "available_actions": decision.available_actions,
|
||||
"reward": reward.total, "done": snap.outcome != NegotiationOutcome.ONGOING,
|
||||
"q_value_at_selection": decision.q_value, "ucb_score_at_selection": decision.ucb_score,
|
||||
"visit_count_at_selection": int(policy.qtable.visits[idx, decision.action_id]),
|
||||
"total_visits_at_selection": int(policy.qtable.state_visits(idx)),
|
||||
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
||||
}
|
||||
try:
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{
|
||||
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
||||
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
||||
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
|
||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 {label_target_price}(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||
"2": "좋은 제안 감사합니다. 다른 {label_supplier}들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
||||
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||
"4": "제시해 주신 조건은 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||
"5": "장기적인 협력 관계를 고려해 최대한 반영하고자 합니다. 현재 제시가에서 추가로 조정해 주시면 즉시 검토를 진행하겠습니다. 다시 제안 부탁드립니다.",
|
||||
"6": "검토 결과, 현재 제시가는 우리 기준을 충족하기 직전 단계입니다. 마지막으로 한 번 더 조정된 가격을 제안해 주시면 협상을 마무리할 수 있습니다.",
|
||||
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. 목표 매입가({target}원)에 가까운 금액을 제안해 주세요.",
|
||||
"7": "성의 있는 제안 감사합니다. 다만 물량과 납기 조건을 함께 고려하면 {input_price}원은 다소 높습니다. {label_target_price}({target}원)에 가까운 금액을 제안해 주세요.",
|
||||
"8": "긍정적으로 검토되고 있습니다. 내부 결재를 위해 명분이 조금 더 필요한 상황입니다. 가능하신 선에서 한 번 더 인하된 가격을 제안해 주시겠어요?"
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"서비스안내": {
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
|
||||
"editor_script_id": "서비스안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": [
|
||||
@ -25,7 +25,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"담당자확인": {
|
||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "담당자확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": [
|
||||
@ -55,7 +55,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"정보변경_완료": {
|
||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||
"script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤, 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내에 갱신되지 않으면 참여 의사가 없는 것으로 간주되어 해당 견적 건이 미참여로 처리될 수 있습니다.",
|
||||
"editor_script_id": "정보변경_완료",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"서비스안내": {
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
||||
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 {label_supplier} 간 신규 물품 공급 협상을 위한 것으로, 귀사에 새로운 공급 기회를 제공하고자 합니다. 이용 방법 안내를 확인하신 뒤 [확인]을 눌러 주세요.",
|
||||
"editor_script_id": "서비스안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["확인"],
|
||||
@ -19,7 +19,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"담당자확인": {
|
||||
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"script": "본 안내는 {label_supplier} 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "담당자확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
@ -37,7 +37,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"정보변경_완료": {
|
||||
"script": "[정보변경]을 선택하셨습니다. 협력사 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
||||
"script": "[정보변경]을 선택하셨습니다. {label_supplier} 관리 시스템에서 담당자 정보를 변경하신 뒤 고객센터로 새 견적 생성을 요청해 주세요. 24시간 이내 갱신되지 않으면 미참여로 처리될 수 있습니다.",
|
||||
"editor_script_id": "정보변경_완료",
|
||||
"next_input_mode": "null",
|
||||
"input_options": [],
|
||||
@ -46,7 +46,7 @@
|
||||
"chat_end": true
|
||||
},
|
||||
"협상품목안내": {
|
||||
"script": "{company_name}는 아래 상품에 대해 신규 공급사를 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 공급사 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
||||
"script": "{company_name}는 아래 상품에 대해 신규 {label_supplier_를} 선정하고 있으며, 귀사를 초대하여 견적을 요청드립니다. 제출하신 견적은 복수 업체와의 비교 평가를 통해 {label_supplier} 선정에 반영됩니다. 상품 정보를 확인해 주세요.",
|
||||
"editor_script_id": "협상품목안내",
|
||||
"next_input_mode": "confirm",
|
||||
"input_options": ["네, 알겠습니다."],
|
||||
@ -73,10 +73,10 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"배송형태선택": {
|
||||
"script": "배송 형태를 선택해 주세요.",
|
||||
"script": "{label_delivery_type_를} 선택해 주세요.",
|
||||
"editor_script_id": "배송형태선택",
|
||||
"next_input_mode": "delivery_type",
|
||||
"input_options": ["협력사배송", "지정택배배송", "픽업배송"],
|
||||
"input_options": ["{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}"],
|
||||
"next_step": { "default": "가격협상_입력" },
|
||||
"type": "text",
|
||||
"chat_end": false
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"editor_script_id": "wild_card_1pct"
|
||||
},
|
||||
"wild_card_budget": {
|
||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 당사 {label_target_price}는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||
"type": "text",
|
||||
"chat_end": false,
|
||||
"next_input_mode": "price",
|
||||
|
||||
157
agent/tests/fuzz_negotiation.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 N회 완주시키고 불변식 위반을 수집한다.
|
||||
시드 고정(재현 가능). test_ 접두사 없음 — pytest 수집 대상 아님, 수동 실행 전용:
|
||||
docker run --rm -v $PWD/agent:/work -w /work -e APP_ENV=local -e DB_HOST=host.docker.internal \
|
||||
o2o-negosium-agent sh -lc "pip install -q pytest pytest-asyncio httpx; python tests/fuzz_negotiation.py"
|
||||
|
||||
케이스마다 검사하는 불변식:
|
||||
1. 전 턴 success
|
||||
2. 같은 카드 2회 발동 금지
|
||||
3. 종결 전용(WC-03·05)은 가격협상_카운터에서만 / 비종결 와일드는 wild_card_dynamic 에서만
|
||||
4. 타결 시 타결가 ≤ 목표가
|
||||
5. 카운터/1% 수락으로 타결하면 그 멘트에 타결가 표기
|
||||
6. 멘트·버튼에 미치환 토큰({xxx}) 잔존 금지
|
||||
7. 턴 상한(60) 안에 반드시 종료
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, "/work")
|
||||
|
||||
from router.v1.chat.protocol import Req_Chat # noqa: E402
|
||||
from services.chat_service import ChatService, reset_sessions # noqa: E402
|
||||
from tenancy.config_loader import TenantConfigLoader # noqa: E402
|
||||
from tenancy.registry import TenantEngineRegistry # noqa: E402
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402
|
||||
|
||||
N = 100
|
||||
SEED = 20260805
|
||||
TARGET = 10_000
|
||||
NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005",
|
||||
"NGC-007", "NGC-008", "NGC-010", "NGC-011"]
|
||||
WILD_POOL = ["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]
|
||||
CLOSING = {"WC-03", "WC-05"}
|
||||
TOKEN_RE = re.compile(r"(?<!\{)\{([a-z_0-9]+)\}(?!\})")
|
||||
|
||||
|
||||
class Supplier:
|
||||
"""랜덤 협력사 — 높은 시작가에서 점진 양보, 카운터는 확률적으로 수락/거절."""
|
||||
|
||||
def __init__(self, rng, anchor):
|
||||
self.rng = rng
|
||||
self.anchor = anchor
|
||||
self.price = TARGET * rng.uniform(1.02, 1.30)
|
||||
self.accept_p = rng.uniform(0.15, 0.5)
|
||||
|
||||
def next_price(self):
|
||||
p = int(self.price)
|
||||
# 다음 라운드를 위해 양보 — 가끔 앵커 밑까지 다이브(우선협상 유도).
|
||||
self.price *= self.rng.uniform(0.90, 0.99)
|
||||
if self.rng.random() < 0.15:
|
||||
self.price = self.anchor * self.rng.uniform(0.95, 1.04)
|
||||
return str(max(p, 100))
|
||||
|
||||
def choose(self, options):
|
||||
if "수락" in options:
|
||||
return "수락" if self.rng.random() < self.accept_p else "다른 가격 제시"
|
||||
if set(options) >= {"예", "아니오"}:
|
||||
return "예" if self.rng.random() < max(self.accept_p, 0.5) else "아니오"
|
||||
return options[0] if options else "확인"
|
||||
|
||||
|
||||
async def run_case(idx, rng):
|
||||
anchor = int(TARGET * rng.choice([0.99, 0.99, 0.97, 0.95, 1.0]))
|
||||
nego = rng.sample(NEGO_POOL, rng.randint(1, 5))
|
||||
wild = rng.sample(WILD_POOL, rng.randint(0, 5))
|
||||
sup = Supplier(rng, anchor)
|
||||
|
||||
reset_sessions()
|
||||
sid = uuid.uuid4()
|
||||
qid, ver = await _seed_quote_session(sid, nego, wild_numbers=wild, target=TARGET, anchor=anchor)
|
||||
violations, fired, settled, outcome, ended = [], [], None, None, False
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
ui, last_input = None, None
|
||||
for _turn in range(60):
|
||||
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||
if r.result.success is not True:
|
||||
violations.append(f"턴 실패 input={ui} msg={r.msg}")
|
||||
break
|
||||
script, opts = r.script or "", list(r.input_options or [])
|
||||
if TOKEN_RE.search(script):
|
||||
violations.append(f"미치환 토큰(script): {TOKEN_RE.findall(script)} @ {r.step}")
|
||||
for o in opts:
|
||||
if TOKEN_RE.search(o):
|
||||
violations.append(f"미치환 토큰(option): {o} @ {r.step}")
|
||||
if r.card_id:
|
||||
fired.append((r.step, r.card_id))
|
||||
if r.settled_price is not None:
|
||||
settled = r.settled_price
|
||||
# 카운터/1% '수락' 타결이면 마지막 카운터 멘트에 타결가가 보였어야 한다.
|
||||
if last_input in ("수락",) and str(settled) not in (last_counter or ""):
|
||||
violations.append(f"표시가≠타결가: {settled} not in counter script")
|
||||
if r.step in ("가격협상_카운터", "wild_card_dynamic", "wild_card_1pct"):
|
||||
last_counter = script
|
||||
if r.chat_end:
|
||||
outcome, ended = r.outcome, True
|
||||
break
|
||||
# 다음 입력 결정
|
||||
last_input = None
|
||||
if r.input_mode == "price":
|
||||
ui = sup.next_price()
|
||||
elif opts:
|
||||
ui = sup.choose(opts)
|
||||
last_input = ui
|
||||
else:
|
||||
ui = "확인"
|
||||
if not ended:
|
||||
violations.append("60턴 내 미종료")
|
||||
|
||||
# 카드 불변식
|
||||
ids = [c for _, c in fired]
|
||||
if len(ids) != len(set(ids)):
|
||||
violations.append(f"카드 중복: {ids}")
|
||||
for step, c in fired:
|
||||
if c in CLOSING and step != "가격협상_카운터":
|
||||
violations.append(f"종결 카드 {c} 가 {step} 에서 발동")
|
||||
if c.startswith("WC") and c not in CLOSING and step != "wild_card_dynamic":
|
||||
violations.append(f"비종결 와일드 {c} 가 {step} 에서 발동")
|
||||
if outcome == "success":
|
||||
if settled is None:
|
||||
violations.append("성공인데 settled 없음")
|
||||
elif settled > TARGET:
|
||||
violations.append(f"목표가 초과 타결: {settled}")
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver)
|
||||
return {"idx": idx, "anchor": anchor, "nego": nego, "wild": wild,
|
||||
"fired": fired, "settled": settled, "outcome": outcome, "violations": violations}
|
||||
|
||||
|
||||
async def main():
|
||||
rng = random.Random(SEED)
|
||||
results, bad = [], []
|
||||
for i in range(N):
|
||||
res = await run_case(i, random.Random(rng.random()))
|
||||
results.append(res)
|
||||
if res["violations"]:
|
||||
bad.append(res)
|
||||
tag = "OK " if not res["violations"] else "BAD"
|
||||
print(f"[{tag}] #{i:02d} anchor={res['anchor']} nego={len(res['nego'])} wild={len(res['wild'])} "
|
||||
f"fired={'→'.join(c for _, c in res['fired']) or '-'} settled={res['settled']} {res['outcome']}")
|
||||
ok = sum(1 for r in results if not r["violations"])
|
||||
succ = sum(1 for r in results if r["outcome"] == "success")
|
||||
print(f"\n===== {ok}/{N} clean · 타결 {succ} / 결렬 {N - succ} =====")
|
||||
for r in bad:
|
||||
print(f"\n#{r['idx']} 위반: nego={r['nego']} wild={r['wild']} anchor={r['anchor']}")
|
||||
for v in r["violations"]:
|
||||
print(" -", v)
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
sys.exit(0 if not bad else 1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@ -53,7 +53,7 @@ async def test_4_4_company_id_auto_onboard():
|
||||
eng = await _reg().get_engine(COMPANY_ID)
|
||||
assert eng.tenant_id == COMPANY_ID
|
||||
assert eng.company_id == COMPANY_ID # 학습/세션이 이 company_id 로 격리
|
||||
assert eng.action_space_size == 11 # base 기본 카드(162×11 정합)
|
||||
assert eng.action_space_size == 9 # DB 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
assert eng.state_space_size == 162
|
||||
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ async def test_selected_cards_only_are_played(db_engine):
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그)
|
||||
assert eng.action_space_size == 11 # 카탈로그 11장(NGC-001~011)
|
||||
assert eng.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
|
||||
svc = ChatService()
|
||||
played = []
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"""카드 전술 재설계 검증 — "멘트 카드 → 전술 카드" (가격 행동 실행 계층).
|
||||
"""카드 전술 검증 — "스크립트에 꽂힌 변수가 곧 전술" (파싱 + 변수별 유효조건 + tactic JSONB).
|
||||
|
||||
① 카운터 산식 결정론 + min(counter, target) 클램프 + 무의미 카운터(HOLD 강등)
|
||||
② 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||
③ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||
④ 선택형 와일드카드(WC-05 중간값 절충) 발동 — 1.02~1.05 구간 갭 해소
|
||||
⑤ E2E: 견적 선택 카드(NGC-009 조건부 가격 조정)의 카운터를 수락하면 settled=target
|
||||
⑥ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||
① 제안가 파싱(마지막 제안가 변수) + 변수별 계산식 결정론
|
||||
② 변수 공통 유효조건 — 목표가 초과·제시가 이상이면 미발동(클램프 아님 — IMK 8AB0 회귀)
|
||||
③ 카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
|
||||
④ 목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
|
||||
⑤ 와일드 진입 — 종결 전용 카드 예약(중반 미발동) + 카드 이력 공유(중복 발동 차단, IMK BB9A 회귀)
|
||||
⑥ E2E: 견적 선택 카드(NGC-010 목표가 제안)의 카운터를 수락하면 settled=target
|
||||
⑦ E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 후 결렬 — 고객사 이득 가드레일
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -15,7 +16,8 @@ from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
|
||||
from negotiation.cards.domain.tactics import (
|
||||
PriceAction, TacticSpec, compute_counter, tactic_available, tactic_for,
|
||||
CardSpec, HOLD, available, build_card_spec, compute_offer,
|
||||
is_played, mark_played, parse_offer_variable, playable, spec_from_context,
|
||||
)
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
@ -23,6 +25,12 @@ from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||
|
||||
# 엔진 단위 테스트용 카드 스펙(로더가 DB 스크립트 파싱으로 만드는 것과 같은 형태).
|
||||
_SPECS = {
|
||||
"WC-02": {"offer_variable": "target_mid_price", "min_round": 1, "closing": False},
|
||||
"WC-05": {"offer_variable": "middle_price", "min_round": 1, "closing": True},
|
||||
}
|
||||
|
||||
|
||||
def _engine() -> ChatEngine:
|
||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
||||
@ -31,47 +39,134 @@ def _engine() -> ChatEngine:
|
||||
|
||||
def _session(step="가격협상_확인", **ctx_over):
|
||||
ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100,
|
||||
"round": 1, "allow_selected_wildcards": False}
|
||||
"round": 1, "allow_selected_wildcards": False, "card_specs": dict(_SPECS)}
|
||||
ctx.update(ctx_over)
|
||||
return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea",
|
||||
company_id="imarketkorea", step=step, action_space_size=0, context=ctx)
|
||||
|
||||
|
||||
# ---- ① 카운터 산식 (결정론 + 가드레일 클램프) --------------------------------
|
||||
def test_counter_formulas_and_clamp():
|
||||
# ---- ① 제안가 파싱 + 계산식 ---------------------------------------------------
|
||||
def test_parse_offer_variable_last_offer_wins():
|
||||
"""제안가 변수가 여럿이면 마지막 것 — 카드 문장은 배경을 먼저, 제안을 마지막에 한다(WC-04)."""
|
||||
assert parse_offer_variable("적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안") == "target_price"
|
||||
assert parse_offer_variable("{target_price}원을 제안 드립니다") == "target_price"
|
||||
# 읽어주기 변수만 있으면 설득 카드 — 제안가 없음
|
||||
assert parse_offer_variable("시장가 {internet_lowest_price}원 안팎, 제시가 {prev_partner_price}원") is None
|
||||
assert parse_offer_variable("가격 변수 없는 설득 멘트") is None
|
||||
assert parse_offer_variable(None) is None
|
||||
# WC-05 정본: 읽어주기(직전 제안·제시가) 뒤 절충가 제안
|
||||
assert parse_offer_variable("당사 제안 {prev_customer_price}원과 귀사 제안 {prev_partner_price}원을 절반씩, {middle_price}원으로") == "middle_price"
|
||||
# negodata 에디터 칩 표기(anchor_price)도 앵커가 제안으로 인식 — DB 시드 표기(anchoring_price)의 별칭
|
||||
assert parse_offer_variable("예산 한도는 {anchor_price}원입니다") == "anchor_price"
|
||||
|
||||
|
||||
def test_build_card_spec_merges_script_and_tactic():
|
||||
spec = build_card_spec("{target_price}원으로 제안", {"min_round": 2, "closing": True})
|
||||
assert spec == CardSpec(offer_variable="target_price", min_round=2, closing=True)
|
||||
# tactic 없음 → 기본값. offer_variable override 는 파싱보다 우선.
|
||||
assert build_card_spec("설득 멘트", None) == HOLD
|
||||
assert build_card_spec("멘트", {"offer_variable": "anchoring_price"}).offer_variable == "anchoring_price"
|
||||
|
||||
|
||||
def test_offer_formulas():
|
||||
ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000}
|
||||
assert compute_counter(tactic_for("NGC-009"), ctx) == 10000 # COUNTER_TARGET
|
||||
assert compute_counter(tactic_for("NGC-007"), ctx) == 9900 # COUNTER_ANCHOR
|
||||
assert compute_counter(tactic_for("WC-02"), ctx) == 9950 # (anchor+target)/2
|
||||
# COUNTER_MID: 갑 직전 포지션 폴백 = anchor → (9900+11000)/2 = 10450 → target 클램프
|
||||
assert compute_counter(tactic_for("WC-05"), ctx) == 10000
|
||||
# 갑 직전 포지션이 있으면 그 기준: (9800+11000)/2 = 10400 → 역시 클램프 10000
|
||||
assert compute_counter(tactic_for("WC-05"), dict(ctx, prev_customer_price=9800)) == 10000
|
||||
# 클램프 미발동 구간: (9900+10050)/2 = 9975 ≤ target → 10원 단위 반올림 9980
|
||||
assert compute_counter(tactic_for("WC-05"), dict(ctx, input_price=10050)) == 9980
|
||||
offer = lambda var, c=None: compute_offer(CardSpec(offer_variable=var), c or ctx) # noqa: E731
|
||||
assert offer("target_price") == 10000
|
||||
assert offer("anchoring_price") == 9900
|
||||
assert offer("target_mid_price") == 9950 # (anchor+target)/2
|
||||
# 절충가: 갑 직전 포지션 폴백 = anchor → (8900+9500)/2 = 9200
|
||||
assert offer("middle_price", dict(ctx, input_price=9500, anchor_price=8900)) == 9200
|
||||
# 갑 직전 포지션이 있으면 그 기준: (9000+9500)/2 = 9250
|
||||
assert offer("middle_price", dict(ctx, input_price=9500, prev_customer_price=9000)) == 9250
|
||||
|
||||
|
||||
def test_counter_meaningless_degrades_to_hold():
|
||||
"""협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(순수 설득 유지)."""
|
||||
# ---- ② 변수 공통 유효조건 — 미발동(클램프 아님) --------------------------------
|
||||
def test_offer_over_target_does_not_fire_imk_8ab0():
|
||||
"""IMK 8AB0 회귀: 목표가 9,000 / 제시가 9,500 → 절충가 (8,910+9,500)/2 = 9,205 > 목표가.
|
||||
구현이 목표가로 깎아 부르면 '중간에서 만나자며 목표가를 부르는' 모순 — 클램프가 아니라 미발동이 정답."""
|
||||
ctx = {"input_price": 9500, "anchor_price": 8910, "target_price": 9000}
|
||||
assert compute_offer(CardSpec(offer_variable="middle_price"), ctx) is None
|
||||
|
||||
|
||||
def test_offer_at_or_above_input_price_does_not_fire():
|
||||
"""협력사 제시가가 이미 제안가 이하면 부를 이유가 없다 → 미발동."""
|
||||
ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000}
|
||||
assert compute_counter(tactic_for("NGC-009"), ctx) is None # target(10000) ≥ 제시가
|
||||
assert compute_counter(TacticSpec(PriceAction.COUNTER_ANCHOR), dict(ctx, input_price=9900)) is None
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) is None # target ≥ 제시가
|
||||
assert compute_offer(CardSpec(offer_variable="anchoring_price"), dict(ctx, input_price=9900)) is None
|
||||
|
||||
|
||||
def test_unknown_card_falls_back_to_hold():
|
||||
"""미등록 카드번호(데모 NGC-B*, 커스텀 COMP-*)는 HOLD — 기존 동작 그대로."""
|
||||
spec = tactic_for("NGC-B003")
|
||||
assert spec.price_action is PriceAction.HOLD
|
||||
assert compute_counter(spec, {"input_price": 11000, "target_price": 10000}) is None
|
||||
assert tactic_available(spec, {}) is True
|
||||
def test_offer_without_materials_does_not_fire():
|
||||
"""재료 결측(목표가·제시가·앵커) — 어떤 변수도 미발동."""
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), {"input_price": 11000}) is None # 목표가 없음
|
||||
assert compute_offer(CardSpec(offer_variable="anchoring_price"),
|
||||
{"input_price": 11000, "target_price": 10000}) is None # 앵커 없음
|
||||
assert compute_offer(HOLD, {"input_price": 11000, "target_price": 10000}) is None # 설득 카드
|
||||
assert compute_offer(CardSpec(offer_variable="없는변수"), {"input_price": 11000, "target_price": 10000}) is None
|
||||
|
||||
|
||||
def test_tactic_availability_conditions():
|
||||
assert tactic_available(tactic_for("WC-04"), {"round": 1}) is False # min_round=2
|
||||
assert tactic_available(tactic_for("WC-04"), {"round": 2}) is True
|
||||
def test_available_min_round_and_closing_phase():
|
||||
spec2 = CardSpec(offer_variable="target_price", min_round=2)
|
||||
assert available(spec2, {"round": 1}) is False # min_round 미만
|
||||
assert available(spec2, {"round": 2}) is True
|
||||
closing = CardSpec(offer_variable="middle_price", closing=True)
|
||||
assert available(closing, {"round": 1}) is False # 종결 전용 — 중반 미발동(예약)
|
||||
assert available(closing, {"round": 1}, closing_phase=True) is True
|
||||
assert available(spec2, {"round": 3}, closing_phase=True) is False # 종결 국면엔 종결 카드만
|
||||
|
||||
|
||||
# ---- ② 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||
def test_tactic_offer_variable_overrides_parse():
|
||||
"""검증: tactic.offer_variable 명시 지정(negodata 셀렉트) — 파싱(마지막 변수) 대신 지정 변수 사용.
|
||||
기대결과: 멘트 마지막이 target_price 여도 지정한 anchoring_price 가 제안가 변수가 된다."""
|
||||
script = "적정가는 {anchoring_price}원이었으나 {target_price}원으로 제안 드립니다."
|
||||
assert build_card_spec(script).offer_variable == "target_price" # 자동: 마지막 변수
|
||||
spec = build_card_spec(script, {"offer_variable": "anchoring_price"})
|
||||
assert spec.offer_variable == "anchoring_price" # 명시 지정이 우선
|
||||
|
||||
|
||||
def test_available_requires_context_value():
|
||||
"""검증: 시장가 인용 카드(NGC-008류)의 requires 게이트 — build_card_spec 이 스크립트에서 잡아내고,
|
||||
기대결과: 컨텍스트에 인터넷 최저가가 없으면(0/결측) 미발동, 있으면 발동(퍼즈 #3·13·23·40 회귀)."""
|
||||
spec = build_card_spec("유사 거래는 {internet_lowest_price}원 안팎에서 합의되고 있습니다.")
|
||||
assert spec.requires == ("internet_lowest_price",)
|
||||
assert available(spec, {"round": 1}) is False # 결측
|
||||
assert available(spec, {"round": 1, "internet_lowest_price": 0}) is False # 미수집(0)
|
||||
assert available(spec, {"round": 1, "internet_lowest_price": 6300}) is True
|
||||
# 일반 카드는 requires 없음 — 기존 동작 그대로.
|
||||
assert build_card_spec("귀사와의 협력을 소중히 생각합니다.").requires == ()
|
||||
|
||||
|
||||
def test_offer_monotonic_no_regression():
|
||||
"""검증: 역행 금지(IMK 논의 — 절충 16,980 후 예산 상한 16,810 제시) 재현.
|
||||
기대결과: 직전 당사 제안보다 낮은 제안가 카드는 미발동(설득 폴백으로도 안 나감).
|
||||
직전 제안이 없으면 앵커 제시 허용, 같은 금액 재제시 허용, 더 높은 제안은 정상."""
|
||||
anchor_card = CardSpec(offer_variable="anchoring_price")
|
||||
ctx = {"round": 2, "target_price": 17_300, "anchor_price": 16_810, "input_price": 17_500}
|
||||
assert compute_offer(anchor_card, ctx) == 16_810 # 첫 카운터 전(포지션=앵커): 같은 금액 → 허용
|
||||
ctx["prev_customer_price"] = 16_980 # 절충 카드가 이미 16,980 을 부른 상태
|
||||
assert compute_offer(anchor_card, ctx) is None # 앵커 16,810 은 역행 → 미발동
|
||||
assert playable(anchor_card, ctx) is False # 멘트에 금액이 박히므로 설득 폴백도 금지
|
||||
assert compute_offer(CardSpec(offer_variable="target_price"), ctx) == 17_300 # 상향 제안은 정상
|
||||
|
||||
|
||||
def test_played_history_is_shared_by_number():
|
||||
ctx = {}
|
||||
assert is_played(ctx, "WC-05") is False
|
||||
mark_played(ctx, "WC-05")
|
||||
assert is_played(ctx, "WC-05") is True
|
||||
mark_played(ctx, "WC-05") # 재기록해도 1건 유지
|
||||
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||
mark_played(ctx, None) # no-op(폴백 최후통첩)
|
||||
assert ctx["played_card_numbers"] == ["WC-05"]
|
||||
|
||||
|
||||
def test_spec_from_context_reads_snapshot_and_falls_back_to_hold():
|
||||
ctx = {"card_specs": dict(_SPECS)}
|
||||
assert spec_from_context(ctx, "WC-05") == CardSpec(offer_variable="middle_price", min_round=1, closing=True)
|
||||
assert spec_from_context(ctx, "NGC-B003") == HOLD # 미등록 카드(데모) 폴백
|
||||
assert spec_from_context({}, "WC-05") == HOLD # 스펙 미적재(구세션·데모) 폴백
|
||||
|
||||
|
||||
# ---- ③ 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
|
||||
def test_accept_counter_settles_at_counter_price():
|
||||
eng = _engine()
|
||||
s = _session(step="가격협상_카운터", pending_counter_price=10000)
|
||||
@ -102,7 +197,7 @@ def test_wildcard_1pct_decline_keeps_original_price():
|
||||
assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용
|
||||
|
||||
|
||||
# ---- ③ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||
# ---- ④ 목표가 초과 타결 금지 가드 --------------------------------------------
|
||||
def test_success_step_guard_rejects_over_target():
|
||||
eng = _engine()
|
||||
s = _session(input_price=10800, target_price=10000)
|
||||
@ -110,20 +205,46 @@ def test_success_step_guard_rejects_over_target():
|
||||
assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제
|
||||
|
||||
|
||||
# ---- ④ 선택형 와일드카드 발동 (1.02~1.05 구간 갭 해소) -------------------------
|
||||
def test_selected_wildcard_fires_in_entry_zone():
|
||||
# ---- ⑤ 와일드 진입 — 종결 예약 + 중복 차단 (IMK BB9A 회귀) ---------------------
|
||||
def test_selected_wildcard_fires_in_entry_zone_and_records_position():
|
||||
eng = _engine()
|
||||
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + 비종결 WC-02 선택
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||
selected_wild_card_numbers=["WC-02"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "wild_card_dynamic"
|
||||
# 제안가 = (anchor 10000 + target 10100)/2 = 10050 ≤ target — 그대로 제시(클램프 없음)
|
||||
assert s.context["pending_counter_price"] == 10050
|
||||
assert s.context["prev_customer_price"] == 10050 # 갑 포지션 기록 — "당사 제안" 멘트 정합(BB9A ③)
|
||||
assert s.context["active_wild_card_number"] == "WC-02"
|
||||
assert is_played(s.context, "WC-02") # 카드 이력 기록
|
||||
# 수락 → 그 가격으로 타결
|
||||
view = eng.advance(s, "수락")
|
||||
assert view.step == "협상완료" and s.context["input_price"] == 10050
|
||||
|
||||
|
||||
def test_closing_card_is_reserved_never_fires_mid_negotiation():
|
||||
"""종결 전용 카드(WC-05)는 entry 존이라도 중반에 안 나간다 — 종결 국면의 마지막 한 방으로 예약.
|
||||
(BB9A 중복의 절반: 중반에 당겨 쓴 카드를 종결에서 또 쓰던 경로 차단.)"""
|
||||
eng = _engine()
|
||||
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + WC-05 선택
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True,
|
||||
selected_wild_card_numbers=["WC-05"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "wild_card_dynamic"
|
||||
# 카운터 = (anchor 10000 + 10300)/2 = 10150 → target(10100) 클램프
|
||||
assert s.context["pending_counter_price"] == 10100
|
||||
assert s.context["active_wild_card_number"] == "WC-05"
|
||||
# 수락 → 그 가격으로 타결
|
||||
view = eng.advance(s, "수락")
|
||||
assert view.step == "협상완료" and s.context["input_price"] == 10100
|
||||
assert view.step == "가격협상" # 종결 카드뿐 → 일반 카드 플레이로
|
||||
assert "active_wild_card_number" not in s.context
|
||||
assert not is_played(s.context, "WC-05") # 안 나갔으니 이력도 없음
|
||||
# 종결 국면에선 발동 가능 + 이력 없음 — 서비스 종결 루프가 이 카드를 쓴다
|
||||
spec = spec_from_context(s.context, "WC-05")
|
||||
assert available(spec, s.context, closing_phase=True) is True
|
||||
|
||||
|
||||
def test_played_wildcard_is_skipped_on_reentry():
|
||||
"""이미 쓴 카드는 같은 협상에서 다시 안 나간다 — 다음 후보로 넘어간다."""
|
||||
eng = _engine()
|
||||
s = _session(input_price=10300, allow_selected_wildcards=True, wildcard_used=False,
|
||||
selected_wild_card_numbers=["WC-02"], played_card_numbers=["WC-02"])
|
||||
view = eng.advance(s, "예")
|
||||
assert view.step == "가격협상" # 유일 후보가 사용됨 → 발동 없음
|
||||
|
||||
|
||||
def test_unselected_wildcard_zone_still_falls_to_nego():
|
||||
@ -186,7 +307,7 @@ def test_vars_for_supplies_tactic_variables():
|
||||
assert v1["target_mid_price"] == 10100
|
||||
|
||||
|
||||
# ---- ⑤⑥ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||
# ---- ⑥⑦ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
|
||||
from sqlalchemy import column, delete, insert, select, table # noqa: E402
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||
@ -211,24 +332,30 @@ _T_QUOTATIONS = table(
|
||||
)
|
||||
_T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card")
|
||||
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
|
||||
_T_VWC = table("version_wild_cards", column("vwc_id"), column("version_id"), column("wild_card_id"), schema="card")
|
||||
_T_WILD = table("wild_cards", column("wild_card_id"), column("number"), column("deleted"), schema="card")
|
||||
|
||||
|
||||
async def _card_uuid(number: str):
|
||||
async def _card_uuid(number: str, *, wild=False):
|
||||
tbl, pk = (_T_WILD, _T_WILD.c.wild_card_id) if wild else (_T_NEGO, _T_NEGO.c.nego_card_id)
|
||||
|
||||
def _q(s):
|
||||
return DB_SESSION_MNG.execute(
|
||||
s, select(_T_NEGO.c.nego_card_id).where(
|
||||
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
|
||||
s, select(pk).where(tbl.c.number == number, tbl.c.deleted == False).limit(1)) # noqa: E712
|
||||
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
|
||||
async def _seed_quote_session(sid, selected_numbers, wild_numbers=(), target=10000, anchor=9900):
|
||||
qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
card_ids = {}
|
||||
card_ids, wild_ids = {}, {}
|
||||
for n in selected_numbers:
|
||||
card_ids[n] = await _card_uuid(n)
|
||||
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||
for n in wild_numbers:
|
||||
wild_ids[n] = await _card_uuid(n, wild=True)
|
||||
assert wild_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
|
||||
|
||||
def _seed(s_):
|
||||
async def run(s):
|
||||
@ -243,6 +370,11 @@ async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
|
||||
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
for n in wild_numbers:
|
||||
e = await DB_SESSION_MNG.add(s, insert(_T_VWC).values(
|
||||
vwc_id=_uuid.uuid4(), version_id=ver_id, wild_card_id=wild_ids[n]))
|
||||
if e != ErrorType.SUCCESS:
|
||||
return e
|
||||
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
|
||||
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
|
||||
qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1,
|
||||
@ -260,17 +392,18 @@ async def _cleanup(sid, qid, ver_id):
|
||||
[DBType.MAIN.value],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_VWC).where(_T_VWC.c.version_id == ver_id)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||
"""견적 선택 카드 NGC-009(조건부 가격 조정 → COUNTER_TARGET)의 카운터를 수락하면
|
||||
합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||
"""견적 선택 카드 NGC-010(향후 거래 연계 — 스크립트 {target_price} 파싱 → 목표가 제안)의
|
||||
카운터를 수락하면 합의가 = 목표가(10000) — '수락 즉시 타결' 기획 결정의 E2E 검증."""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-009"])
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-010"])
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
@ -279,9 +412,9 @@ async def test_e2e_counter_accept_settles_at_target(db_engine):
|
||||
r = None
|
||||
for ui in [None, "확인", "예", "확인", "11000", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||
# 가격협상 카드 턴 → NGC-009 카운터(target) 제시 스텝
|
||||
# 가격협상 카드 턴 → NGC-010 카운터(target) 제시 스텝
|
||||
assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}"
|
||||
assert r.card_id == "NGC-009"
|
||||
assert r.card_id == "NGC-010"
|
||||
assert r.input_options == ["수락", "다른 가격 제시"]
|
||||
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||
@ -297,7 +430,7 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||
그래도 거절 → 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다."""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # HOLD 카드 1장 → 빠른 소진
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # 설득 카드 1장 → 빠른 소진
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
@ -321,3 +454,46 @@ async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
|
||||
assert r.settled_price is None # 초과가 타결 없음
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bb9a_no_duplicate_wildcard_and_real_middle(db_engine):
|
||||
"""IMK BB9A 재현 E2E — 와일드카드 2장(WC-02·WC-05) + 설득 카드 1장.
|
||||
|
||||
기대 흐름(수정 후):
|
||||
· 중반 와일드 진입 = 비종결 WC-02 (종결 전용 WC-05 는 예약 — 구현 전엔 WC-05 가 먼저 나갔다)
|
||||
· 종결 국면 = WC-05, 절충가 = (당사 직전 제안 + 협력사 제시가)/2 실계산 (구현 전엔 목표가로 클램프)
|
||||
· 같은 카드 2회 발동 없음 + 종결 발동도 card_id 기록
|
||||
"""
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"], wild_numbers=["WC-02", "WC-05"])
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
session_id = str(sid)
|
||||
r = None
|
||||
# 10300: 1pct 존(≤ 9900×1.02=10098) 밖, entry 존(≤ 10395) 안 → 선택형 와일드 발동 구간
|
||||
for ui in [None, "확인", "예", "확인", "10300", "예"]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
|
||||
assert r.step == "wild_card_dynamic"
|
||||
assert r.card_id == "WC-02" # 종결 전용 WC-05 가 아니라 비종결 카드
|
||||
# WC-02 제안가 = (anchor 9900 + target 10000)/2 = 9950
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
|
||||
# 10010 재제시 → 설득 카드(NGC-003) 1장 소진
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10010"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||
assert r.step == "가격협상" and r.card_id == "NGC-003"
|
||||
# 10005 재제시 → 카드 소진 → 종결 국면: WC-05 절충가 = (9950 + 10005)/2 = 9980 (≤ target)
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="10005"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="예"))
|
||||
assert r.step == "가격협상_카운터"
|
||||
assert r.card_id == "WC-05" # 종결 발동도 카드 기록(구현 전 null)
|
||||
assert "9980" in r.script # 실제 절충가 — 목표가(10000) 클램프 아님
|
||||
|
||||
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
|
||||
assert r.step == "협상완료"
|
||||
assert r.settled_price == 9980 # 표시가 = 타결가
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
|
||||
@ -186,11 +186,14 @@ 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):
|
||||
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전.
|
||||
# 호칭은 crud 가 어떤 회사든 '공급가'(공급사 화면 고정 용어)로 내려준다.
|
||||
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 +220,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 +245,14 @@ async def test_loader_with_crud_double(db_engine):
|
||||
assert ctx.partner_type is PartnerType.NONE
|
||||
assert ctx.selected_nego_card_numbers == ["NGC-003", "NGC-008"]
|
||||
assert ctx.selected_wild_card_numbers == ["WC-02"]
|
||||
# 카드 전술 확정 — 설득 카드/읽기 전용 변수는 제안가 없음, WC-02 는 스크립트 파싱으로 중간가.
|
||||
assert ctx.card_specs["NGC-003"]["offer_variable"] is None
|
||||
assert ctx.card_specs["NGC-008"]["offer_variable"] is None # 인터넷 최저가는 읽어주기 변수 — 제안가 아님
|
||||
# 시장가 인용 카드는 최저가 결측 세션에서 미발동하도록 requires 로 표시된다(토큰 노출 방지).
|
||||
assert ctx.card_specs["NGC-008"]["requires"] == ["internet_lowest_price"]
|
||||
assert ctx.card_specs["WC-02"] == {
|
||||
"offer_variable": "target_mid_price", "min_round": 1, "closing": False, "requires": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -49,9 +49,14 @@ def test_wildcard_threshold_is_config_driven():
|
||||
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
|
||||
view = _engine().advance(_session(10800), "예")
|
||||
assert view.step == "가격협상"
|
||||
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동.
|
||||
# 1%가(10800×0.99=10692)도 제안가 공통 유효조건(≤목표가)을 타므로 목표가를 그 위로 둔다 —
|
||||
# 기본 target(10100)이면 초과 제시 금지 규칙에 걸려 발동하지 않는 게 새 정답.
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800, target_price=11000), "예")
|
||||
assert view.step == "wild_card_1pct"
|
||||
# 목표가가 1%가 아래면(초과 제시 금지) 완화 임계라도 미발동 — 수락해도 결렬되는 모순 제안 차단.
|
||||
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
|
||||
assert view.step == "가격협상"
|
||||
|
||||
|
||||
def test_max_counter_rounds_is_config_driven():
|
||||
|
||||
190
agent/tests/test_negotiation_invariants.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""협상 불변식 시나리오 하네스 — 실서비스 스택(ChatService + 실 DB 카드)으로 13개 협상을 완주시키고,
|
||||
IMK 가 잡은 두 부류의 사고(같은 카드 반복 · 이상한 금액)가 어떤 흐름에서도 안 나는지 검사한다.
|
||||
|
||||
시나리오별 기대 이벤트(카드가 나간 턴의 step·카드·금액)를 정확히 못박고, 공통 불변식을 전 턴에 건다:
|
||||
· 카드 중복 없음 — 한 협상에서 같은 card_id 2회 발동 금지
|
||||
· 카드 자리 규칙 — 종결 전용(WC-03·05)은 가격협상_카운터에서만, 비종결 와일드는 wild_card_dynamic 에서만
|
||||
· 타결가 ≤ 목표가 — 어떤 성공 경로도 목표가 초과로 안 끝남
|
||||
· 카운터 멘트의 금액 = 수락 시 타결가 (표시가=타결가)
|
||||
"""
|
||||
|
||||
import uuid as _uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from router.v1.chat.protocol import Req_Chat
|
||||
from services.chat_service import ChatService, reset_sessions
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
from tenancy.registry import TenantEngineRegistry
|
||||
from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session
|
||||
|
||||
# 종결 전용 와일드카드(DB tactic 시드와 동일) — 자리 규칙 검사용.
|
||||
_CLOSING_WILDS = {"WC-03", "WC-05"}
|
||||
_NONCLOSING_WILDS = {"WC-01", "WC-02", "WC-04"}
|
||||
# 카드가 나갈 수 있는 스텝(이벤트로 수집).
|
||||
_CARD_STEPS = {"가격협상", "wild_card_dynamic", "wild_card_1pct", "가격협상_카운터"}
|
||||
_BOILERPLATE = [None, "확인", "예", "확인"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
name: str
|
||||
inputs: list # 서두(안내~기존가격제시) 이후의 협력사 입력 시퀀스
|
||||
# 기대 이벤트: (step, card, offer_substring). card="NGC-*" 는 임의 협상카드(중복만 검사).
|
||||
events: list
|
||||
settled: Optional[int] # 기대 타결가(원). None=결렬
|
||||
nego: list = field(default_factory=lambda: ["NGC-001"])
|
||||
wild: list = field(default_factory=list)
|
||||
target: int = 10_000
|
||||
anchor: int = 9_900
|
||||
|
||||
|
||||
# 밴드(기본 target 10000·anchor 9900): 1% 존 ≤ 10,098 · 진입 존 ≤ 10,395.
|
||||
SCENARIOS = [
|
||||
# S01 BB9A 재현 — 중반 비종결 WC-02, 종결 WC-05 실절충가. 같은 카드 2회 없음.
|
||||
Scenario("S01_bb9a_mid_wc02_close_wc05",
|
||||
["10300", "예", "다른 가격 제시", "10010", "예", "10005", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-05", "9980")],
|
||||
settled=9980, wild=["WC-02", "WC-05"]),
|
||||
# S02 8AB0 재현 — 절충가(9,205)가 목표가(9,000) 초과 → WC-05 미발동, 목표가 최후통첩(카드 없음).
|
||||
Scenario("S02_8ab0_middle_over_target_skips",
|
||||
["9500", "예", "9500", "예", "다른 가격 제시", "9500", "예", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", None, "9000")],
|
||||
settled=None, wild=["WC-05"], target=9_000, anchor=8_910),
|
||||
# S03 와일드 5장 전부 + 협상카드 2장 — 중반 1장(WC-01)·종결 1장(WC-03)만, 협상카드는 서로 다른 2장.
|
||||
Scenario("S03_five_wilds_full_run",
|
||||
["10300", "예", "다른 가격 제시", "10200", "예", "10150", "예", "10100", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-01", "10000"),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=10_000, nego=["NGC-001", "NGC-003"],
|
||||
wild=["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"]),
|
||||
# S04 종결 전용 와일드만 담김 + 제시가가 진입 존에 머무름 — 소진 판정이 막히지 않고
|
||||
# 종결로 넘어간다(프로브 픽스 회귀: 픽스 전엔 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상).
|
||||
Scenario("S04_closing_only_wild_no_deadlock",
|
||||
["10300", "예", "10250", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", None, "10000")], # WC-05 절충 10,075>목표가 → 미발동 → 최후통첩
|
||||
settled=10_000, wild=["WC-05"]),
|
||||
# S05 1% 존 — 시스템 1% 카드, 수락 시 표시 금액 그대로 타결.
|
||||
Scenario("S05_one_pct_zone_accept",
|
||||
["10050", "예", "예", "확인"],
|
||||
[("wild_card_1pct", None, "9950")],
|
||||
settled=9_950),
|
||||
# S06 앵커 이하 즉시 타결 — 카드 0장.
|
||||
Scenario("S06_priority_match_no_cards",
|
||||
["9800", "예", "확인"],
|
||||
[],
|
||||
settled=9_800),
|
||||
# S07 목표가 초과 고수 → 설득 1장 → 최후통첩 → 결렬.
|
||||
Scenario("S07_hold_high_fails",
|
||||
["11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||
[("가격협상", "NGC-003", None),
|
||||
("가격협상_카운터", None, "10000")],
|
||||
settled=None, nego=["NGC-003"]),
|
||||
# S08 협상카드 카운터(NGC-010 목표가 제안) 수락 — 협상카드도 카운터 스텝을 쓴다.
|
||||
Scenario("S08_nego_counter_accept",
|
||||
["11000", "예", "수락", "확인"],
|
||||
[("가격협상_카운터", "NGC-010", "10000")],
|
||||
settled=10_000, nego=["NGC-010"]),
|
||||
# S09 min_round=2 — WC-04 는 1라운드 진입 존에서 안 나가고 2라운드에 나간다.
|
||||
Scenario("S09_min_round_two_defers_wc04",
|
||||
["10300", "예", "10200", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("wild_card_dynamic", "WC-04", "10000")],
|
||||
settled=10_000, wild=["WC-04"]),
|
||||
# S10 종결 체인 폴백 — WC-05 무효(절충 10,175>목표) → 다음 종결 WC-03 발동.
|
||||
Scenario("S10_closing_chain_falls_to_wc03",
|
||||
["10500", "예", "10450", "예", "다른 가격 제시", "10450", "예", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=None, wild=["WC-05", "WC-03"]),
|
||||
# S11 중반+종결 콤보 — WC-02 중반, 종결은 WC-05 무효 건너뛰고 WC-03. 전 카드 1회씩.
|
||||
Scenario("S11_mid_and_closing_combo",
|
||||
["10300", "예", "다른 가격 제시", "10400", "예", "10350", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")],
|
||||
settled=10_000, wild=["WC-02", "WC-05", "WC-03"]),
|
||||
# S12 라운드 상한 — 협상카드 3장 각 1회(중복 없음) 후 상한 도달 → 최후통첩 → 결렬.
|
||||
Scenario("S12_round_cap_distinct_nego_cards",
|
||||
["11000", "예", "11000", "예", "11000", "예", "11000", "예", "다른 가격 제시", "11000", "예", "확인"],
|
||||
[("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상", "NGC-*", None),
|
||||
("가격협상_카운터", None, "10000")],
|
||||
settled=None, nego=["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005"]),
|
||||
# S13 재생성 아님·재료 극단 — 앵커 미박제 세션(anchor=target 폴백)에서도 초과 제시·중복 없음.
|
||||
Scenario("S13_anchor_equals_target_fallback",
|
||||
["10300", "예", "10200", "예", "수락", "확인"],
|
||||
[("가격협상", "NGC-001", None),
|
||||
("가격협상_카운터", "WC-03", "10000")], # WC-05 절충 (10000+10200)/2=10100>목표 → 스킵
|
||||
settled=10_000, wild=["WC-05", "WC-03"], anchor=10_000),
|
||||
# S14 역행 금지(IMK 논의 재현) — 절충 카드(9,950) 뒤에 예산 상한 카드(NGC-007, 앵커 9,900)가
|
||||
# 선택돼 있어도 발동하지 않는다(설득 폴백으로도 안 나감). 낼 카드가 없어져 종결(목표가 최후통첩)로.
|
||||
Scenario("S14_no_offer_regression",
|
||||
["10300", "예", "다른 가격 제시", "10200", "예", "수락", "확인"],
|
||||
[("wild_card_dynamic", "WC-02", "9950"),
|
||||
("가격협상_카운터", None, "10000")], # NGC-007 이벤트가 없어야 함(역행 차단)
|
||||
settled=10_000, nego=["NGC-007"], wild=["WC-02"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sc", SCENARIOS, ids=[s.name for s in SCENARIOS])
|
||||
async def test_negotiation_invariants(db_engine, sc: Scenario):
|
||||
reset_sessions()
|
||||
sid = _uuid.uuid4()
|
||||
qid, ver_id = await _seed_quote_session(sid, sc.nego, wild_numbers=sc.wild,
|
||||
target=sc.target, anchor=sc.anchor)
|
||||
try:
|
||||
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
||||
eng = await reg.get_engine(str(_uuid.uuid4()))
|
||||
svc = ChatService()
|
||||
trace, settled, outcome = [], None, None
|
||||
for ui in [*_BOILERPLATE, *sc.inputs]:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui))
|
||||
assert r.result.success is True, f"{sc.name}: 턴 실패 input={ui} msg={r.msg}"
|
||||
trace.append(r)
|
||||
if r.settled_price is not None:
|
||||
settled = r.settled_price
|
||||
if r.chat_end:
|
||||
outcome = r.outcome
|
||||
|
||||
# ── 기대 이벤트(카드/카운터 턴) 정확 일치 ──
|
||||
events = [r for r in trace if r.step in _CARD_STEPS]
|
||||
got = [(r.step, r.card_id) for r in events]
|
||||
assert len(events) == len(sc.events), f"{sc.name}: 이벤트 수 {got} ≠ 기대 {sc.events}"
|
||||
for r, (step, card, offer) in zip(events, sc.events):
|
||||
assert r.step == step, f"{sc.name}: step {r.step} ≠ {step} (전체 {got})"
|
||||
if card == "NGC-*":
|
||||
assert r.card_id and r.card_id.startswith("NGC-"), f"{sc.name}: 협상카드 기대, 실제 {r.card_id}"
|
||||
else:
|
||||
assert r.card_id == card, f"{sc.name}: card {r.card_id} ≠ {card} (전체 {got})"
|
||||
if offer is not None:
|
||||
assert offer in (r.script or ""), f"{sc.name}: 멘트에 금액 {offer} 없음 — {r.script[:80]}"
|
||||
|
||||
# ── 공통 불변식 ──
|
||||
played = [r.card_id for r in events if r.card_id]
|
||||
assert len(played) == len(set(played)), f"{sc.name}: 카드 중복 발동 {played}"
|
||||
for r in events:
|
||||
if r.card_id in _CLOSING_WILDS:
|
||||
assert r.step == "가격협상_카운터", f"{sc.name}: 종결 카드 {r.card_id}가 중반({r.step})에 발동"
|
||||
if r.card_id in _NONCLOSING_WILDS:
|
||||
assert r.step == "wild_card_dynamic", f"{sc.name}: 비종결 와일드 {r.card_id}가 {r.step}에서 발동"
|
||||
|
||||
# ── 결말 ──
|
||||
if sc.settled is None:
|
||||
assert outcome == "failure" and settled is None, f"{sc.name}: 결렬 기대, settled={settled} outcome={outcome}"
|
||||
else:
|
||||
assert outcome == "success", f"{sc.name}: 타결 기대, outcome={outcome}"
|
||||
assert settled == sc.settled, f"{sc.name}: 타결가 {settled} ≠ 기대 {sc.settled}"
|
||||
assert settled <= sc.target, f"{sc.name}: 목표가 초과 타결 {settled} > {sc.target}"
|
||||
finally:
|
||||
await _cleanup(sid, qid, ver_id)
|
||||
@ -35,7 +35,7 @@ async def test_two_tenants_distinct_engines():
|
||||
assert e1.mapper.get_card_id(0) == "NGC-001"
|
||||
assert e2.mapper.get_card_id(0) == "NGC-B001"
|
||||
# 차원
|
||||
assert e1.state_space_size == 162 and e1.action_space_size == 11
|
||||
assert e1.state_space_size == 162 and e1.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -69,7 +69,7 @@ async def test_unregistered_company_id_auto_onboards():
|
||||
reg = _registry()
|
||||
# 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state).
|
||||
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
|
||||
assert eng.action_space_size == 11 and eng.state_space_size == 162
|
||||
assert eng.action_space_size == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
|
||||
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
||||
assert reg.is_registered("imarketkorea") is True
|
||||
# 빈 키만 미등록 → KeyError
|
||||
|
||||
@ -69,27 +69,27 @@ async def test_cold_start_creates_warmstart_version(db_engine):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
|
||||
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||
"""카탈로그 카드 수 변경(7→9) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
||||
import uuid as _uuid
|
||||
cid = str(_uuid.uuid4())
|
||||
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (5,2)=0.9
|
||||
# 이 회사 활성 버전을 A=7 로 시드 + 셀 (5,2)=0.9
|
||||
repo = LearningRepository(cid)
|
||||
vid = await repo.get_or_create_active_version(
|
||||
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95,
|
||||
scope=2, version_name="old_v9")
|
||||
state_space_size=162, action_space_size=7, learning_rate=0.1, discount_factor=0.95,
|
||||
scope=2, version_name="old_v7")
|
||||
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
|
||||
|
||||
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션
|
||||
# 엔진(_base type:db → 카탈로그 9장) 로드 → 7≠9 감지 → 마이그레이션
|
||||
eng = await _reg().get_engine(cid)
|
||||
assert eng.action_space_size == 11
|
||||
assert eng.action_space_size == 9
|
||||
policy, new_vid, _ = await QTablePolicyStore.load(eng)
|
||||
assert str(new_vid) != str(vid) # 새 버전
|
||||
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
|
||||
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) fresh
|
||||
assert policy.qtable.q[5, 8] == 0.0 # 새 카드(action 8) fresh
|
||||
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
|
||||
# 새 버전이 활성 · 차원 11
|
||||
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
|
||||
assert str(active.version_id) == str(new_vid) and active.action_space_size == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -42,7 +42,10 @@ def test_requote_structure_preserved():
|
||||
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
|
||||
assert key in s
|
||||
assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
|
||||
assert s["배송형태선택"]["input_options"] == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
# 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
|
||||
assert s["배송형태선택"]["input_options"] == [
|
||||
"{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}",
|
||||
]
|
||||
|
||||
|
||||
def test_wildcard_present_and_merged():
|
||||
@ -170,3 +173,24 @@ async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
||||
|
||||
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
|
||||
assert out == "선택 카드 DB 멘트 **10200원**"
|
||||
|
||||
|
||||
def test_option_label_tokens_rendered():
|
||||
"""검증: 옵션에 회사 용어 토큰({label_delivery_type_*})이 있는 스텝을 정상 렌더·에러 재렌더로 출력.
|
||||
기대결과: 두 경로 모두 버튼 문자열이 기본 라벨(협력사배송 등)로 치환되고 토큰이 남지 않는다."""
|
||||
import os
|
||||
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
||||
from tenancy.config_loader import TenantConfigLoader
|
||||
|
||||
tenants = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||||
cfg = TenantConfigLoader(tenants_dir=tenants, cache_ttl_seconds=0).load("_base")
|
||||
engine = ChatEngine(ScriptRepository(cfg, tenants), rq_type="재견적")
|
||||
session = ChatSession(session_id="s", tenant_id="_base", company_id="_base")
|
||||
|
||||
view = engine.render_step(session, "배송형태선택")
|
||||
assert view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
|
||||
# 에러 재렌더(잘못된 입력 등)도 같은 치환을 타야 한다 — raw 옵션이면 토큰이 버튼에 노출된다.
|
||||
err_view = engine._error(session, "다시 선택해 주세요.")
|
||||
assert err_view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
||||
|
||||
@ -1,18 +1,33 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import case, cast, func, nulls_last, or_, select, text, update
|
||||
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items, quotations, sessions
|
||||
from common.database.model.models import chats, items, quotations, sessions
|
||||
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
||||
from common.logger import LOG
|
||||
|
||||
|
||||
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
||||
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
||||
|
||||
|
||||
def _effective_status():
|
||||
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
|
||||
|
||||
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
|
||||
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
|
||||
"""
|
||||
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
|
||||
return case(
|
||||
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
|
||||
else_=sessions.status,
|
||||
)
|
||||
|
||||
|
||||
class ISessionCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
||||
@ -39,7 +54,9 @@ class ISessionCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||
async def update_session_reject(
|
||||
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
||||
) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -47,19 +64,6 @@ class ISessionCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
||||
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
|
||||
try:
|
||||
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, 0
|
||||
top = rows[0][0] if rows and rows[0] else None
|
||||
return ErrorType.SUCCESS, int(top or 0)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@ -73,7 +77,8 @@ class SessionCRUD(ISessionCRUD):
|
||||
def __filters(supplier_id, status, qt_type, keyword=None, result=None):
|
||||
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
||||
if status is not None:
|
||||
conds.append(sessions.status == status)
|
||||
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
|
||||
conds.append(_effective_status() == status)
|
||||
if qt_type is not None:
|
||||
conds.append(sessions.qt_type == qt_type)
|
||||
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
|
||||
@ -113,7 +118,7 @@ class SessionCRUD(ISessionCRUD):
|
||||
else:
|
||||
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
||||
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
||||
actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
||||
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
||||
status_rank = case((actionable, 0), else_=1)
|
||||
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
||||
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
||||
@ -122,7 +127,7 @@ class SessionCRUD(ISessionCRUD):
|
||||
query = (
|
||||
select(
|
||||
sessions.session_id,
|
||||
sessions.status,
|
||||
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
|
||||
sessions.qt_type,
|
||||
sessions.qt_number,
|
||||
quotations.end_time, # qt_end_time = 견적 마감 시각
|
||||
@ -136,6 +141,11 @@ class SessionCRUD(ISessionCRUD):
|
||||
quotations.round,
|
||||
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
|
||||
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
||||
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
|
||||
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
|
||||
# 거부 건이 제출한 사유·희망가 — 목록의 '거부 내역' 열람용(의견은 custom.opinion).
|
||||
sessions.reject_reason,
|
||||
sessions.reject_price,
|
||||
)
|
||||
.join(items, items.item_id == sessions.item_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
@ -212,12 +222,18 @@ class SessionCRUD(ISessionCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
|
||||
async def update_session_reject(
|
||||
self, cdb: AsyncSession, session_id, status: int, reject_reason: str, reject_price: Optional[int] = None,
|
||||
) -> ErrorType:
|
||||
try:
|
||||
values = {"status": status, "reject_reason": reject_reason}
|
||||
# 공급 희망 가격은 선택 입력이라 안 들어올 수 있다 — 그때는 컬럼을 건드리지 않는다.
|
||||
if reject_price is not None:
|
||||
values["reject_price"] = reject_price
|
||||
query = (
|
||||
update(sessions)
|
||||
.where(sessions.session_id == session_id)
|
||||
.values(status=status, reject_reason=reject_reason)
|
||||
.values(**values)
|
||||
)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
@ -231,7 +247,8 @@ class SessionCRUD(ISessionCRUD):
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, 0
|
||||
top = rows[0][0] if rows and rows[0] else None
|
||||
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님).
|
||||
top = rows[0] if rows else None
|
||||
return ErrorType.SUCCESS, int(top or 0)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -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):
|
||||
@ -71,4 +72,4 @@ class Res_HidePopup(Res_WebPacketProtocol):
|
||||
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). 한 줄 = 담당자 한 명")
|
||||
|
||||
@ -79,6 +79,8 @@ class Res_ChatInit(Res_WebPacketProtocol):
|
||||
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
|
||||
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
|
||||
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
|
||||
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
|
||||
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
|
||||
labels: dict = Field(default_factory=dict, description="회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명(예: lead_time) 치환용. 없으면 프론트 기본값")
|
||||
|
||||
|
||||
|
||||
@ -21,6 +21,9 @@ class ListItem(WebPacketProtocol):
|
||||
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
|
||||
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
||||
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
|
||||
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
|
||||
reject_reason: str = Field("", description="협상 거부 시 제출한 사유. 거부 건이 아니면 빈 문자열")
|
||||
reject_price: Optional[int] = Field(None, description="협상 거부 시 함께 낸 공급 희망 가격(원). 미입력이면 null")
|
||||
|
||||
|
||||
class Res_SessionList(Res_WebPacketProtocol):
|
||||
@ -36,6 +39,8 @@ class Res_Participate(Res_WebPacketProtocol):
|
||||
|
||||
class Req_Reject(WebPacketProtocol):
|
||||
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
||||
reject_price: Optional[int] = Field(None, description="공급 희망 가격(원). 선택 입력 — 없으면 컬럼 미변경")
|
||||
opinion: Optional[str] = Field(None, max_length=255, description="추가 의견 — sessions.custom.opinion 에 병합")
|
||||
|
||||
|
||||
class Res_Reject(Res_WebPacketProtocol):
|
||||
|
||||
@ -62,7 +62,7 @@ async def participate(
|
||||
path="/sessions/{session_id}/reject",
|
||||
response_model=Res_Reject,
|
||||
summary="협상 거부",
|
||||
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
|
||||
description="세션 참여를 거부하거나 진행 중인 협상을 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유·공급 희망 가격·의견을 저장.",
|
||||
)
|
||||
async def reject(
|
||||
session_id: str = Path(description="대상 협상 세션 uuid"),
|
||||
@ -71,7 +71,11 @@ async def reject(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
service: NegotiationService = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
|
||||
return RemoveNoneResponse(
|
||||
await service.reject(
|
||||
user_info, credentials.credentials, session_id, req.reject_reason, req.reject_price, req.opinion,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@ -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:
|
||||
@ -278,7 +279,7 @@ class AuthService:
|
||||
branding = branding or {}
|
||||
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:
|
||||
|
||||
@ -71,6 +71,14 @@ class ChatService:
|
||||
opinion = None
|
||||
if ", 의견-" in s:
|
||||
s, opinion = s.split(", 의견-", 1)
|
||||
# 폼이 아닌 자유 입력("협상 포기합니다" 등)은 원문이 곧 사유다. 폼 마커가 없으면 가격도 읽지 않는다
|
||||
# — 문장에 섞인 숫자를 희망가로 오인해 저장하는 것을 막는다.
|
||||
if "합의불가사유-" not in s and "공급희망가격-" not in s:
|
||||
return {
|
||||
"offer_price": None,
|
||||
"reason": s.strip()[:255] or None,
|
||||
"opinion": (opinion.strip() or None) if opinion is not None else None,
|
||||
}
|
||||
reason = None
|
||||
if ", 합의불가사유-" in s:
|
||||
price_part, reason = s.split(", 합의불가사유-", 1)
|
||||
@ -227,11 +235,8 @@ class ChatService:
|
||||
)
|
||||
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
||||
|
||||
# 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙).
|
||||
# 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다.
|
||||
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||
return res
|
||||
# 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건).
|
||||
# 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다.
|
||||
|
||||
await self._ensure_in_progress(sess, quote)
|
||||
|
||||
@ -254,6 +259,9 @@ class ChatService:
|
||||
res.item_vat_yn = item.vat_yn
|
||||
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||
res.custom = sess.custom or {}
|
||||
# 거부로 끝난 세션은 대화에 남지 않는 제출 내역(사유·희망가)을 열람용으로 함께 내린다.
|
||||
res.reject_reason = sess.reject_reason or ""
|
||||
res.reject_price = sess.reject_price
|
||||
|
||||
# 회사 커스텀 라벨(companies.settings.labels) — 상품 상세 필드명 치환용(예: lead_time→표준납기). 실패해도 빈 dict 폴백.
|
||||
_e, settings = await DB_SESSION_MNG.execute_lambda(
|
||||
@ -262,10 +270,24 @@ class ChatService:
|
||||
)
|
||||
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||
|
||||
# 회사가 VAT(vat_yn)를 관리하지 않으면(hidden_fields) 협상 화면 VAT 표기를 숨긴다(값 null → 프론트 라벨 생략).
|
||||
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
|
||||
if "vat_yn" in _hidden:
|
||||
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||
|
||||
# VAT 표기 — 부가세 전체 통일 회사(features.vat_mode)는 상품 잔존값과 무관하게 'VAT 별도' 고정(False).
|
||||
# 상품별 관리 회사가 vat_yn 을 숨겼으면(구 방식) 표기 자체를 생략한다(값 null → 프론트 라벨 생략).
|
||||
if _features.get("vat_mode") == "unified_excluded":
|
||||
res.item_vat_yn = False
|
||||
elif "vat_yn" in _hidden:
|
||||
res.item_vat_yn = None
|
||||
|
||||
# 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
|
||||
# agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
|
||||
_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 +474,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 +648,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:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
@ -182,6 +183,9 @@ class NegotiationService:
|
||||
renegotiation_status=status,
|
||||
renegotiation_memo=renego.get("memo") or "",
|
||||
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
||||
has_chat=bool(r[15]),
|
||||
reject_reason=r[16] or "",
|
||||
reject_price=r[17],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@ -434,7 +438,10 @@ class NegotiationService:
|
||||
res.session_id = str(sess.session_id)
|
||||
return res
|
||||
|
||||
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject:
|
||||
async def reject(
|
||||
self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str,
|
||||
reject_price: Optional[int] = None, opinion: Optional[str] = None,
|
||||
) -> Res_Reject:
|
||||
res = Res_Reject()
|
||||
|
||||
# 거부 사유 필수
|
||||
@ -454,11 +461,19 @@ class NegotiationService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[sessions.DBType()],
|
||||
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
|
||||
)
|
||||
# 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장.
|
||||
# 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리.
|
||||
funcs = [
|
||||
lambda s: self.session_crud.update_session_reject(
|
||||
s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price,
|
||||
)
|
||||
]
|
||||
note = (opinion or "").strip()[:255]
|
||||
if note:
|
||||
funcs.append(
|
||||
lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note})
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
@ -220,12 +220,58 @@ async def test_chat_init_returns_meta(client, chat_seed):
|
||||
assert body["quotation_end_time"] # 타이머용 마감 시각
|
||||
|
||||
|
||||
async def test_chat_init_returns_reject_detail(client, chat_seed, db_engine):
|
||||
"""검증: 협상 거부로 끝난 세션에 재진입('결과 보기')했을 때의 init 응답.
|
||||
기대결과: 대화에 남지 않는 제출 내역(reject_reason·reject_price)이 실려 열람 카드를 그릴 수 있다."""
|
||||
token = await _login_token(client)
|
||||
sid = chat_seed["sids"]["P"]
|
||||
await client.post(
|
||||
f"/v1/negotiation/sessions/{sid}/reject",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"reject_reason": "단종", "reject_price": 91000, "opinion": "후속 모델로 제안 가능합니다"},
|
||||
)
|
||||
body = (await _init(client, token, sid)).json()
|
||||
assert body["session_status"] == 5
|
||||
assert body["reject_reason"] == "단종"
|
||||
assert body["reject_price"] == 91000
|
||||
assert body["custom"]["opinion"] == "후속 모델로 제안 가능합니다"
|
||||
|
||||
|
||||
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
||||
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||
|
||||
|
||||
async def test_chat_init_vat_mode_unified_shows_excluded(client, chat_seed, db_engine):
|
||||
"""검증: 부가세 전체 통일 회사(features.vat_mode=unified_excluded)의 세션 채팅 init.
|
||||
기대결과: 상품에 vat_yn=true 잔존값이 있어도 item_vat_yn=False — 프론트가 'VAT별도'로 고정 표기."""
|
||||
import json
|
||||
|
||||
company_id = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO company.companies (company_id, name, status, settings) VALUES (:c, :n, 1, CAST(:s AS JSONB))"),
|
||||
{"c": company_id, "n": f"{MARK}VAT통일사", "s": json.dumps({"features": {"vat_mode": "unified_excluded"}})},
|
||||
)
|
||||
await conn.execute(
|
||||
text("UPDATE partner.suppliers SET company_id = :c WHERE supplier_id = :sid"),
|
||||
{"c": company_id, "sid": chat_seed["supplier_id"]},
|
||||
)
|
||||
await conn.execute(
|
||||
text("UPDATE partner.items SET vat_yn = true WHERE item_id = (SELECT item_id FROM negotiation.sessions WHERE session_id = :s)"),
|
||||
{"s": chat_seed["sids"]["P"]},
|
||||
)
|
||||
try:
|
||||
token = await _login_token(client)
|
||||
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["item_vat_yn"] is False
|
||||
finally:
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("DELETE FROM company.companies WHERE company_id = :c"), {"c": company_id})
|
||||
|
||||
|
||||
# ---- messages (오프닝 seed) -------------------------------------------------
|
||||
async def test_messages_seeds_opening(client, chat_seed):
|
||||
token = await _login_token(client)
|
||||
@ -351,23 +397,28 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine)
|
||||
|
||||
|
||||
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
||||
"""검증: 마감시간이 지난 협상생성 세션으로 채팅 진입.
|
||||
기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다."""
|
||||
token = await _login_token(client)
|
||||
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
||||
body = (await _init(client, token, sid)).json()
|
||||
# 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다.
|
||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
||||
assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨
|
||||
assert body["result"]["success"] is True
|
||||
assert body["session_status"] == 4
|
||||
assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이
|
||||
|
||||
|
||||
async def test_init_blocks_rejected_session(client, chat_seed, db_engine):
|
||||
async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine):
|
||||
"""검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입.
|
||||
기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다."""
|
||||
token = await _login_token(client)
|
||||
sid = chat_seed["sids"]["P"]
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
|
||||
body = (await _init(client, token, sid)).json()
|
||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단
|
||||
assert body["result"]["success"] is True and body["session_status"] == 5
|
||||
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
|
||||
|
||||
|
||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||
|
||||
@ -16,6 +16,35 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
||||
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
||||
|
||||
|
||||
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
|
||||
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
|
||||
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
|
||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
||||
),
|
||||
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
||||
),
|
||||
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
||||
),
|
||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||
)
|
||||
return session_id, qt_id
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def nego_seed(db_engine):
|
||||
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
||||
@ -33,6 +62,11 @@ async def nego_seed(db_engine):
|
||||
sids, qids = {}, {}
|
||||
|
||||
async def _cleanup(conn):
|
||||
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
|
||||
await conn.execute(text(
|
||||
f"DELETE FROM negotiation.chats WHERE session_id IN "
|
||||
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
|
||||
))
|
||||
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||
@ -52,31 +86,9 @@ async def nego_seed(db_engine):
|
||||
),
|
||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||
)
|
||||
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
sids[code], qids[code] = session_id, qt_id
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
||||
),
|
||||
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
||||
),
|
||||
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
||||
),
|
||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||
)
|
||||
for spec in specs:
|
||||
code = spec[0]
|
||||
sids[code], qids[code] = await _seed_case(conn, *spec)
|
||||
|
||||
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
||||
|
||||
@ -141,6 +153,63 @@ async def test_list_filter_status(client, nego_seed):
|
||||
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
||||
|
||||
|
||||
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
|
||||
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
|
||||
async with db_engine.begin() as conn:
|
||||
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
|
||||
token = await _login_token(client)
|
||||
|
||||
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
|
||||
assert listed["session_status"] == 4
|
||||
|
||||
waiting = (await _list(client, token, status=1)).json()
|
||||
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
|
||||
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
|
||||
|
||||
|
||||
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
|
||||
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
|
||||
async with db_engine.begin() as conn:
|
||||
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
|
||||
token = await _login_token(client)
|
||||
|
||||
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
|
||||
assert listed["session_status"] == 4
|
||||
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
|
||||
|
||||
|
||||
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
|
||||
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
|
||||
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
|
||||
async with db_engine.begin() as conn:
|
||||
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
|
||||
await conn.execute(text(
|
||||
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
|
||||
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
|
||||
await conn.execute(text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
|
||||
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
|
||||
token = await _login_token(client)
|
||||
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
|
||||
assert listed["result"] == 3 and listed["renegotiable"] is False
|
||||
|
||||
|
||||
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
|
||||
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
|
||||
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
|
||||
{"sid": nego_seed["sids"]["C"]},
|
||||
)
|
||||
token = await _login_token(client)
|
||||
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
|
||||
assert by_code[f"{MARK}C"] is True
|
||||
assert by_code[f"{MARK}A"] is False
|
||||
|
||||
|
||||
async def test_list_filter_qt_type(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _list(client, token, qt_type=2)).json()
|
||||
@ -316,6 +385,65 @@ async def test_reject_success(client, nego_seed, db_engine):
|
||||
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
||||
|
||||
|
||||
async def test_reject_with_price_and_opinion(client, nego_seed, db_engine):
|
||||
# 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다.
|
||||
token = await _login_token(client)
|
||||
sid = nego_seed["sids"]["B"]
|
||||
r = await client.post(
|
||||
f"/v1/negotiation/sessions/{sid}/reject",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"},
|
||||
)
|
||||
assert r.json()["result"]["success"] is True
|
||||
async with db_engine.begin() as conn:
|
||||
row = (await conn.execute(
|
||||
text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||
{"sid": sid},
|
||||
)).first()
|
||||
assert row.status == 5 and row.reject_reason == "품절"
|
||||
assert row.reject_price == 88000
|
||||
assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다"
|
||||
|
||||
|
||||
async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
|
||||
# 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다.
|
||||
token = await _login_token(client)
|
||||
sid = nego_seed["sids"]["B"]
|
||||
r = await _reject(client, token, sid, "단종")
|
||||
assert r.json()["result"]["success"] is True
|
||||
async with db_engine.begin() as conn:
|
||||
row = (await conn.execute(
|
||||
text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
||||
{"sid": sid},
|
||||
)).first()
|
||||
assert row.reject_price is None and row.custom is None
|
||||
|
||||
|
||||
async def test_list_returns_reject_detail(client, nego_seed):
|
||||
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
|
||||
token = await _login_token(client)
|
||||
sid = nego_seed["sids"]["B"]
|
||||
await client.post(
|
||||
f"/v1/negotiation/sessions/{sid}/reject",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
|
||||
)
|
||||
items = (await _list(client, token)).json()["items"]
|
||||
row = next(i for i in items if i["session_id"] == str(sid))
|
||||
assert row["reject_reason"] == "품절"
|
||||
assert row["reject_price"] == 77000
|
||||
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
|
||||
|
||||
|
||||
async def test_list_reject_detail_empty_for_active(client, nego_seed):
|
||||
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
|
||||
token = await _login_token(client)
|
||||
items = (await _list(client, token)).json()["items"]
|
||||
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
|
||||
assert row["reject_reason"] == ""
|
||||
assert row.get("reject_price") is None
|
||||
|
||||
|
||||
async def test_reject_empty_reason(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
||||
|
||||
BIN
dev-settings.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/AIO2O-요청사항-반영보고서.pdf
Normal file
157
docs/aio2o-request-implementation-report.swift
Normal file
@ -0,0 +1,157 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let output = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "docs/AIO2O-요청사항-반영보고서.pdf"
|
||||
let W: CGFloat = 595, H: CGFloat = 842, M: CGFloat = 42
|
||||
let navy = NSColor(calibratedRed: 0.06, green: 0.08, blue: 0.16, alpha: 1)
|
||||
let ink = NSColor(calibratedRed: 0.11, green: 0.13, blue: 0.18, alpha: 1)
|
||||
let muted = NSColor(calibratedRed: 0.39, green: 0.43, blue: 0.50, alpha: 1)
|
||||
let paper = NSColor(calibratedRed: 0.98, green: 0.985, blue: 0.995, alpha: 1)
|
||||
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
|
||||
let purple = NSColor(calibratedRed: 0.48, green: 0.25, blue: 0.92, alpha: 1)
|
||||
let green = NSColor(calibratedRed: 0.08, green: 0.60, blue: 0.37, alpha: 1)
|
||||
let orange = NSColor(calibratedRed: 0.94, green: 0.48, blue: 0.10, alpha: 1)
|
||||
let red = NSColor(calibratedRed: 0.85, green: 0.24, blue: 0.28, alpha: 1)
|
||||
let blue = NSColor(calibratedRed: 0.13, green: 0.39, blue: 0.92, alpha: 1)
|
||||
|
||||
func pr(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: H-r.maxY, width: r.width, height: r.height) }
|
||||
func font(_ s: CGFloat, _ w: NSFont.Weight = .regular) -> NSFont {
|
||||
NSFont(name: "Apple SD Gothic Neo", size: s) ?? .systemFont(ofSize: s, weight: w)
|
||||
}
|
||||
func style(_ s: CGFloat, _ c: NSColor = ink, _ w: NSFont.Weight = .regular,
|
||||
_ a: NSTextAlignment = .left, _ spacing: CGFloat = 2.5) -> [NSAttributedString.Key:Any] {
|
||||
let p = NSMutableParagraphStyle(); p.alignment = a; p.lineSpacing = spacing; p.lineBreakMode = .byWordWrapping
|
||||
return [.font:font(s,w), .foregroundColor:c, .paragraphStyle:p]
|
||||
}
|
||||
func text(_ t:String,_ r:CGRect,_ s:CGFloat=10,_ c:NSColor=ink,_ w:NSFont.Weight = .regular,
|
||||
_ a:NSTextAlignment = .left,_ spacing:CGFloat=2.5) {
|
||||
NSAttributedString(string:t,attributes:style(s,c,w,a,spacing)).draw(with:pr(r),options:[.usesLineFragmentOrigin,.usesFontLeading])
|
||||
}
|
||||
func box(_ r:CGRect,_ fill:NSColor = .white,_ stroke:NSColor? = line,_ radius:CGFloat=10) {
|
||||
let p=NSBezierPath(roundedRect:pr(r),xRadius:radius,yRadius:radius); fill.setFill(); p.fill()
|
||||
if let stroke { stroke.setStroke(); p.lineWidth=0.8; p.stroke() }
|
||||
}
|
||||
func pill(_ t:String,_ r:CGRect,_ c:NSColor) {
|
||||
box(r,c.withAlphaComponent(0.12),nil,r.height/2)
|
||||
text(t,CGRect(x:r.minX,y:r.minY+4,width:r.width,height:r.height-7),8.2,c,.semibold,.center,1)
|
||||
}
|
||||
func begin(_ ctx:CGContext,_ page:Int,_ title:String) {
|
||||
ctx.beginPDFPage(nil); ctx.saveGState(); NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current=NSGraphicsContext(cgContext:ctx,flipped:false)
|
||||
paper.setFill(); NSBezierPath(rect:pr(CGRect(x:0,y:0,width:W,height:H))).fill()
|
||||
text(title,CGRect(x:M,y:30,width:420,height:16),7.5,muted,.medium)
|
||||
text(String(format:"%02d",page),CGRect(x:W-M-30,y:30,width:30,height:16),8,muted,.medium,.right)
|
||||
let p=NSBezierPath(); p.move(to:CGPoint(x:M,y:31)); p.line(to:CGPoint(x:W-M,y:31))
|
||||
line.setStroke(); p.lineWidth=0.7; p.stroke()
|
||||
}
|
||||
func end(_ ctx:CGContext) {
|
||||
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage()
|
||||
}
|
||||
func heading(_ n:String,_ t:String,_ sub:String) {
|
||||
pill(n,CGRect(x:M,y:54,width:34,height:24),purple)
|
||||
text(t,CGRect(x:86,y:49,width:465,height:30),21,navy,.bold)
|
||||
text(sub,CGRect(x:M,y:87,width:W-2*M,height:31),9.5,muted,.regular,.left,3)
|
||||
}
|
||||
func statusRow(_ no:String,_ title:String,_ body:String,_ status:String,_ c:NSColor,_ y:CGFloat,_ h:CGFloat=82) {
|
||||
box(CGRect(x:M,y:y,width:W-2*M,height:h),.white,line,9)
|
||||
pill(no,CGRect(x:M+12,y:y+13,width:28,height:20),c)
|
||||
text(title,CGRect(x:M+50,y:y+12,width:338,height:19),10.5,navy,.bold)
|
||||
pill(status,CGRect(x:W-M-102,y:y+12,width:90,height:21),c)
|
||||
text(body,CGRect(x:M+50,y:y+37,width:W-2*M-64,height:h-44),8.8,ink,.regular,.left,2.4)
|
||||
}
|
||||
func metric(_ value:String,_ label:String,_ x:CGFloat,_ c:NSColor) {
|
||||
box(CGRect(x:x,y:435,width:117,height:96),c.withAlphaComponent(0.08),c.withAlphaComponent(0.3),12)
|
||||
text(value,CGRect(x:x+8,y:454,width:101,height:32),25,c,.bold,.center)
|
||||
text(label,CGRect(x:x+8,y:493,width:101,height:20),9,muted,.medium,.center)
|
||||
}
|
||||
|
||||
var media=CGRect(x:0,y:0,width:W,height:H)
|
||||
guard let consumer=CGDataConsumer(url:URL(fileURLWithPath:output) as CFURL),
|
||||
let ctx=CGContext(consumer:consumer,mediaBox:&media,nil) else { fatalError("PDF 생성 실패") }
|
||||
|
||||
// 1. cover
|
||||
begin(ctx,1,"AIO2O · 요청사항 반영 보고서")
|
||||
box(CGRect(x:0,y:0,width:W,height:H),navy,nil,0)
|
||||
pill("IMPLEMENTATION REVIEW",CGRect(x:M,y:112,width:148,height:25),NSColor(calibratedRed:0.42,green:0.78,blue:1,alpha:1))
|
||||
text("AIO2O 테스트 및 요청사항\n반영 결과 보고서",CGRect(x:M,y:166,width:510,height:112),34,.white,.bold,.left,7)
|
||||
text("260727_AIO2O 테스트 및 요청사항.xlsx 기준\n현재 저장소 구현·커밋·검증 캡처 대조",CGRect(x:M,y:310,width:510,height:60),14,NSColor(calibratedWhite:0.78,alpha:1),.regular,.left,7)
|
||||
box(CGRect(x:M,y:435,width:W-2*M,height:176),NSColor.white.withAlphaComponent(0.07),NSColor.white.withAlphaComponent(0.12),16)
|
||||
text("결론",CGRect(x:M+22,y:458,width:460,height:25),13,.white,.bold)
|
||||
text("핵심 업무 흐름은 대부분 구현되었습니다. 견적 목록·상세, 재협상 접수, 목표가 자동계산, 인터넷 최저가, 종료 의견, 결렬폼 통일, VAT 별도 표기, 10원 반올림은 코드 근거가 확인됩니다.\n\n다만 절충안/자동 제안가의 업무 적정성, SG명·유통레벨의 최종 UX, 최저가 VAT 산식은 추가 확인이 필요합니다.",CGRect(x:M+22,y:495,width:W-2*M-44,height:96),11,NSColor(calibratedWhite:0.88,alpha:1),.regular,.left,5)
|
||||
text("작성일 2026.07.31 | 기준 브랜치 feature/negodata | HEAD 775984fe",CGRect(x:M,y:758,width:W-2*M,height:18),8.5,NSColor(calibratedWhite:0.60,alpha:1))
|
||||
end(ctx)
|
||||
|
||||
// 2. summary
|
||||
begin(ctx,2,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("01","종합 요약","엑셀 RAW 시트의 24개 요청을 현재 저장소 상태로 재판정했습니다. 중복 요청은 원 요청 번호를 유지했습니다.")
|
||||
metric("18","완료·반영",M,green); metric("3","부분 반영",M+130,orange); metric("3","확인 필요",M+260,red); metric("24","전체 항목",M+390,blue)
|
||||
text("판정 기준",CGRect(x:M,y:566,width:507,height:24),13,navy,.bold)
|
||||
statusRow("A","완료·반영","사용자 화면과 처리 로직이 모두 확인되거나, 동일 기능을 제공하는 구현 및 검증 캡처가 존재합니다.","18건",green,603,60)
|
||||
statusRow("B","부분 반영","핵심 기능은 있으나 요청한 명칭·선택값·산식 중 일부가 다르거나 배포/운영 확인이 남았습니다.","3건",orange,675,60)
|
||||
statusRow("C","확인 필요","코드는 존재하지만 계산 결과의 업무 적정성을 확정할 수 없거나 요청 산식이 명시적으로 확인되지 않습니다.","3건",red,747,60)
|
||||
end(ctx)
|
||||
|
||||
// 3. system 1
|
||||
begin(ctx,3,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("02","기본 시스템 반영 내역","견적 생성부터 협력사·최저가·협상 화면까지의 공통 요청입니다.")
|
||||
statusRow("01","견적관리 목록·상세/히스토리","견적 목록과 상세 드로어가 있으며, 상세의 채팅 탭·협상카드 탭이 세션 데이터를 연결합니다. 견적번호별 진행/완료 상태와 상세 확인 경로가 마련됐습니다.","완료",green,132)
|
||||
statusRow("02","재협상 접수 및 관리","공급사 포털에서 결렬 건 재협상 요청·철회가 가능하고, 구매자 화면에 재협상 요청 목록·검토 시트·승인/반려 및 알림이 구현됐습니다.","완료",green,226)
|
||||
statusRow("03","MD 제시가 용어·위치·판매가","‘MD’는 ‘구매담당자’로 통일했고 제시가/산정후보를 ‘3. 낙찰기준’으로 이동했습니다. 판매가 설정은 숨김 처리되어 요청 흐름과 일치합니다.","완료",green,320)
|
||||
statusRow("04","목표가 자동 산출","매입가 × (1 − 목표 네고율)로 구매담당자 제시가를 자동 입력합니다. 예: 10,000원, 2% → 9,800원. 프론트 자동계산과 백엔드 가격 처리 근거가 있습니다.","완료",green,414)
|
||||
statusRow("05","공급사/매입가 라벨 일원화","상품 및 견적 화면의 회사별 필드 라벨 설정을 연동해 ‘공급사=매입가’ 표기 정책을 적용할 수 있게 했습니다. 실제 운영 회사 설정값 확인은 필요합니다.","부분",orange,508)
|
||||
statusRow("06","인터넷 최저가 수집","15%에서 멈추던 Worker/큐 처리 문제를 수정하고, 몰별 결과·진행 상태·이력 화면을 재설계했습니다. 다만 요청 산식 ‘(상품가+배송비)/1.1’의 최종 대표값 적용은 코드에서 확정되지 않습니다.","부분",orange,602)
|
||||
statusRow("07","신규 상품 공급사 입력","신규 상품 등록 폼에 공급사 선택기를 추가하고 상품–공급사 매핑을 저장하도록 구현했습니다.","완료",green,696)
|
||||
end(ctx)
|
||||
|
||||
// 4. system 2
|
||||
begin(ctx,4,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("03","협력사·협상 화면 반영","용어 통일, 종료 단계, 가격 표기와 협상 지표를 중심으로 확인했습니다.")
|
||||
statusRow("08","협력사 SG명·유통레벨","취급상품 기반 분류와 공급유형 선택/저장은 구현되어 있습니다. 다만 요청한 SG명 콤보와 유통레벨 4종(제조·총판·대리점·일반유통), 취급상품 삭제가 그대로 완성됐는지는 추가 UX 확인이 필요합니다.","부분",orange,132)
|
||||
statusRow("09","리드타임 → 표준납기","협상 완료 부가정보와 API 설명에 ‘표준납기’가 반영되고 회사 정의 session_fields와 연결됩니다.","완료",green,226)
|
||||
statusRow("10","협상 단가 VAT 별도","협상 상품정보·요약·목록/상세의 단가 표기를 VAT 별도로 통일했습니다. 검증 캡처도 존재합니다.","완료",green,320)
|
||||
statusRow("11","협상 성공률 기준 안내","성공률은 공급사 제시가를 앵커가·목표가와 비교한 1~99 지표입니다. 100% 미달이 결렬 조건은 아니며, 실제 종료는 별도 낙찰/개찰 규칙이 결정합니다.","완료(안내)",blue,414)
|
||||
statusRow("12","협상 종료 추가 의견","타결 부가정보와 결렬 통합폼 모두 ‘기타 의견’을 받으며 sessions.custom.opinion에 저장합니다. 구매자 상세·요약에서 조회되고 완료 후 잠깁니다.","완료",green,508)
|
||||
statusRow("13","결렬 사유·희망가격 통일","기존 RejectRSP/RejectCM을 단일 RejectForm으로 교체했습니다. 결렬사유·희망가·의견을 한 흐름에서 받고 reject_reason/reject_price에 저장합니다.","완료",green,602)
|
||||
statusRow("14","카드 사용 횟수 제한","견적 설정의 card_count(기본 3)를 컨텍스트에서 읽어 실제 사용 가능한 카드 수와 종료 조건을 제한하도록 반영했습니다. 운영 시 기존 세션 회귀검증을 권장합니다.","완료",green,696)
|
||||
end(ctx)
|
||||
|
||||
// 5. case-specific
|
||||
begin(ctx,5,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("04","견적번호별 이슈 반영","EST-202607-05DE·8945·C9D2 사례에서 제기된 가격/종료 흐름을 대조했습니다.")
|
||||
statusRow("15","앵커·자동 제안가 10원 반올림","앵커 생성가, 목표가 후보, 협상카드 카운터를 공통으로 10원 단위 반올림합니다. 예: 15,213원 → 15,210원. 관련 커밋과 단위 테스트가 있습니다.","완료",green,132)
|
||||
statusRow("16","절충안 계산식","협상카드 전술에는 앵커·목표가·직전 제시가를 이용한 중간값 및 목표가 상한 로직이 존재합니다. 다만 ‘절충안’의 기대 공식이 엑셀에 없어 업무적으로 맞는지 확정할 수 없습니다.","확인 필요",red,226)
|
||||
statusRow("17","05DE/C9D2 결렬 희망가격","견적 유형별로 갈리던 결렬 화면을 단일 폼으로 통합해 희망가격 입력 절차를 동일하게 만들었습니다.","완료",green,320)
|
||||
statusRow("18","8945 협상 마무리 개편","종료 후 표준납기·MOQ·발주배수·배송유형 등 회사 정의 부가정보를 선택/입력하고, 기타 의견과 함께 최종 요약에 반영합니다. 배송 선택값은 회사 설정에 따라 구성됩니다.","완료",green,414)
|
||||
statusRow("19","C9D2 자동 제안가 갭","제안가는 카드 전술과 앵커·목표가·직전 제시가의 조합으로 계산되고 10원 반올림됩니다. 17,500원→15,210원의 10.5% 갭이 정책상 적정한지는 목표/앵커 설정을 포함한 별도 검증이 필요합니다.","확인 필요",red,508)
|
||||
statusRow("20","성공/실패 후 의견 조회","협력사가 입력한 종료 의견은 공급사 요약과 구매자 견적 상세 양쪽에서 확인할 수 있고, 종료 후 읽기 전용으로 잠깁니다.","완료",green,602)
|
||||
statusRow("21","중복 요청 통합 반영","엑셀 18/22(성공률), 19/25(종료 의견), 5/23(목표가), 14/17/20(결렬폼)은 각각 하나의 공통 구현으로 해소했습니다.","완료",green,696)
|
||||
end(ctx)
|
||||
|
||||
// 6. evidence
|
||||
begin(ctx,6,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("05","구현 근거","최근 커밋과 현재 코드에서 확인한 핵심 근거입니다. 커밋 단위로 기능 범위를 추적할 수 있습니다.")
|
||||
statusRow("A","b33ae05c · 견적 가격/상품/라벨","목표가 자동입력, 산정후보 위치 이동, 앵커·후보 10원 반올림, 신규 상품 공급사 입력, 회사 설정 라벨 연동.","커밋",purple,132,72)
|
||||
statusRow("B","a56589c6 · 종료폼/의견/VAT","결렬폼 통합, 희망가·사유 저장, 타결/결렬 의견 수취, 상품정보 라벨 연동, 협상 단가 VAT 별도 표기.","커밋",purple,216,72)
|
||||
statusRow("C","30f13483 · 인터넷 최저가","무한 로딩 버그 수정, Worker 설정 보강, 몰별 최저가·진행/상세 UI 및 이력 저장 개선.","커밋",purple,300,72)
|
||||
statusRow("D","9dca78dc · 완료 부가정보","완료 부가정보 수취·요약 표시·잠금, 구매자 상세 노출, VAT 표기 통일.","커밋",purple,384,72)
|
||||
statusRow("E","2a004734 · 견적 상세 연결","견적 상세의 채팅·협상카드 탭 연동과 드로어 탐색 개선.","커밋",purple,468,72)
|
||||
statusRow("F","775984fe / f554202c · 반올림","협상카드 카운터와 자동 앵커를 10원 단위 반올림으로 통일.","커밋",purple,552,72)
|
||||
statusRow("G","화면 검증 캡처","목록/상세, 종료 의견, VAT, 완료 요약, 읽기 전용 잠금 등 12개 캡처가 저장소 루트에 남아 있습니다.","캡처",blue,636,72)
|
||||
text("주의: 본 보고서는 2026-07-31 현재 로컬 저장소의 코드·커밋·캡처를 기준으로 합니다. 운영 배포 여부와 기존 데이터 마이그레이션 상태는 별도 확인 대상입니다.",CGRect(x:M,y:742,width:W-2*M,height:42),8.8,muted,.regular,.left,3)
|
||||
end(ctx)
|
||||
|
||||
// 7. actions
|
||||
begin(ctx,7,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("06","남은 확인 및 권고","기능 누락이라기보다 업무 규칙·운영 설정을 확정해야 하는 항목입니다.")
|
||||
statusRow("1","최저가 VAT 대표값 확정","현재 LPS는 상품가와 배송비를 별도 수집·표시합니다. 대표 최저가를 반드시 (상품가+배송비)/1.1로 저장할지, 화면 표시만 할지 정책을 확정한 뒤 테스트를 추가해야 합니다.","우선순위 높음",red,142,98)
|
||||
statusRow("2","절충안/자동 제안가 기준 검증","05DE·C9D2의 실제 앵커가·목표가·직전 제시가를 넣어 계산 결과를 재현하고, 허용 최대 인하폭 또는 목표가 클램프 기준을 업무 담당자와 합의하는 것이 좋습니다.","우선순위 높음",red,254,98)
|
||||
statusRow("3","SG명·유통레벨 UX 확정","현행 취급상품 기반 분류/공급유형을 요청한 SG 콤보와 유통레벨 4종으로 대체할지, 데이터 모델을 유지한 채 라벨만 조정할지 결정이 필요합니다.","우선순위 중간",orange,366,98)
|
||||
statusRow("4","운영 배포·기존 세션 회귀검증","종료폼, 의견, 카드 횟수 제한은 신규 코드에 반영됐습니다. 운영 컨테이너 재빌드 후 기존 세션과 신규 세션에서 각각 1회 이상 확인해야 합니다.","배포 확인",blue,478,98)
|
||||
box(CGRect(x:M,y:612,width:W-2*M,height:118),purple.withAlphaComponent(0.08),purple.withAlphaComponent(0.28),12)
|
||||
text("권장 최종 승인 기준",CGRect(x:M+18,y:630,width:470,height:22),12,purple,.bold)
|
||||
text("① 운영 배포 버전 확인 ② 대표 견적 3건 시나리오 재실행 ③ 계산식 2건 서면 확정\n④ SG/유통레벨 화면 승인 ⑤ 완료·결렬 의견이 구매자 상세에 저장되는지 확인",CGRect(x:M+18,y:662,width:470,height:50),10,ink,.medium,.left,5)
|
||||
text("— End of report —",CGRect(x:M,y:760,width:W-2*M,height:20),8,muted,.medium,.center)
|
||||
end(ctx)
|
||||
|
||||
ctx.closePDF()
|
||||
BIN
docs/backend-advanced-concepts-ko.pdf
Normal file
790
docs/backend_advanced_guide.swift
Normal file
@ -0,0 +1,790 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let outPath = CommandLine.arguments.count > 1
|
||||
? CommandLine.arguments[1]
|
||||
: "docs/backend-advanced-concepts-ko.pdf"
|
||||
|
||||
let W: CGFloat = 595
|
||||
let H: CGFloat = 842
|
||||
let margin: CGFloat = 44
|
||||
|
||||
let navy = NSColor(calibratedRed: 0.055, green: 0.086, blue: 0.16, alpha: 1)
|
||||
let ink = NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1)
|
||||
let muted = NSColor(calibratedRed: 0.37, green: 0.42, blue: 0.50, alpha: 1)
|
||||
let paper = NSColor(calibratedRed: 0.975, green: 0.98, blue: 0.99, alpha: 1)
|
||||
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
|
||||
let blue = NSColor(calibratedRed: 0.16, green: 0.39, blue: 0.93, alpha: 1)
|
||||
let cyan = NSColor(calibratedRed: 0.10, green: 0.69, blue: 0.74, alpha: 1)
|
||||
let green = NSColor(calibratedRed: 0.10, green: 0.63, blue: 0.39, alpha: 1)
|
||||
let orange = NSColor(calibratedRed: 0.94, green: 0.47, blue: 0.12, alpha: 1)
|
||||
let red = NSColor(calibratedRed: 0.88, green: 0.25, blue: 0.28, alpha: 1)
|
||||
let purple = NSColor(calibratedRed: 0.48, green: 0.32, blue: 0.89, alpha: 1)
|
||||
|
||||
func pdfRect(_ r: CGRect) -> CGRect {
|
||||
CGRect(x: r.minX, y: H - r.maxY, width: r.width, height: r.height)
|
||||
}
|
||||
|
||||
func pdfPoint(_ p: CGPoint) -> CGPoint {
|
||||
CGPoint(x: p.x, y: H - p.y)
|
||||
}
|
||||
|
||||
func font(_ size: CGFloat, _ weight: NSFont.Weight = .regular) -> NSFont {
|
||||
NSFont(name: "Apple SD Gothic Neo", size: size)
|
||||
?? NSFont.systemFont(ofSize: size, weight: weight)
|
||||
}
|
||||
|
||||
func mono(_ size: CGFloat) -> NSFont {
|
||||
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
|
||||
func attrs(_ size: CGFloat, color: NSColor = ink, weight: NSFont.Weight = .regular,
|
||||
align: NSTextAlignment = .left, lineSpacing: CGFloat = 3) -> [NSAttributedString.Key: Any] {
|
||||
let p = NSMutableParagraphStyle()
|
||||
p.alignment = align
|
||||
p.lineSpacing = lineSpacing
|
||||
p.lineBreakMode = .byWordWrapping
|
||||
return [.font: font(size, weight), .foregroundColor: color, .paragraphStyle: p]
|
||||
}
|
||||
|
||||
func drawText(_ text: String, _ rect: CGRect, size: CGFloat = 11, color: NSColor = ink,
|
||||
weight: NSFont.Weight = .regular, align: NSTextAlignment = .left,
|
||||
lineSpacing: CGFloat = 3) {
|
||||
NSAttributedString(string: text, attributes: attrs(size, color: color, weight: weight,
|
||||
align: align, lineSpacing: lineSpacing))
|
||||
.draw(with: pdfRect(rect), options: [.usesLineFragmentOrigin, .usesFontLeading])
|
||||
}
|
||||
|
||||
func rounded(_ rect: CGRect, radius: CGFloat = 12, fill: NSColor = .white,
|
||||
stroke: NSColor? = line, width: CGFloat = 1) {
|
||||
let p = NSBezierPath(roundedRect: pdfRect(rect), xRadius: radius, yRadius: radius)
|
||||
fill.setFill(); p.fill()
|
||||
if let stroke { stroke.setStroke(); p.lineWidth = width; p.stroke() }
|
||||
}
|
||||
|
||||
func pill(_ text: String, x: CGFloat, y: CGFloat, w: CGFloat, color: NSColor) {
|
||||
rounded(CGRect(x: x, y: y, width: w, height: 25), radius: 12.5,
|
||||
fill: color.withAlphaComponent(0.12), stroke: nil)
|
||||
drawText(text, CGRect(x: x, y: y + 5, width: w, height: 16), size: 9.5,
|
||||
color: color, weight: .semibold, align: .center)
|
||||
}
|
||||
|
||||
func arrow(_ from: CGPoint, _ to: CGPoint, color: NSColor = muted) {
|
||||
let from = pdfPoint(from), to = pdfPoint(to)
|
||||
let p = NSBezierPath(); p.move(to: from); p.line(to: to)
|
||||
color.setStroke(); p.lineWidth = 1.8; p.stroke()
|
||||
let a = atan2(to.y - from.y, to.x - from.x)
|
||||
let l: CGFloat = 7
|
||||
let h = NSBezierPath()
|
||||
h.move(to: to)
|
||||
h.line(to: CGPoint(x: to.x - l * cos(a - .pi / 6), y: to.y - l * sin(a - .pi / 6)))
|
||||
h.line(to: CGPoint(x: to.x - l * cos(a + .pi / 6), y: to.y - l * sin(a + .pi / 6)))
|
||||
h.close(); color.setFill(); h.fill()
|
||||
}
|
||||
|
||||
func node(_ title: String, _ sub: String, rect: CGRect, color: NSColor) {
|
||||
rounded(rect, radius: 10, fill: color.withAlphaComponent(0.10),
|
||||
stroke: color.withAlphaComponent(0.55), width: 1.2)
|
||||
drawText(title, CGRect(x: rect.minX + 8, y: rect.minY + 10, width: rect.width - 16, height: 18),
|
||||
size: 10.5, color: color, weight: .bold, align: .center)
|
||||
drawText(sub, CGRect(x: rect.minX + 8, y: rect.minY + 31, width: rect.width - 16, height: rect.height - 36),
|
||||
size: 8.5, color: muted, align: .center, lineSpacing: 1)
|
||||
}
|
||||
|
||||
func sectionTitle(_ number: String, _ title: String, _ subtitle: String, color: NSColor) {
|
||||
pill(number, x: margin, y: 48, w: 34, color: color)
|
||||
drawText(title, CGRect(x: 86, y: 46, width: 450, height: 30), size: 22,
|
||||
color: navy, weight: .bold)
|
||||
drawText(subtitle, CGRect(x: margin, y: 82, width: W - 2 * margin, height: 26),
|
||||
size: 10.5, color: muted)
|
||||
}
|
||||
|
||||
func footer(_ page: Int, _ label: String = "O2O Negosium · Backend Concepts") {
|
||||
let p = NSBezierPath()
|
||||
p.move(to: CGPoint(x: margin, y: H - 34)); p.line(to: CGPoint(x: W - margin, y: H - 34))
|
||||
line.setStroke(); p.lineWidth = 0.7; p.stroke()
|
||||
drawText(label, CGRect(x: margin, y: H - 28, width: 350, height: 14), size: 7.5, color: muted)
|
||||
drawText("\(page)", CGRect(x: W - margin - 35, y: H - 28, width: 35, height: 14),
|
||||
size: 8, color: muted, align: .right)
|
||||
}
|
||||
|
||||
func callout(_ title: String, _ body: String, rect: CGRect, color: NSColor) {
|
||||
rounded(rect, radius: 12, fill: color.withAlphaComponent(0.08),
|
||||
stroke: color.withAlphaComponent(0.35))
|
||||
rounded(CGRect(x: rect.minX, y: rect.minY, width: 5, height: rect.height),
|
||||
radius: 2.5, fill: color, stroke: nil)
|
||||
drawText(title, CGRect(x: rect.minX + 16, y: rect.minY + 12,
|
||||
width: rect.width - 28, height: 20),
|
||||
size: 11, color: color, weight: .bold)
|
||||
drawText(body, CGRect(x: rect.minX + 16, y: rect.minY + 37,
|
||||
width: rect.width - 28, height: rect.height - 45),
|
||||
size: 9.5, color: ink, lineSpacing: 3)
|
||||
}
|
||||
|
||||
func comparison(_ leftTitle: String, _ left: String, _ rightTitle: String, _ right: String,
|
||||
y: CGFloat, color: NSColor) {
|
||||
let gap: CGFloat = 14
|
||||
let cw = (W - 2 * margin - gap) / 2
|
||||
callout(leftTitle, left, rect: CGRect(x: margin, y: y, width: cw, height: 126), color: red)
|
||||
callout(rightTitle, right, rect: CGRect(x: margin + cw + gap, y: y, width: cw, height: 126), color: color)
|
||||
}
|
||||
|
||||
func codeBox(_ title: String, _ path: String, _ code: String, rect: CGRect, accent: NSColor) {
|
||||
rounded(rect, radius: 10, fill: navy, stroke: nil)
|
||||
drawText(title, CGRect(x: rect.minX + 14, y: rect.minY + 11,
|
||||
width: rect.width - 28, height: 17),
|
||||
size: 10, color: .white, weight: .bold)
|
||||
drawText(path, CGRect(x: rect.minX + 14, y: rect.minY + 30,
|
||||
width: rect.width - 28, height: 14),
|
||||
size: 7.5, color: accent)
|
||||
let p = NSMutableParagraphStyle(); p.lineSpacing = 2; p.lineBreakMode = .byClipping
|
||||
NSAttributedString(string: code, attributes: [.font: mono(7.8), .foregroundColor: NSColor(calibratedWhite: 0.88, alpha: 1), .paragraphStyle: p])
|
||||
.draw(with: pdfRect(CGRect(x: rect.minX + 14, y: rect.minY + 51,
|
||||
width: rect.width - 28, height: rect.height - 60)),
|
||||
options: [.usesLineFragmentOrigin])
|
||||
}
|
||||
|
||||
func beginPage(_ ctx: CGContext, page: Int, label: String = "O2O Negosium · Backend Concepts") {
|
||||
ctx.beginPDFPage(nil)
|
||||
ctx.saveGState()
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
|
||||
paper.setFill(); NSBezierPath(rect: pdfRect(CGRect(x: 0, y: 0, width: W, height: H))).fill()
|
||||
footer(page, label)
|
||||
}
|
||||
|
||||
func endPage(_ ctx: CGContext) {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
ctx.restoreGState()
|
||||
ctx.endPDFPage()
|
||||
}
|
||||
|
||||
var mediaBox = CGRect(x: 0, y: 0, width: W, height: H)
|
||||
guard let consumer = CGDataConsumer(url: URL(fileURLWithPath: outPath) as CFURL),
|
||||
let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
|
||||
fatalError("PDF context 생성 실패")
|
||||
}
|
||||
|
||||
// 1 — Cover
|
||||
beginPage(ctx, page: 1, label: "O2O Negosium · Backend Field Guide")
|
||||
rounded(CGRect(x: 0, y: 0, width: W, height: H), radius: 0, fill: navy, stroke: nil)
|
||||
for i in 0..<7 {
|
||||
let x = CGFloat(50 + i * 78)
|
||||
let c = [blue, cyan, green, orange, purple][i % 5]
|
||||
rounded(CGRect(x: x, y: 85 + CGFloat((i % 3) * 28), width: 48, height: 48),
|
||||
radius: 24, fill: c.withAlphaComponent(0.35), stroke: nil)
|
||||
}
|
||||
drawText("BACKEND", CGRect(x: margin, y: 190, width: 507, height: 35), size: 15,
|
||||
color: cyan, weight: .bold)
|
||||
drawText("어려운 개념 5가지,\n코드로 이해하기", CGRect(x: margin, y: 228, width: 507, height: 118),
|
||||
size: 36, color: .white, weight: .bold, lineSpacing: 7)
|
||||
drawText("분산 시스템 · 트랜잭션/동시성 · 멀티테넌시\n캐시 정합성 · 스케줄러/배치",
|
||||
CGRect(x: margin, y: 370, width: 507, height: 62), size: 15,
|
||||
color: NSColor(calibratedWhite: 0.80, alpha: 1), lineSpacing: 8)
|
||||
rounded(CGRect(x: margin, y: 485, width: 507, height: 154), radius: 18,
|
||||
fill: NSColor.white.withAlphaComponent(0.07),
|
||||
stroke: NSColor.white.withAlphaComponent(0.15))
|
||||
drawText("이 문서는 이렇게 읽어요", CGRect(x: 66, y: 510, width: 455, height: 25),
|
||||
size: 14, color: .white, weight: .bold)
|
||||
drawText("① 일상 비유로 개념 잡기\n② 실제 서비스 흐름을 그림으로 보기\n③ 프로젝트 코드에서 구현 확인하기\n④ 없을 때 생기는 문제와 비교하기",
|
||||
CGRect(x: 66, y: 548, width: 455, height: 78), size: 11.5,
|
||||
color: NSColor(calibratedWhite: 0.88, alpha: 1), lineSpacing: 6)
|
||||
drawText("Generated from the current repository · 2026-07-29",
|
||||
CGRect(x: margin, y: 758, width: 507, height: 18), size: 8.5,
|
||||
color: NSColor(calibratedWhite: 0.62, alpha: 1))
|
||||
endPage(ctx)
|
||||
|
||||
// 2 — Architecture map
|
||||
beginPage(ctx, page: 2)
|
||||
drawText("먼저, 서비스 지도를 봅시다", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||||
size: 24, color: navy, weight: .bold)
|
||||
drawText("다섯 개념은 따로 노는 것이 아니라, 한 요청이 여러 서비스와 저장소를 지나면서 함께 작동합니다.",
|
||||
CGRect(x: margin, y: 88, width: 507, height: 32), size: 10.5, color: muted)
|
||||
node("사용자", "브라우저", rect: CGRect(x: 44, y: 170, width: 90, height: 62), color: purple)
|
||||
node("Backend", "채팅·공급사", rect: CGRect(x: 184, y: 145, width: 102, height: 72), color: blue)
|
||||
node("Agent", "AI 협상", rect: CGRect(x: 348, y: 145, width: 102, height: 72), color: orange)
|
||||
node("Negodata", "견적·관리", rect: CGRect(x: 184, y: 270, width: 102, height: 72), color: cyan)
|
||||
node("LPS", "최저가 Worker", rect: CGRect(x: 348, y: 270, width: 102, height: 72), color: green)
|
||||
node("PostgreSQL", "업무 원본", rect: CGRect(x: 184, y: 405, width: 130, height: 72), color: purple)
|
||||
node("Redis", "앵커링 캐시", rect: CGRect(x: 368, y: 405, width: 100, height: 72), color: red)
|
||||
arrow(CGPoint(x: 134, y: 200), CGPoint(x: 184, y: 182), color: purple)
|
||||
arrow(CGPoint(x: 286, y: 180), CGPoint(x: 348, y: 180), color: blue)
|
||||
arrow(CGPoint(x: 235, y: 217), CGPoint(x: 235, y: 270), color: cyan)
|
||||
arrow(CGPoint(x: 286, y: 304), CGPoint(x: 348, y: 304), color: green)
|
||||
arrow(CGPoint(x: 235, y: 342), CGPoint(x: 235, y: 405), color: purple)
|
||||
arrow(CGPoint(x: 399, y: 342), CGPoint(x: 415, y: 405), color: red)
|
||||
callout("① 경계가 생기면 ‘분산 시스템’", "서비스 A가 서비스 B를 네트워크로 호출하는 순간, 지연·타임아웃·부분 실패를 다뤄야 합니다.",
|
||||
rect: CGRect(x: margin, y: 525, width: 246, height: 105), color: blue)
|
||||
callout("② 여러 실행자가 만나면 ‘동시성’", "사용자 클릭과 스케줄러가 같은 견적을 동시에 마감할 수 있어, DB가 최종 심판 역할을 합니다.",
|
||||
rect: CGRect(x: 305, y: 525, width: 246, height: 105), color: orange)
|
||||
callout("③ 빠르게 읽되 원본을 지키면 ‘캐시’", "Redis와 프로세스 메모리는 복사본입니다. PostgreSQL과 설정 파일이 원본입니다.",
|
||||
rect: CGRect(x: margin, y: 650, width: 246, height: 105), color: red)
|
||||
callout("④ 회사별 경계를 지키면 ‘멀티테넌시’", "요청 헤더에서 회사 ID를 결정하고, 회사별 설정·엔진을 선택합니다.",
|
||||
rect: CGRect(x: 305, y: 650, width: 246, height: 105), color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 3 — Distributed systems: concept first
|
||||
beginPage(ctx, page: 3)
|
||||
sectionTitle("3", "분산 시스템 — 개념부터", "여러 독립 실행 단위가 네트워크를 통해 하나의 업무를 완성하는 시스템", color: blue)
|
||||
callout("정확한 정의", "프로세스·컨테이너·서버가 각자 메모리와 실행 상태를 가지고, HTTP나 메시지로 통신하는 구조입니다. 한 서비스의 함수 호출과 달리 상대의 상태를 직접 볼 수 없고, 네트워크 응답만으로 결과를 추론해야 합니다.",
|
||||
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: blue)
|
||||
drawText("왜 어려운가: 네트워크에는 네 가지 결과가 있습니다", CGRect(x: margin, y: 244, width: 507, height: 24),
|
||||
size: 13.5, color: navy, weight: .bold)
|
||||
let distCases: [(String, String, NSColor)] = [
|
||||
("성공", "상대가 처리했고 응답도 받음", green),
|
||||
("명확한 실패", "상대가 오류 응답을 보냄", red),
|
||||
("연결 실패", "상대에게 요청이 도착하지 않음", orange),
|
||||
("애매한 타임아웃", "처리는 됐지만 응답만 늦었을 수도 있음", purple),
|
||||
]
|
||||
for (i, c) in distCases.enumerated() {
|
||||
let col = i % 2, row = i / 2
|
||||
let x = margin + CGFloat(col) * 260
|
||||
let y = CGFloat(286 + row * 86)
|
||||
callout(c.0, c.1, rect: CGRect(x: x, y: y, width: 247, height: 70), color: c.2)
|
||||
}
|
||||
drawText("대표적인 대응 수단", CGRect(x: margin, y: 475, width: 507, height: 24),
|
||||
size: 13.5, color: navy, weight: .bold)
|
||||
callout("Timeout", "얼마나 기다릴지 상한을 둡니다. 짧으면 정상 요청도 실패하고, 길면 자원이 오래 묶입니다.",
|
||||
rect: CGRect(x: margin, y: 515, width: 159, height: 92), color: blue)
|
||||
callout("Retry", "일시 실패를 다시 시도합니다. 단, 중복 처리에 안전한 작업에서만 제한적으로 사용합니다.",
|
||||
rect: CGRect(x: 218, y: 515, width: 159, height: 92), color: orange)
|
||||
callout("Idempotency", "같은 요청을 여러 번 보내도 결과가 한 번 처리한 것과 같도록 만듭니다.",
|
||||
rect: CGRect(x: 392, y: 515, width: 159, height: 92), color: purple)
|
||||
callout("Fallback", "주 서비스가 실패하면 대체 경로·기본값·이전 데이터를 사용합니다. 대체 결과가 업무적으로 허용될 때만 가능합니다.",
|
||||
rect: CGRect(x: margin, y: 630, width: 247, height: 92), color: green)
|
||||
callout("Circuit breaker", "실패가 계속되는 서비스를 잠시 호출하지 않아 연쇄 장애를 막습니다. 현재 프로젝트에는 명시적 구현이 없습니다.",
|
||||
rect: CGRect(x: 304, y: 630, width: 247, height: 92), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 4 distributed concept
|
||||
beginPage(ctx, page: 4)
|
||||
sectionTitle("3", "분산 시스템과 장애 대응", "한 프로그램이 아니라 여러 서비스가 네트워크로 협력하는 구조", color: blue)
|
||||
callout("쉬운 비유", "한 식당 안에서 주방과 홀 직원이 말로 협업하는 것이 단일 시스템이라면, 분산 시스템은 서로 다른 건물의 팀이 전화로 협업하는 것입니다. 전화는 늦거나 끊길 수 있고, 상대가 일을 끝냈는데 답만 못 받을 수도 있습니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 105), color: blue)
|
||||
drawText("프로젝트의 대표 흐름", CGRect(x: margin, y: 266, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("Backend", "사용자 채팅 요청", rect: CGRect(x: 52, y: 315, width: 110, height: 74), color: blue)
|
||||
node("HTTPX", "timeout 설정", rect: CGRect(x: 242, y: 315, width: 110, height: 74), color: cyan)
|
||||
node("Agent", "협상 턴 계산", rect: CGRect(x: 432, y: 315, width: 110, height: 74), color: orange)
|
||||
arrow(CGPoint(x: 162, y: 352), CGPoint(x: 242, y: 352), color: blue)
|
||||
arrow(CGPoint(x: 352, y: 352), CGPoint(x: 432, y: 352), color: cyan)
|
||||
drawText("성공", CGRect(x: 394, y: 414, width: 80, height: 18), size: 9, color: green, weight: .bold)
|
||||
arrow(CGPoint(x: 485, y: 389), CGPoint(x: 485, y: 458), color: green)
|
||||
node("응답 반영", "채팅 상태 저장", rect: CGRect(x: 430, y: 458, width: 112, height: 65), color: green)
|
||||
drawText("타임아웃/실패", CGRect(x: 185, y: 414, width: 105, height: 18), size: 9, color: red, weight: .bold)
|
||||
arrow(CGPoint(x: 297, y: 389), CGPoint(x: 297, y: 458), color: red)
|
||||
node("안전한 실패", "ok=false 반환", rect: CGRect(x: 241, y: 458, width: 112, height: 65), color: red)
|
||||
callout("중요한 함정: 타임아웃 ≠ 상대가 아무 일도 안 함", "Agent가 DB 상태를 이미 전진시킨 직후 응답만 늦었을 수 있습니다. 그래서 무조건 재시도하면 같은 턴을 두 번 처리할 위험이 있습니다. 코드가 timed_out을 따로 표시하는 이유입니다.",
|
||||
rect: CGRect(x: margin, y: 566, width: 507, height: 105), color: orange)
|
||||
comparison("이 장치가 없으면", "Agent가 느린 순간 Backend 요청도 끝없이 대기합니다. 무작정 재시도하면 협상 step이 두 번 전진할 수 있습니다.",
|
||||
"현재 방식", "HTTP timeout을 두고 성공/일반 실패/타임아웃을 구분합니다. 호출 경계에서 예외를 응답 객체로 변환합니다.",
|
||||
y: 695, color: blue)
|
||||
endPage(ctx)
|
||||
|
||||
// 5 distributed code
|
||||
beginPage(ctx, page: 5)
|
||||
sectionTitle("3", "분산 시스템 — 실제 코드", "서비스 경계마다 timeout, fallback, best-effort 정책이 다릅니다.", color: blue)
|
||||
codeBox("Agent 호출: 타임아웃을 별도 상태로 반환",
|
||||
"backend/services/agent_client.py · lines 81–91",
|
||||
"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=agent_config.base_url,
|
||||
timeout=agent_config.timeout_sec,
|
||||
) as cli:
|
||||
resp = await cli.post("/v1/chat", json=body, headers=headers)
|
||||
|
||||
except httpx.TimeoutException as ex:
|
||||
# Agent가 이미 처리했을 수 있어 단순 재시도는 위험
|
||||
return AgentTurn(ok=False, timed_out=True)
|
||||
except Exception:
|
||||
return AgentTurn(ok=False)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 245), accent: cyan)
|
||||
codeBox("카탈로그 변경 알림: 핵심 업무를 막지 않는 best-effort",
|
||||
"negodata/backend/services/agent_notify.py · lines 15–26",
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as cli:
|
||||
await cli.post(f"{base}/v1/catalog-refresh-all")
|
||||
except Exception as ex:
|
||||
# 알림 실패는 카드 작업을 막지 않는다
|
||||
LOG.w(f"[agent_notify] 변경 알림 실패(무시): {ex}")
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 395, width: 507, height: 160), accent: green)
|
||||
callout("어떻게 정책을 고르나요?", "결제처럼 반드시 성공해야 하는 작업은 실패를 호출자에게 알려 재처리해야 합니다. 반면 ‘캐시 무효화 알림’처럼 보조적인 작업은 실패해도 핵심 카드 변경을 성공시킬 수 있습니다. 모든 외부 호출을 똑같이 재시도하면 안 됩니다.",
|
||||
rect: CGRect(x: margin, y: 580, width: 507, height: 105), color: blue)
|
||||
drawText("기억할 단어", CGRect(x: margin, y: 712, width: 120, height: 20), size: 12,
|
||||
color: navy, weight: .bold)
|
||||
pill("timeout", x: 150, y: 707, w: 82, color: blue)
|
||||
pill("partial failure", x: 242, y: 707, w: 102, color: orange)
|
||||
pill("best-effort", x: 354, y: 707, w: 92, color: green)
|
||||
pill("idempotency", x: 456, y: 707, w: 90, color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 6 — Transactions and concurrency: concept first
|
||||
beginPage(ctx, page: 6)
|
||||
sectionTitle("4", "트랜잭션·동시성 — 개념부터", "데이터의 일관성을 지키는 작업 단위와, 동시에 실행되는 요청을 제어하는 방법", color: orange)
|
||||
drawText("트랜잭션의 ACID", CGRect(x: margin, y: 126, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let acid: [(String, String, NSColor)] = [
|
||||
("A · Atomicity", "전부 성공하거나 전부 취소", orange),
|
||||
("C · Consistency", "규칙을 만족하는 상태로 이동", green),
|
||||
("I · Isolation", "동시 작업의 중간 상태를 서로 숨김", purple),
|
||||
("D · Durability", "COMMIT된 결과는 장애 후에도 보존", blue),
|
||||
]
|
||||
for (i, a) in acid.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(165 + (i / 2) * 84)
|
||||
callout(a.0, a.1, rect: CGRect(x: x, y: y, width: 247, height: 68), color: a.2)
|
||||
}
|
||||
drawText("동시성 문제는 어떻게 생기나?", CGRect(x: margin, y: 352, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("요청 A", "status=OPEN 읽음", rect: CGRect(x: 48, y: 401, width: 116, height: 64), color: blue)
|
||||
node("요청 B", "status=OPEN 읽음", rect: CGRect(x: 48, y: 500, width: 116, height: 64), color: purple)
|
||||
node("둘 다 처리", "중복 마감·중복 메일", rect: CGRect(x: 250, y: 449, width: 130, height: 70), color: red)
|
||||
arrow(CGPoint(x: 164, y: 433), CGPoint(x: 250, y: 471), color: blue)
|
||||
arrow(CGPoint(x: 164, y: 532), CGPoint(x: 250, y: 497), color: purple)
|
||||
callout("해결 ① 비관적 잠금", "SELECT ... FOR UPDATE로 먼저 행을 잠급니다. 명확하지만 잠금 대기와 deadlock을 관리해야 합니다.",
|
||||
rect: CGRect(x: 408, y: 391, width: 143, height: 92), color: orange)
|
||||
callout("해결 ② 조건부 갱신", "UPDATE ... WHERE status=OPEN 후 rowcount를 확인합니다. 상태 검사와 변경이 원자적으로 일어납니다.",
|
||||
rect: CGRect(x: 408, y: 497, width: 143, height: 92), color: green)
|
||||
callout("격리 수준과 잠금은 만능이 아님", "격리를 높이면 안전성은 커지지만 동시 처리량이 줄고 대기·교착 가능성이 커집니다. 업무 규칙에 맞는 최소 범위의 트랜잭션과 조건부 상태 전이가 실용적입니다.",
|
||||
rect: CGRect(x: margin, y: 624, width: 507, height: 92), color: orange)
|
||||
callout("COMMIT / ROLLBACK", "COMMIT은 변경 확정, ROLLBACK은 현재 트랜잭션의 미확정 변경 취소입니다. 외부 이메일 발송은 DB rollback으로 되돌릴 수 없다는 점도 중요합니다.",
|
||||
rect: CGRect(x: margin, y: 720, width: 507, height: 72), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 7 transaction concept
|
||||
beginPage(ctx, page: 7)
|
||||
sectionTitle("4", "DB 트랜잭션과 동시성 제어", "여러 작업을 하나로 묶고, 동시에 온 요청 중 한 명만 통과시키는 기술", color: orange)
|
||||
callout("트랜잭션이란?", "은행 이체에서 ‘내 계좌 차감’과 ‘상대 계좌 증가’가 둘 다 성공하거나 둘 다 취소되어야 하듯, 관련 DB 변경을 하나의 작업 단위로 묶는 것입니다. 중간에 실패하면 rollback합니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: orange)
|
||||
drawText("동시 마감 문제", CGRect(x: margin, y: 252, width: 200, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("사용자 클릭", "마감 요청 A", rect: CGRect(x: 50, y: 300, width: 110, height: 66), color: blue)
|
||||
node("스케줄러", "마감 요청 B", rect: CGRect(x: 50, y: 405, width: 110, height: 66), color: purple)
|
||||
node("조건부 UPDATE", "status != CLOSED", rect: CGRect(x: 243, y: 350, width: 120, height: 74), color: orange)
|
||||
node("PostgreSQL", "원자적으로 판정", rect: CGRect(x: 430, y: 350, width: 112, height: 74), color: green)
|
||||
arrow(CGPoint(x: 160, y: 333), CGPoint(x: 243, y: 376), color: blue)
|
||||
arrow(CGPoint(x: 160, y: 438), CGPoint(x: 243, y: 399), color: purple)
|
||||
arrow(CGPoint(x: 363, y: 387), CGPoint(x: 430, y: 387), color: orange)
|
||||
callout("승자", "영향받은 행 수(rowcount) = 1\n마감 판정 권한 획득",
|
||||
rect: CGRect(x: 76, y: 518, width: 205, height: 88), color: green)
|
||||
callout("패자/재요청", "rowcount = 0\n이미 닫혔으므로 추가 처리 중단",
|
||||
rect: CGRect(x: 314, y: 518, width: 205, height: 88), color: red)
|
||||
comparison("단순 SELECT 후 UPDATE", "두 요청이 동시에 OPEN을 읽으면 둘 다 마감·낙찰 로직을 실행할 수 있습니다. 이메일도 두 번 발송될 수 있습니다.",
|
||||
"조건부 UPDATE", "DB가 상태 검사와 변경을 한 문장으로 처리합니다. 먼저 성공한 요청만 rowcount=1을 받습니다.",
|
||||
y: 640, color: orange)
|
||||
endPage(ctx)
|
||||
|
||||
// 8 transaction code
|
||||
beginPage(ctx, page: 8)
|
||||
sectionTitle("4", "트랜잭션·동시성 — 실제 코드", "애플리케이션의 if문보다 DB의 원자적 UPDATE가 강한 최종 방어선입니다.", color: orange)
|
||||
codeBox("조건부 상태 전이: 마감 권한 선점",
|
||||
"negodata/backend/crud/quotation_crud.py · lines 482–500",
|
||||
"""
|
||||
query = (
|
||||
update(quotations)
|
||||
.where(
|
||||
quotations.qt_id == qt_id,
|
||||
quotations.status != QuotationStatus.CLOSED.value,
|
||||
quotations.deleted == False,
|
||||
)
|
||||
.values(
|
||||
status=QuotationStatus.CLOSED.value,
|
||||
updated_at=GTime.UTC(),
|
||||
)
|
||||
)
|
||||
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 235), accent: orange)
|
||||
codeBox("트랜잭션 실패 시 rollback",
|
||||
"negodata/backend/common/database/db_session_manager.py · lines 116–127",
|
||||
"""
|
||||
try:
|
||||
await db.commit()
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
return ErrorType.DB_ALREADY_SAME_KEY
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 390, width: 507, height: 175), accent: red)
|
||||
callout("왜 rowcount를 보나요?", "UPDATE가 에러 없이 실행됐다는 사실만으로는 내가 상태를 바꿨는지 알 수 없습니다. WHERE 조건에 맞는 행이 없으면 SQL은 정상 실행되지만 변경 행은 0개입니다. 그래서 1이면 승자, 0이면 이미 다른 요청이 처리한 것으로 판단합니다.",
|
||||
rect: CGRect(x: margin, y: 592, width: 507, height: 108), color: orange)
|
||||
callout("실무 체크", "트랜잭션은 짧게 유지하고, 외부 HTTP·이메일처럼 오래 걸리는 작업을 DB 트랜잭션 안에 오래 붙잡아 두지 않습니다.",
|
||||
rect: CGRect(x: margin, y: 720, width: 507, height: 65), color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 9 — Multitenancy: concept first
|
||||
beginPage(ctx, page: 9)
|
||||
sectionTitle("5", "멀티테넌시 — 개념부터", "하나의 애플리케이션을 여러 고객사가 공유하면서 논리적으로 격리하는 설계", color: purple)
|
||||
callout("Tenant란?", "서비스를 사용하는 독립 고객 단위입니다. 이 프로젝트에서는 주로 ‘회사’가 tenant입니다. 같은 API와 서버를 쓰더라도 회사별 데이터, 설정, 권한, 협상 정책이 섞이면 안 됩니다.",
|
||||
rect: CGRect(x: margin, y: 126, width: 507, height: 88), color: purple)
|
||||
drawText("대표적인 데이터 격리 모델", CGRect(x: margin, y: 242, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
callout("DB 분리", "회사마다 별도 DB\n격리 강함 · 운영비 높음",
|
||||
rect: CGRect(x: margin, y: 282, width: 159, height: 88), color: blue)
|
||||
callout("Schema 분리", "한 DB 안에서 schema 분리\n중간 수준의 격리와 비용",
|
||||
rect: CGRect(x: 218, y: 282, width: 159, height: 88), color: cyan)
|
||||
callout("Row 공유", "같은 테이블 + tenant_id\n효율적 · 쿼리 누락 위험",
|
||||
rect: CGRect(x: 392, y: 282, width: 159, height: 88), color: orange)
|
||||
drawText("격리는 DB만의 문제가 아닙니다", CGRect(x: margin, y: 404, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let tenantAxes: [(String, String)] = [
|
||||
("식별", "이 요청이 어느 회사 것인지 신뢰할 수 있게 결정"),
|
||||
("인가", "그 사용자가 해당 회사 자원에 접근 가능한지 확인"),
|
||||
("데이터", "모든 조회·수정 쿼리에 회사 범위 적용"),
|
||||
("설정", "회사별 정책·브랜딩·카드 선택"),
|
||||
("캐시", "캐시 key에 tenant를 포함해 회사 간 충돌 방지"),
|
||||
("자원", "한 회사의 과부하가 다른 회사에 미치는 영향 제한"),
|
||||
]
|
||||
for (i, t) in tenantAxes.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(444 + (i / 2) * 74)
|
||||
rounded(CGRect(x: x, y: y, width: 247, height: 58), radius: 9,
|
||||
fill: purple.withAlphaComponent(0.06), stroke: purple.withAlphaComponent(0.25))
|
||||
drawText(t.0, CGRect(x: x + 12, y: y + 10, width: 52, height: 18), size: 10,
|
||||
color: purple, weight: .bold)
|
||||
drawText(t.1, CGRect(x: x + 66, y: y + 9, width: 168, height: 38), size: 8.5, color: ink)
|
||||
}
|
||||
callout("가장 흔한 사고", "쿼리의 WHERE tenant_id 조건 누락, 공유 캐시 key에 tenant_id 누락, 사용자가 body로 보낸 tenant_id를 그대로 신뢰하는 경우입니다. 그래서 tenant context를 요청 초기에 확정하고 자동 전달하는 구조가 중요합니다.",
|
||||
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 10 multitenancy concept
|
||||
beginPage(ctx, page: 10)
|
||||
sectionTitle("5", "멀티테넌시", "하나의 시스템을 여러 회사가 쓰되, 설정과 데이터의 경계를 지키는 구조", color: purple)
|
||||
callout("쉬운 비유", "한 오피스 빌딩을 여러 회사가 함께 사용하지만 출입카드가 자기 회사 층만 열어주는 구조입니다. 서버는 공유하되, 요청마다 ‘어느 회사의 요청인지’를 먼저 확정해야 합니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: purple)
|
||||
drawText("요청이 회사별 엔진을 찾는 과정", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("HTTP 요청", "X-Tenant-ID", rect: CGRect(x: 45, y: 310, width: 100, height: 68), color: blue)
|
||||
node("Middleware", "존재·등록 검증", rect: CGRect(x: 195, y: 310, width: 105, height: 68), color: purple)
|
||||
node("request.state", "tenant_id 보관", rect: CGRect(x: 350, y: 310, width: 105, height: 68), color: cyan)
|
||||
arrow(CGPoint(x: 145, y: 344), CGPoint(x: 195, y: 344), color: blue)
|
||||
arrow(CGPoint(x: 300, y: 344), CGPoint(x: 350, y: 344), color: purple)
|
||||
node("Registry", "회사별 엔진 선택", rect: CGRect(x: 195, y: 445, width: 105, height: 68), color: orange)
|
||||
node("TenantEngine", "회사별 정책·카드", rect: CGRect(x: 350, y: 445, width: 105, height: 68), color: green)
|
||||
arrow(CGPoint(x: 402, y: 378), CGPoint(x: 275, y: 445), color: cyan)
|
||||
arrow(CGPoint(x: 300, y: 479), CGPoint(x: 350, y: 479), color: orange)
|
||||
callout("보안 핵심", "tenant_id를 요청 body에서 받으면 사용자가 다른 회사 ID를 넣어 위조할 수 있습니다. 이 프로젝트는 헤더/경로에서 결정한 값을 middleware가 request.state에 넣고, 뒤의 코드가 그것만 사용합니다.",
|
||||
rect: CGRect(x: margin, y: 558, width: 507, height: 105), color: red)
|
||||
comparison("멀티테넌시 경계가 약하면", "A회사 요청이 B회사 카드·설정·협상 엔진을 사용할 수 있습니다. 이는 단순 버그가 아니라 데이터 유출 사고입니다.",
|
||||
"현재 방식", "요청 시작점에서 tenant를 검증하고, Registry가 해당 회사의 설정과 엔진을 해석합니다.",
|
||||
y: 687, color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 11 multitenancy code
|
||||
beginPage(ctx, page: 11)
|
||||
sectionTitle("5", "멀티테넌시 — 실제 코드", "식별 → 검증 → request.state 전달 → 회사별 엔진 선택의 4단계", color: purple)
|
||||
codeBox("Middleware: tenant를 요청 경계에서 확정",
|
||||
"agent/router/middleware/tenant_middleware.py · lines 35–69",
|
||||
"""
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
|
||||
if not tenant_id:
|
||||
return JSONResponse(status_code=400, ...)
|
||||
|
||||
if not tenant_registry.is_registered(tenant_id):
|
||||
return JSONResponse(status_code=404, ...)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
return await call_next(request)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 205), accent: purple)
|
||||
codeBox("Dependency: 검증된 tenant로 엔진 조회",
|
||||
"agent/router/deps.py · lines 13–21",
|
||||
"""
|
||||
async def get_tenant_engine(request: Request) -> TenantEngine:
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
if not tenant_id:
|
||||
raise EXCEPTION_TENANT_HEADER_MISSING
|
||||
return await tenant_registry.get_engine(tenant_id)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 360, width: 507, height: 165), accent: cyan)
|
||||
codeBox("Registry: 프로세스 메모리에서 회사별 엔진 재사용",
|
||||
"agent/tenancy/registry.py · lines 85–98",
|
||||
"""
|
||||
cached = self._engines.get(tenant_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
async with self._locks[tenant_id]:
|
||||
cached = self._engines.get(tenant_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
engine = await self._build(tenant_id)
|
||||
self._engines[tenant_id] = engine
|
||||
return engine
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 550, width: 507, height: 205), accent: orange)
|
||||
endPage(ctx)
|
||||
|
||||
// 12 — Cache consistency: concept first
|
||||
beginPage(ctx, page: 12)
|
||||
sectionTitle("8", "캐시 정합성 — 개념부터", "비싼 계산·DB·외부 호출의 결과를 가까운 곳에 복사해 재사용하는 기술", color: red)
|
||||
drawText("기본 용어", CGRect(x: margin, y: 126, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let cacheTerms: [(String, String, NSColor)] = [
|
||||
("Hit", "캐시에 값이 있어 원본을 읽지 않음", green),
|
||||
("Miss", "값이 없어 원본을 읽고 캐시를 채움", blue),
|
||||
("TTL", "값이 자동 만료될 때까지의 시간", orange),
|
||||
("Stale", "원본은 바뀌었지만 캐시는 옛 값인 상태", red),
|
||||
]
|
||||
for (i, t) in cacheTerms.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(165 + (i / 2) * 75)
|
||||
callout(t.0, t.1, rect: CGRect(x: x, y: y, width: 247, height: 60), color: t.2)
|
||||
}
|
||||
drawText("대표적인 읽기·쓰기 패턴", CGRect(x: margin, y: 338, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
callout("Cache-aside", "앱이 캐시를 먼저 조회하고 miss이면 DB를 읽어 캐시에 저장합니다. 단순하고 가장 흔하지만 무효화를 앱이 책임집니다.",
|
||||
rect: CGRect(x: margin, y: 378, width: 247, height: 92), color: blue)
|
||||
callout("Write-through", "쓰기 때 캐시와 원본을 함께 갱신합니다. 읽기는 안정적이지만 쓰기 지연과 두 저장소의 부분 실패를 다뤄야 합니다.",
|
||||
rect: CGRect(x: 304, y: 378, width: 247, height: 92), color: purple)
|
||||
callout("Write-behind", "캐시에 먼저 쓰고 DB는 나중에 반영합니다. 빠르지만 캐시 장애 시 데이터 유실 위험이 있어 업무 원본에는 신중해야 합니다.",
|
||||
rect: CGRect(x: margin, y: 486, width: 247, height: 92), color: orange)
|
||||
callout("Negative cache", "‘결과 없음’도 잠깐 저장합니다. 반복 실패 비용을 줄이지만 너무 긴 TTL은 새로 생긴 데이터를 늦게 발견하게 합니다.",
|
||||
rect: CGRect(x: 304, y: 486, width: 247, height: 92), color: green)
|
||||
drawText("캐시에서 자주 생기는 문제", CGRect(x: margin, y: 610, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
callout("Invalidation", "원본 변경 후 어떤 key를 언제 삭제·갱신할지 결정하기 어렵습니다.",
|
||||
rect: CGRect(x: margin, y: 650, width: 159, height: 82), color: red)
|
||||
callout("Stampede", "인기 key가 만료되는 순간 많은 요청이 동시에 DB로 몰립니다.",
|
||||
rect: CGRect(x: 218, y: 650, width: 159, height: 82), color: orange)
|
||||
callout("Key 설계", "tenant·버전 등이 빠지면 서로 다른 데이터가 같은 key를 공유합니다.",
|
||||
rect: CGRect(x: 392, y: 650, width: 159, height: 82), color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 13 cache concept
|
||||
beginPage(ctx, page: 13)
|
||||
sectionTitle("8", "캐시 정합성과 무효화", "빠른 복사본이 원본과 다른 값을 갖지 않도록 관리하는 문제", color: red)
|
||||
callout("캐시는 복사본", "도서관 검색대의 메모가 캐시이고, 원본 장부가 DB라고 생각하면 쉽습니다. 메모는 빠르지만 오래된 정보일 수 있습니다. 정합성이란 메모와 장부가 의미상 같은 상태를 유지하는 것입니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 95), color: red)
|
||||
drawText("앵커링 값의 저장·조회 순서", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("1. DB 저장", "조정 이력 COMMIT", rect: CGRect(x: 46, y: 310, width: 118, height: 70), color: purple)
|
||||
node("2. Redis SET", "최신값 + TTL 7일", rect: CGRect(x: 238, y: 310, width: 118, height: 70), color: red)
|
||||
node("3. 다음 조회", "Redis 우선", rect: CGRect(x: 430, y: 310, width: 118, height: 70), color: blue)
|
||||
arrow(CGPoint(x: 164, y: 345), CGPoint(x: 238, y: 345), color: purple)
|
||||
arrow(CGPoint(x: 356, y: 345), CGPoint(x: 430, y: 345), color: red)
|
||||
drawText("Redis 실패", CGRect(x: 240, y: 417, width: 110, height: 18), size: 9, color: red, weight: .bold)
|
||||
arrow(CGPoint(x: 297, y: 380), CGPoint(x: 297, y: 465), color: red)
|
||||
node("DB Fallback", "업무는 계속", rect: CGRect(x: 238, y: 465, width: 118, height: 70), color: green)
|
||||
callout("stale 데이터란?", "DB에는 새 값 60이 저장됐는데 Redis SET이 실패해 캐시에 옛 값 55가 남은 상태입니다. 캐시 miss와 달리 값이 존재하므로 더 위험합니다. TTL과 주간 re-SET으로 회복합니다.",
|
||||
rect: CGRect(x: margin, y: 574, width: 507, height: 95), color: orange)
|
||||
comparison("캐시만 믿으면", "Redis 장애가 업무 장애가 되고, 오래된 값이 실제 제안가를 왜곡할 수 있습니다. Redis 유실 시 원본도 사라집니다.",
|
||||
"원본 DB + 파생 캐시", "Redis 장애 시 DB를 읽고, TTL과 reconciliation으로 오래된 복사본을 교정합니다.",
|
||||
y: 693, color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 14 cache code
|
||||
beginPage(ctx, page: 14)
|
||||
sectionTitle("8", "캐시 정합성 — 실제 코드", "Cache-aside, TTL, DB fallback, reconciliation이 한 세트로 작동합니다.", color: red)
|
||||
codeBox("Cache-aside: Redis miss → DB → Redis backfill",
|
||||
"schedules/anchoring/src/anchoring/reader.py · lines 33–42",
|
||||
"""
|
||||
cached = await get_value(company_id, supplier_type, price_range)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
value = await get_latest_adjusted_value(
|
||||
db, company_id, supplier_type, price_range
|
||||
)
|
||||
if value is None:
|
||||
value = get_base_anchoring_value(price_range)
|
||||
|
||||
await set_value(..., value, nx=True)
|
||||
return value
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 225), accent: red)
|
||||
codeBox("Redis 장애는 cache miss로 취급",
|
||||
"schedules/anchoring/src/anchoring/redis_client.py · lines 69–84",
|
||||
"""
|
||||
try:
|
||||
raw = await _client.get(anchor_key(...))
|
||||
if raw is None:
|
||||
return None
|
||||
return int(raw)
|
||||
except Exception as ex:
|
||||
_note_failure("get", anchor_key(...), ex)
|
||||
return None # 호출측이 DB fallback
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 380, width: 507, height: 180), accent: orange)
|
||||
callout("두 겹의 회복 장치", "TTL 7일은 오래된 키가 영원히 남는 것을 막습니다. 주간 reconciliation은 DB의 최신 조정값을 Redis에 다시 SET하여, DB commit 뒤 Redis 갱신에 실패했던 값도 교정합니다.",
|
||||
rect: CGRect(x: margin, y: 585, width: 507, height: 100), color: red)
|
||||
callout("현재 견적 생성 경로의 예외", "Negodata의 실제 견적 생성은 Redis를 사용하지 않고 PostgreSQL의 anchoring.current_values View를 일괄 조회합니다. Redis는 현재 anchoring 배치 내부 캐시입니다.",
|
||||
rect: CGRect(x: margin, y: 705, width: 507, height: 72), color: cyan)
|
||||
endPage(ctx)
|
||||
|
||||
// 15 — Scheduler and batch: concept first
|
||||
beginPage(ctx, page: 15)
|
||||
sectionTitle("9", "스케줄러·배치 — 개념부터", "시간 규칙으로 작업을 시작하고, 많은 데이터를 사용자 요청 밖에서 처리하는 방식", color: green)
|
||||
callout("둘의 차이", "스케줄러는 ‘언제 실행할지’를 결정합니다. 배치 Job은 ‘무엇을 어떻게 처리할지’를 구현합니다. CronTrigger가 알람시계라면 close_expired_quotations는 알람이 울렸을 때 수행할 실제 업무입니다.",
|
||||
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: green)
|
||||
drawText("Job의 생명주기", CGRect(x: margin, y: 248, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("Trigger", "실행 시각 도달", rect: CGRect(x: 45, y: 297, width: 94, height: 66), color: green)
|
||||
node("Select", "처리 대상 조회", rect: CGRect(x: 181, y: 297, width: 94, height: 66), color: blue)
|
||||
node("Process", "개별 업무 수행", rect: CGRect(x: 317, y: 297, width: 94, height: 66), color: orange)
|
||||
node("Checkpoint", "결과·진행점 기록", rect: CGRect(x: 453, y: 297, width: 94, height: 66), color: purple)
|
||||
arrow(CGPoint(x: 139, y: 330), CGPoint(x: 181, y: 330), color: green)
|
||||
arrow(CGPoint(x: 275, y: 330), CGPoint(x: 317, y: 330), color: blue)
|
||||
arrow(CGPoint(x: 411, y: 330), CGPoint(x: 453, y: 330), color: orange)
|
||||
drawText("운영에서 반드시 결정할 것", CGRect(x: margin, y: 405, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let jobIssues: [(String, String, NSColor)] = [
|
||||
("중복 실행", "이전 Job이 안 끝났는데 다음 시각이 오면?", red),
|
||||
("Misfire", "서버가 꺼져 실행 시각을 놓쳤다면?", orange),
|
||||
("부분 실패", "100건 중 73번째가 실패하면 어디부터 재개?", purple),
|
||||
("재시도", "즉시 재시도, 다음 tick, 운영자 재처리 중 무엇?", blue),
|
||||
("멱등성", "같은 대상을 다시 처리해도 중복 효과가 없는가?", green),
|
||||
("관측성", "처리량·실패 대상·소요시간을 로그와 지표로 남기는가?", cyan),
|
||||
]
|
||||
for (i, j) in jobIssues.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(445 + (i / 2) * 73)
|
||||
callout(j.0, j.1, rect: CGRect(x: x, y: y, width: 247, height: 58), color: j.2)
|
||||
}
|
||||
callout("스케줄러만으로 정확성은 보장되지 않음", "max_instances=1은 한 프로세스 안의 중복을 막을 뿐입니다. 서버가 여러 대면 각 서버가 Job을 실행할 수 있으므로 DB 조건부 갱신, 분산 락, 전용 Worker 같은 추가 방어가 필요합니다.",
|
||||
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 16 scheduler concept
|
||||
beginPage(ctx, page: 16)
|
||||
sectionTitle("9", "스케줄러와 배치 안정성", "사용자 요청 없이 정해진 시간마다 반복 업무를 수행하는 백그라운드 실행", color: green)
|
||||
callout("쉬운 비유", "API가 손님이 주문할 때 움직이는 직원이라면, 스케줄러는 매 5분마다 마감 시간이 지난 주문을 확인하는 당직자입니다. 사람이 요청하지 않아도 시간이 되면 일을 시작합니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: green)
|
||||
drawText("5분 tick의 세 가지 작업", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("CronTrigger", "매 5분", rect: CGRect(x: 48, y: 315, width: 105, height: 68), color: green)
|
||||
node("잡 ①", "기한 지난 견적 마감", rect: CGRect(x: 225, y: 280, width: 135, height: 62), color: orange)
|
||||
node("잡 ②", "협상 완료 견적 마감", rect: CGRect(x: 225, y: 365, width: 135, height: 62), color: purple)
|
||||
node("잡 ③", "LPS 결과 증분 반영", rect: CGRect(x: 225, y: 450, width: 135, height: 62), color: cyan)
|
||||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 311), color: green)
|
||||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 396), color: green)
|
||||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 481), color: green)
|
||||
node("PostgreSQL", "조건부 처리·기록", rect: CGRect(x: 430, y: 365, width: 115, height: 72), color: blue)
|
||||
arrow(CGPoint(x: 360, y: 311), CGPoint(x: 430, y: 385), color: orange)
|
||||
arrow(CGPoint(x: 360, y: 396), CGPoint(x: 430, y: 401), color: purple)
|
||||
arrow(CGPoint(x: 360, y: 481), CGPoint(x: 430, y: 420), color: cyan)
|
||||
callout("중복 실행 방지 장치", "SCHEDULER_ENABLED=1인 프로세스 하나만 잡을 등록합니다. 각 잡은 max_instances=1이고, 밀린 실행은 coalesce=True로 한 번만 실행합니다. 그래도 다중 서버 가능성을 고려해 DB의 조건부 UPDATE가 마지막 방어선입니다.",
|
||||
rect: CGRect(x: margin, y: 565, width: 507, height: 115), color: green)
|
||||
comparison("안정 장치가 없으면", "서버 Worker 수만큼 같은 잡이 실행되고, 같은 견적을 여러 번 마감하거나 알림을 중복 발송할 수 있습니다.",
|
||||
"현재 방식", "실행 프로세스 제한 + 잡 중복 제한 + DB 동시성 가드를 겹쳐 사용합니다.",
|
||||
y: 704, color: green)
|
||||
endPage(ctx)
|
||||
|
||||
// 17 scheduler code
|
||||
beginPage(ctx, page: 17)
|
||||
sectionTitle("9", "스케줄러·배치 — 실제 코드", "‘언제 실행할지’와 ‘무엇을 안전하게 처리할지’를 분리합니다.", color: green)
|
||||
codeBox("APScheduler 등록: 5분, 중복 방지, 지연 허용",
|
||||
"negodata/backend/scheduler/__init__.py · lines 37–69",
|
||||
"""
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
_scheduler.add_job(
|
||||
jobs.close_expired_quotations,
|
||||
CronTrigger(minute="*/5"),
|
||||
id="close_expired_quotations",
|
||||
coalesce=True, # 밀린 실행은 1번만
|
||||
misfire_grace_time=600, # 10분 내 지연 실행 허용
|
||||
max_instances=1, # 같은 잡 동시 실행 금지
|
||||
)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 215), accent: green)
|
||||
codeBox("Job: 대상 조회와 개별 마감 처리를 분리",
|
||||
"negodata/backend/scheduler/jobs.py · lines 39–61",
|
||||
"""
|
||||
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: crud.list_due_for_close(s, now),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return 0
|
||||
|
||||
results = await _close_each(service, qt_ids)
|
||||
return sum(results.values())
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 370, width: 507, height: 190), accent: cyan)
|
||||
callout("멱등성(idempotency)", "같은 잡을 두 번 실행해도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질입니다. 대상 조회가 중복될 수 있어도 claim_for_close의 조건부 UPDATE가 두 번째 처리를 rowcount=0으로 막습니다.",
|
||||
rect: CGRect(x: margin, y: 585, width: 507, height: 105), color: purple)
|
||||
callout("실패한 tick은 어떻게 되나요?", "LPS 동기화는 watermark 기반 증분 처리라 실패한 회차의 데이터가 다음 5분 tick에서 다시 대상이 됩니다. 스케줄러 자체 재시도보다 데이터 설계를 통해 회복합니다.",
|
||||
rect: CGRect(x: margin, y: 710, width: 507, height: 72), color: green)
|
||||
endPage(ctx)
|
||||
|
||||
// 18 combined scenario
|
||||
beginPage(ctx, page: 18)
|
||||
drawText("다섯 기술이 한 장면에서 만나는 순간", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||||
size: 23, color: navy, weight: .bold)
|
||||
drawText("예: 협상이 모두 끝난 견적을 스케줄러가 자동 마감하는 동안 담당자가 수동 마감을 클릭했다.",
|
||||
CGRect(x: margin, y: 88, width: 507, height: 30), size: 10.5, color: muted)
|
||||
let rows: [(String, String, NSColor)] = [
|
||||
("1", "멀티테넌시: 요청의 X-Tenant-ID로 어느 회사의 협상 엔진과 데이터인지 결정", purple),
|
||||
("2", "분산 시스템: Backend가 Agent를 HTTP로 호출할 때 timeout과 부분 실패를 구분", blue),
|
||||
("3", "스케줄러: 5분 tick이 동일 견적을 마감 대상으로 발견", green),
|
||||
("4", "동시성 제어: 수동 요청과 스케줄러 중 조건부 UPDATE를 먼저 성공한 쪽만 처리", orange),
|
||||
("5", "트랜잭션: 관련 상태 변경을 commit하거나, 실패하면 rollback", cyan),
|
||||
("6", "캐시 정합성: 원본 DB commit 후 파생 캐시를 갱신하고 실패 시 다음 회차에 회복", red),
|
||||
]
|
||||
for (i, item) in rows.enumerated() {
|
||||
let y = CGFloat(145 + i * 91)
|
||||
rounded(CGRect(x: margin, y: y, width: 507, height: 70), radius: 12,
|
||||
fill: item.2.withAlphaComponent(0.08), stroke: item.2.withAlphaComponent(0.30))
|
||||
rounded(CGRect(x: 58, y: y + 15, width: 40, height: 40), radius: 20,
|
||||
fill: item.2, stroke: nil)
|
||||
drawText(item.0, CGRect(x: 58, y: y + 24, width: 40, height: 20), size: 13,
|
||||
color: .white, weight: .bold, align: .center)
|
||||
drawText(item.1, CGRect(x: 116, y: y + 15, width: 415, height: 42), size: 10.2,
|
||||
color: ink, weight: .medium, lineSpacing: 3)
|
||||
if i < rows.count - 1 {
|
||||
arrow(CGPoint(x: 78, y: y + 70), CGPoint(x: 78, y: y + 90), color: item.2)
|
||||
}
|
||||
}
|
||||
callout("핵심 관점", "어려운 백엔드 기술은 ‘라이브러리 이름’보다 경계와 실패를 다루는 방법입니다. 네트워크 경계, 회사 경계, 트랜잭션 경계, 캐시의 원본 경계를 명확하게 설계하는 것이 핵심입니다.",
|
||||
rect: CGRect(x: margin, y: 708, width: 507, height: 78), color: navy)
|
||||
endPage(ctx)
|
||||
|
||||
// 19 glossary
|
||||
beginPage(ctx, page: 19)
|
||||
drawText("초보자를 위한 한 줄 사전", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||||
size: 24, color: navy, weight: .bold)
|
||||
let glossary: [(String, String)] = [
|
||||
("분산 시스템", "여러 프로세스·서버가 네트워크로 협력하는 시스템"),
|
||||
("부분 실패", "전체 중 일부 서비스만 실패한 상태"),
|
||||
("Timeout", "응답을 무한히 기다리지 않고 정해진 시간에 포기하는 제한"),
|
||||
("Best-effort", "실패해도 핵심 업무는 성공시키는 보조 작업 정책"),
|
||||
("트랜잭션", "여러 DB 변경을 모두 성공 또는 모두 취소하는 작업 단위"),
|
||||
("Rollback", "실패했을 때 트랜잭션의 변경을 되돌리는 것"),
|
||||
("Race condition", "실행 순서에 따라 결과가 달라지는 동시성 문제"),
|
||||
("원자적 연산", "중간 상태가 보이지 않도록 한 번에 처리되는 연산"),
|
||||
("멀티테넌시", "한 시스템을 여러 고객사가 격리된 상태로 공유하는 구조"),
|
||||
("Cache-aside", "캐시를 먼저 보고, miss이면 원본 조회 후 캐시를 채우는 패턴"),
|
||||
("TTL", "캐시 값이 자동 만료되기까지의 시간"),
|
||||
("Stale", "원본보다 오래되어 현재와 맞지 않는 캐시 상태"),
|
||||
("Invalidation", "원본 변경 시 캐시를 삭제하거나 무효화하는 것"),
|
||||
("Reconciliation", "원본과 복사본을 비교·재적재해 다시 맞추는 작업"),
|
||||
("Scheduler", "정해진 시간 규칙에 따라 작업을 실행하는 도구"),
|
||||
("Batch", "사용자 요청과 별개로 데이터 묶음을 주기적으로 처리하는 작업"),
|
||||
("멱등성", "같은 작업을 반복해도 최종 결과가 달라지지 않는 성질"),
|
||||
]
|
||||
for (i, g) in glossary.enumerated() {
|
||||
let col = i < 9 ? 0 : 1
|
||||
let row = col == 0 ? i : i - 9
|
||||
let x = margin + CGFloat(col) * 260
|
||||
let y = CGFloat(128 + row * 69)
|
||||
drawText(g.0, CGRect(x: x, y: y, width: 230, height: 19), size: 10.5,
|
||||
color: [blue, orange, purple, red, green][i % 5], weight: .bold)
|
||||
drawText(g.1, CGRect(x: x, y: y + 23, width: 230, height: 36), size: 8.8,
|
||||
color: ink, lineSpacing: 2)
|
||||
}
|
||||
callout("추천 복습 순서", "트랜잭션·동시성 → 캐시 정합성 → 스케줄러 → 멀티테넌시 → 분산 시스템 순으로 다시 보면, 작은 DB 작업에서 전체 서비스 구조로 이해가 확장됩니다.",
|
||||
rect: CGRect(x: margin, y: 718, width: 507, height: 74), color: blue)
|
||||
endPage(ctx)
|
||||
|
||||
ctx.closePDF()
|
||||
print(outPath)
|
||||
@ -57,8 +57,7 @@ export interface RefreshTokenResponse {
|
||||
export interface Branding {
|
||||
service_name?: string
|
||||
logo_url?: string
|
||||
primary_color?: string
|
||||
email_header?: string
|
||||
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
|
||||
}
|
||||
|
||||
// 로그인 전(초청 링크 진입) 브랜딩 조회 — GET /v1/auth/session-branding/{session_id}, 인증 불필요
|
||||
@ -66,7 +65,7 @@ export interface SessionBrandingResponse {
|
||||
result: ApiResult
|
||||
service_name: string
|
||||
logo_url: string
|
||||
primary_color: string
|
||||
helpdesk?: string[]
|
||||
}
|
||||
|
||||
// 협상완료 부가정보 필드 정의(companies.settings.session_fields)
|
||||
@ -87,6 +86,7 @@ export interface MeResponse {
|
||||
role: number
|
||||
branding?: Branding
|
||||
session_fields?: SessionField[]
|
||||
guide_notices?: string[]
|
||||
}
|
||||
|
||||
// --- 로그아웃 -------------------------------------------------------------
|
||||
@ -121,6 +121,8 @@ export interface AuthUser {
|
||||
role: number
|
||||
branding: Branding
|
||||
sessionFields: SessionField[]
|
||||
/** 협상 유의사항 항목(회사 설정). 비면 포털 기본 문구를 쓴다 */
|
||||
guideNotices: string[]
|
||||
}
|
||||
|
||||
export function toAuthUser(res: MeResponse): AuthUser {
|
||||
@ -133,5 +135,6 @@ export function toAuthUser(res: MeResponse): AuthUser {
|
||||
role: res.role,
|
||||
branding: res.branding ?? {},
|
||||
sessionFields: res.session_fields ?? [],
|
||||
guideNotices: res.guide_notices ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,8 @@ export interface ChatInitResponse {
|
||||
item_delivery_fee_yn?: boolean
|
||||
custom?: Record<string, unknown>
|
||||
labels?: Record<string, string>
|
||||
reject_reason?: string
|
||||
reject_price?: number | null
|
||||
}
|
||||
|
||||
export interface ChatMessagesResponse {
|
||||
@ -108,5 +110,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
|
||||
quotation_memo: r.quotation_memo ?? '',
|
||||
quotation_end_time: r.quotation_end_time ?? '',
|
||||
labels: r.labels ?? {},
|
||||
reject_reason: r.reject_reason ?? '',
|
||||
reject_price: r.reject_price ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,9 @@ export interface SessionListItem {
|
||||
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
|
||||
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
|
||||
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
|
||||
reject_reason: string // 협상 거부 시 제출한 사유. 거부 건이 아니면 ''
|
||||
reject_price?: number | null // 거부와 함께 낸 공급 희망 가격(원). 미입력이면 null
|
||||
}
|
||||
|
||||
/** 공급사 관점 협상 결과 (sessions 파생) */
|
||||
@ -118,10 +121,13 @@ export interface ParticipateResponse {
|
||||
}
|
||||
|
||||
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
|
||||
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
|
||||
// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정)
|
||||
// 목록의 협상 거부와 채팅 중 협상 거부가 같은 폼(components/RejectPopup)·같은 엔드포인트를 쓴다.
|
||||
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트. 필수.
|
||||
// reject_price/opinion: 선택 입력 — 빈 값이면 아예 보내지 않는다(opinion 은 sessions.custom 에 병합).
|
||||
export interface RejectRequest {
|
||||
reject_reason: string
|
||||
reject_price?: number
|
||||
opinion?: string
|
||||
}
|
||||
|
||||
export interface RejectResponse {
|
||||
|
||||
81
frontend/src/components/RejectDetailPopup.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { numberToKorean } from '@/lib'
|
||||
|
||||
export interface RejectDetailPopupProps {
|
||||
onClose: () => void
|
||||
/** 어느 건인지 식별용 부제 (견적번호 · 상품명) */
|
||||
subtitle?: string
|
||||
/** sessions.reject_reason — 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
|
||||
reason: string
|
||||
/** sessions.reject_price — 미입력이면 null */
|
||||
price: number | null
|
||||
/** sessions.custom.opinion */
|
||||
opinion: string
|
||||
/** 'VAT포함'/'VAT별도' — 품목 VAT 를 아는 화면에서만 넘긴다 */
|
||||
vatLabel?: string
|
||||
}
|
||||
|
||||
// 협상 거부 사유 열람 팝업 — 목록의 '협상완료 부가정보(보기)'와 같은 규격(읽기전용 필드 나열).
|
||||
// 거부는 제출 내역이 대화에 남지 않아 세션 컬럼이 유일한 기록이다.
|
||||
export function RejectDetailPopup({ onClose, subtitle, reason, price, opinion, vatLabel }: RejectDetailPopupProps) {
|
||||
const priceText = price
|
||||
? `${price.toLocaleString()}원 (${numberToKorean(price)}원)${vatLabel ? ` ${vatLabel}` : ''}`
|
||||
: '미입력'
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||
<div className="flex items-center justify-between border-b border-border p-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-neutral-90">협상 거부 사유 (보기)</h3>
|
||||
<p className="mt-0.5 text-xs text-neutral-60">
|
||||
{subtitle}
|
||||
{subtitle ? ' · ' : ''}제출 후에는 수정할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<Field label="거부 사유" value={reason || '-'} />
|
||||
<Field label="공급 희망 가격" value={priceText} />
|
||||
<Field label="의견" value={opinion} rows={2} placeholder="남긴 의견 없음" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t border-border p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// 부가정보 팝업의 읽기전용 필드와 같은 모양(라벨 + disabled 입력).
|
||||
function Field({ label, value, rows, placeholder }: { label: string; value: string; rows?: number; placeholder?: string }) {
|
||||
const style =
|
||||
'w-full rounded-xl border border-border bg-neutral-10 px-3 text-sm text-neutral-60 cursor-not-allowed outline-none'
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-semibold text-neutral-80">{label}</label>
|
||||
{rows ? (
|
||||
<textarea value={value} rows={rows} disabled placeholder={placeholder} className={`${style} resize-none py-2`} />
|
||||
) : (
|
||||
<input type="text" value={value} disabled placeholder={placeholder} className={`${style} h-11`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
frontend/src/components/RejectPopup.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { cn, numberToKorean } from '@/lib'
|
||||
|
||||
const MAX_PRICE = 999999999999999
|
||||
|
||||
// 거부 사유는 협상을 시작하지 않겠다는 사유(단종/품절/기타)다. 협상해보고 합의가 안 된
|
||||
// 결렬 폼(단가인상·수량 포함 5종)과는 성격이 달라 목록을 맞추지 않는다.
|
||||
const REASONS = ['단종', '품절', '기타'] as const
|
||||
|
||||
export interface RejectSubmitPayload {
|
||||
/** 프리셋 라벨 또는 '기타' 직접 입력 텍스트 */
|
||||
reject_reason: string
|
||||
reject_price?: number
|
||||
opinion?: string
|
||||
}
|
||||
|
||||
export interface RejectPopupProps {
|
||||
onClose: () => void
|
||||
onSubmit: (payload: RejectSubmitPayload) => void
|
||||
isPending?: boolean
|
||||
/** 가격 옆 'VAT 별도' 표기 — 품목 VAT 를 아는 화면(채팅)에서만 켠다 */
|
||||
isVatExcluded?: boolean
|
||||
}
|
||||
|
||||
// 협상 거부 팝업 — 목록과 채팅 양쪽이 같은 폼·같은 엔드포인트(/reject)를 쓴다.
|
||||
// 사유는 필수, 공급 희망 가격과 의견은 선택.
|
||||
export function RejectPopup({ onClose, onSubmit, isPending = false, isVatExcluded = false }: RejectPopupProps) {
|
||||
const [selectedReason, setSelectedReason] = useState<string | null>(null)
|
||||
const [customReason, setCustomReason] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [opinion, setOpinion] = useState('')
|
||||
const [showError, setShowError] = useState(false)
|
||||
|
||||
const isEtcOpen = selectedReason === '기타'
|
||||
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim()) || isPending
|
||||
|
||||
const handleReasonClick = (reason: string) => {
|
||||
setShowError(false)
|
||||
if (selectedReason === reason) {
|
||||
setSelectedReason(null)
|
||||
setCustomReason('')
|
||||
} else {
|
||||
setSelectedReason(reason)
|
||||
if (reason !== '기타') setCustomReason('')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePriceChange = (value: string) => {
|
||||
const numeric = value.replace(/\D/g, '')
|
||||
if (numeric && parseInt(numeric) > MAX_PRICE) return
|
||||
setPrice(numeric)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isEtcOpen && !customReason.trim()) {
|
||||
setShowError(true)
|
||||
return
|
||||
}
|
||||
if (isSubmitDisabled || !selectedReason) return
|
||||
onSubmit({
|
||||
reject_reason: isEtcOpen ? customReason.trim() : selectedReason,
|
||||
// 빈 값이면 아예 보내지 않아 컬럼을 건드리지 않는다.
|
||||
...(price ? { reject_price: parseInt(price) } : {}),
|
||||
...(opinion.trim() ? { opinion: opinion.trim() } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const koreanPrice = price && parseInt(price) > 0 ? `[${numberToKorean(parseInt(price))} 원]` : ''
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||
{/* 헤더 */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border p-5">
|
||||
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{REASONS.map((reason) => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
onClick={() => handleReasonClick(reason)}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98] disabled:opacity-50',
|
||||
selectedReason === reason
|
||||
? 'border-brand-600 bg-brand-light text-brand-600'
|
||||
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
|
||||
)}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEtcOpen && (
|
||||
<div className="animate-fade-in">
|
||||
<textarea
|
||||
placeholder="사유를 입력하여 주십시오"
|
||||
value={customReason}
|
||||
onChange={(e) => {
|
||||
setCustomReason(e.target.value)
|
||||
setShowError(false)
|
||||
}}
|
||||
rows={3}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
|
||||
'placeholder:text-neutral-50 focus:ring-1',
|
||||
showError
|
||||
? 'border-destructive focus:border-destructive focus:ring-destructive'
|
||||
: 'border-border focus:border-brand-600 focus:ring-brand-600',
|
||||
)}
|
||||
/>
|
||||
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공급 희망 가격 (선택) — 결렬 폼과 같은 라벨·표기 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm font-bold text-neutral-90">
|
||||
공급 희망 가격 <span className="font-medium text-neutral-60">(선택)</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className={cn(
|
||||
'h-10 max-w-[200px] rounded-xl border px-3 text-right text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||
isPending
|
||||
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
||||
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
||||
)}
|
||||
value={price ? parseInt(price).toLocaleString() : ''}
|
||||
onChange={(e) => handlePriceChange(e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-sm text-neutral-70">원{isVatExcluded && '(VAT 별도)'}</span>
|
||||
{koreanPrice && <span className="whitespace-nowrap text-sm text-neutral-50">{koreanPrice}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 의견 (선택) — 결렬 폼과 동일 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm font-bold text-neutral-90">
|
||||
의견 <span className="font-medium text-neutral-60">(선택)</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={opinion}
|
||||
onChange={(e) => setOpinion(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={255}
|
||||
disabled={isPending}
|
||||
placeholder="추가로 남길 의견이 있으면 작성해 주세요."
|
||||
className={cn(
|
||||
'w-full resize-none rounded-xl border px-3 py-2 text-sm outline-none transition-all placeholder:text-neutral-50',
|
||||
isPending
|
||||
? 'cursor-not-allowed border-border bg-neutral-10 text-neutral-50'
|
||||
: 'border-border bg-white text-neutral-90 focus:border-brand-600 focus:ring-1 focus:ring-brand-600',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex shrink-0 gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
|
||||
>
|
||||
거부 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -7,3 +7,7 @@ export { Logo } from '@/components/Logo'
|
||||
export type { LogoProps, LogoVariant } from '@/components/Logo'
|
||||
export { ErrorPage } from '@/components/ErrorPage'
|
||||
export type { ErrorPageProps } from '@/components/ErrorPage'
|
||||
export { RejectPopup } from '@/components/RejectPopup'
|
||||
export type { RejectPopupProps, RejectSubmitPayload } from '@/components/RejectPopup'
|
||||
export { RejectDetailPopup } from '@/components/RejectDetailPopup'
|
||||
export type { RejectDetailPopupProps } from '@/components/RejectDetailPopup'
|
||||
|
||||
@ -43,9 +43,9 @@ export function usePreLoginBranding(): Branding | null {
|
||||
const next: Branding = {
|
||||
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 가 없어도 이 회사로 보이게 한다
|
||||
})
|
||||
|
||||
@ -7,6 +7,8 @@ import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
||||
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
||||
import { Summary } from '@/features/chat/components/templates/Summary'
|
||||
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
||||
import { RejectSummary } from '@/features/chat/components/templates/RejectSummary'
|
||||
import { RejectedNotice } from '@/features/chat/components/templates/RejectedNotice'
|
||||
|
||||
const AI_LABEL = '아이마켓코리아 (구매담당자)'
|
||||
|
||||
@ -57,6 +59,7 @@ function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }
|
||||
{chats.map((message, index) => (
|
||||
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
|
||||
))}
|
||||
<RejectedNotice />
|
||||
{isLoading && <TypingBubble />}
|
||||
</div>
|
||||
)
|
||||
@ -87,11 +90,16 @@ const MessageItem = memo(function MessageItem({
|
||||
}) {
|
||||
const isBot = message.sender === 'bot'
|
||||
|
||||
// 직전이 reject 폼이면 사용자 답변 말풍선은 숨긴다(폼 자체가 답변을 담고 있음)
|
||||
// 결렬 폼 답변은 제출 문자열(공급희망가격-…, 합의불가사유-…)이라 말풍선 대신 요약 카드로 낸다.
|
||||
// 폼은 세션 종료와 함께 사라지므로 이 카드가 없으면 결렬 사유가 대화에 안 남는다.
|
||||
if (!isBot && currentIndex > 0) {
|
||||
const prev = messages[currentIndex - 1]
|
||||
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
||||
return null
|
||||
return (
|
||||
<div className="mb-5 animate-fade-in">
|
||||
<RejectSummary script={message.script || ''} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import { SessionStatus } from '@/apis'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { ChatMessage } from '@/features/chat/components/ChatMessage'
|
||||
import { UserButton } from '@/features/chat/components/UserButton'
|
||||
import { RejectPopup } from '@/features/chat/components/popup/RejectPopup'
|
||||
import { canRejectNegotiation, GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
||||
import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
|
||||
import type { UserButtonConfig } from '@/features/chat/types'
|
||||
|
||||
// 미참여·협상거부로 끝난 세션은 '결과 보기'(열람) 진입이라 입력을 잠근다.
|
||||
// 마지막 봇 메시지가 입력을 요구하는 상태로 멈춰 있어도(중간 이탈) 서버가 전송을 막으므로 액션바는 복귀 버튼만 둔다.
|
||||
const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED]
|
||||
const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT }
|
||||
|
||||
// 거부 가능한 세션 상태 — 백엔드 /reject 가 허용하는 범위(완료/미참여/거부는 불가)와 같다.
|
||||
const REJECTABLE_STATUSES: number[] = [SessionStatus.CREATED, SessionStatus.IN_PROGRESS]
|
||||
|
||||
export function ChatSection() {
|
||||
const { userButtonConfig } = useChatStore()
|
||||
const { messages, userButtonConfig } = useChatStore()
|
||||
const sessionStatus = useChatInitStore((s) => s.session_status)
|
||||
const [isRejectOpen, setIsRejectOpen] = useState(false)
|
||||
|
||||
const isReadOnly = READ_ONLY_STATUSES.includes(sessionStatus)
|
||||
const config = isReadOnly ? READ_ONLY_CONFIG : userButtonConfig
|
||||
// 단종·품절처럼 대화로 풀 수 없는 사유는 봇의 결렬 선언을 기다릴 수 없다 — 진행 중에도 빠져나갈 길을 둔다.
|
||||
const canReject =
|
||||
!isReadOnly && REJECTABLE_STATUSES.includes(sessionStatus) && canRejectNegotiation(messages, userButtonConfig)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
|
||||
<MobileStepBar />
|
||||
@ -12,8 +35,9 @@ export function ChatSection() {
|
||||
{/* 하단 액션 덱 */}
|
||||
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
|
||||
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||
<UserButton {...userButtonConfig} />
|
||||
<UserButton {...config} onReject={canReject ? () => setIsRejectOpen(true) : undefined} />
|
||||
</div>
|
||||
{isRejectOpen && <RejectPopup onClose={() => setIsRejectOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -110,7 +110,8 @@ function ItemInfo() {
|
||||
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
|
||||
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
|
||||
{renderRow(fieldLabel('item.code', '상품코드'), item_code)}
|
||||
{renderRow(fieldLabel('item.price', '단가'), formattedPrice)}
|
||||
{/* 값은 회사가 고른 협상 기준가(item_price), 호칭은 공급사 화면 고정 용어 — 회사 용어 사전을 타지 않는다. */}
|
||||
{renderRow('공급가', formattedPrice)}
|
||||
{renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)}
|
||||
{renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)}
|
||||
{renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)}
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib'
|
||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||
import { numberToKorean } from '@/lib'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { SelectRadio, SubmitButton } from '@/features/chat/components/templates/rejectControls'
|
||||
import { OtherReason } from '@/features/chat/components/templates/OtherReason'
|
||||
import { findRestoreScript, restoreRejectRSP, extractPart } from '@/features/chat/lib/rejectForm'
|
||||
import {
|
||||
findRestoreScript,
|
||||
restoreRejectRSP,
|
||||
extractPart,
|
||||
REJECT_REASONS,
|
||||
} from '@/features/chat/lib/rejectForm'
|
||||
|
||||
const MAX_PRICE = 999999999999999
|
||||
|
||||
// 합의 불가 사유 프리셋 (rejectRSP 와 동일 목록)
|
||||
const REASONS = [
|
||||
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
||||
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
||||
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
||||
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
||||
]
|
||||
|
||||
// 통일된 협상 결렬 폼 — 최종 제안 단가 + 합의 불가 사유 + 의견.
|
||||
// rejectRSP / rejectCM 두 유형 모두 이 폼 하나로 받는다(액션바 렌더).
|
||||
export function RejectForm() {
|
||||
@ -129,7 +126,7 @@ export function RejectForm() {
|
||||
<div className="flex w-full gap-3">
|
||||
<div className="w-[100px] shrink-0 pt-2 text-sm font-bold text-neutral-90">합의 불가 사유</div>
|
||||
<div className="flex w-full flex-col mt-2 gap-2">
|
||||
{REASONS.map((r) => (
|
||||
{REJECT_REASONS.map((r) => (
|
||||
<SelectRadio
|
||||
key={r.value}
|
||||
text={r.text}
|
||||
|
||||
@ -17,7 +17,8 @@ const style = {
|
||||
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
|
||||
}
|
||||
|
||||
export function UserButton({ type, text, textList, priceErrorMessage }: UserButtonConfig) {
|
||||
// onReject: 협상 거부 진입점. 넘어오면 버튼 덱 끝에 붙는다(입력 단계는 인풋이 넓어 좁은 화면에서 아랫줄로 wrap).
|
||||
export function UserButton({ type, text, textList, priceErrorMessage, onReject }: UserButtonConfig & { onReject?: () => void }) {
|
||||
if (type === '') return null
|
||||
|
||||
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
|
||||
@ -41,8 +42,13 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
||||
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
|
||||
const isInputStep = type === 'percent' || type === 'price'
|
||||
return (
|
||||
<div className={cn('flex w-full justify-center px-6 py-4 max-[1180px]:px-4', !isInputStep && 'overflow-x-auto')}>
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-wrap items-center justify-center gap-3 px-6 py-4 max-[1180px]:px-4',
|
||||
!isInputStep && 'overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{type === 'one-black' && <OneBlack text={text || '확인'} />}
|
||||
{type === 'one-gray' && <OneGray text={text || '확인'} />}
|
||||
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
|
||||
@ -53,6 +59,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
|
||||
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
|
||||
{type === 'loading' && <LoadingDots />}
|
||||
</div>
|
||||
{/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로),
|
||||
좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */}
|
||||
{onReject && (
|
||||
<button
|
||||
className={cn(
|
||||
style.white,
|
||||
'min-[1180px]:absolute min-[1180px]:right-8 min-[1180px]:top-1/2 min-[1180px]:-translate-y-1/2',
|
||||
)}
|
||||
onClick={onReject}
|
||||
>
|
||||
협상 거부
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import { useMeQuery } from '@/apis'
|
||||
|
||||
// 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
|
||||
// 연락처는 회사 설정(companies.settings.branding.helpdesk)에서 온다. 미등록이면 섹션 자체를 숨긴다.
|
||||
export function Contact() {
|
||||
const { data: user } = useMeQuery()
|
||||
const helpdesk = user?.branding?.helpdesk ?? []
|
||||
if (helpdesk.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="w-full pt-4">
|
||||
<div className="border-b border-border pb-2">
|
||||
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60">
|
||||
<span>010-0000-0000</span>
|
||||
<span>o2odev@o2o.kr</span>
|
||||
{helpdesk.map((line) => (
|
||||
<span key={line}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { useMeQuery } from '@/apis'
|
||||
|
||||
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
|
||||
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다.
|
||||
// 항목은 회사 설정(companies.settings.guide_notices)에서 오고, 비어 있으면 아래 기본 문구를 쓴다.
|
||||
const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep'
|
||||
|
||||
// negodata 회사 설정의 "기본 문구 불러오기" 값과 동일해야 한다
|
||||
// (원본: negodata/front/src/features/settings/catalog.ts DEFAULT_GUIDE_NOTICES).
|
||||
// VAT·배송비 조건은 상품마다 달라 기본 문구에서 뺐다 — 필요한 회사가 항목으로 직접 넣는다.
|
||||
const DEFAULT_NOTICES = [
|
||||
'협상 개시는 협상 참여 버튼을 클릭하는 순간부터 시작됩니다.',
|
||||
'부여된 협상 시간에 응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행될 수 있습니다.',
|
||||
'본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.',
|
||||
'협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.',
|
||||
'안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.',
|
||||
]
|
||||
|
||||
function Bullet({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
@ -15,10 +26,8 @@ function Bullet({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function GuideContent() {
|
||||
const { item_vat_yn, item_delivery_fee_yn } = useChatInitStore()
|
||||
// init 미로드/값 없음 → 보수적 기본값 (KT-NEGOWIZ 와 동일한 폴백 규칙)
|
||||
const vat = item_vat_yn || 'VAT별도'
|
||||
const deliveryFee = item_delivery_fee_yn || '배송비별도'
|
||||
const { data: user } = useMeQuery()
|
||||
const notices = user?.guideNotices?.length ? user.guideNotices : DEFAULT_NOTICES
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@ -31,59 +40,30 @@ export function GuideContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1">
|
||||
<Bullet>
|
||||
협상 개시는
|
||||
<span className="font-semibold text-neutral-90">
|
||||
Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작
|
||||
</span>
|
||||
됩니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
부여된 협상 시간에
|
||||
<span className="font-semibold text-neutral-90">
|
||||
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
|
||||
</span>
|
||||
될 수 있습니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
협상에 입력되는 모든 가격은
|
||||
<span className="font-semibold text-negative">
|
||||
{vat} 및 {deliveryFee}
|
||||
</span>
|
||||
기준이며,
|
||||
<span className="font-semibold text-neutral-90">
|
||||
할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산
|
||||
</span>
|
||||
되어 제시됩니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에
|
||||
대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>
|
||||
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.
|
||||
</Bullet>
|
||||
|
||||
<Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet>
|
||||
{notices.map((notice) => (
|
||||
<Bullet key={notice}>{notice}</Bullet>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 하단 문의 안내 박스
|
||||
// 하단 문의 안내 박스 — 연락처는 회사 설정(branding.helpdesk). 미등록이면 박스를 통째로 숨긴다.
|
||||
export function GuideContactBox() {
|
||||
const { data: user } = useMeQuery()
|
||||
const helpdesk = user?.branding?.helpdesk ?? []
|
||||
if (helpdesk.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4">
|
||||
<div className={`${bodyStyle} text-center`}>
|
||||
기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다.
|
||||
</div>
|
||||
<div className="break-keep text-center text-sm font-bold text-neutral-90">
|
||||
헬프데스크 010-0000-0000 · o2odev@o2o.kr
|
||||
</div>
|
||||
{helpdesk.map((line) => (
|
||||
<div key={line} className="break-keep text-center text-sm font-bold text-neutral-90">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
35
frontend/src/features/chat/components/popup/RejectPopup.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { RejectPopup as RejectPopupForm } from '@/components'
|
||||
import { getApiErrorMessage, useRejectMutation } from '@/apis'
|
||||
import { toast } from '@/lib'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
|
||||
// 협상 진행 중 거부 — 폼은 목록 거부와 공용이고, 여기서는 대화 세션에 붙여 제출·이탈만 처리한다.
|
||||
export function RejectPopup({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const reject = useRejectMutation()
|
||||
const sessionId = useChatStore((s) => s.sessionId)
|
||||
const itemVatYn = useChatInitStore((s) => s.item_vat_yn)
|
||||
|
||||
return (
|
||||
<RejectPopupForm
|
||||
onClose={onClose}
|
||||
isPending={reject.isPending}
|
||||
isVatExcluded={itemVatYn === 'VAT별도'}
|
||||
onSubmit={(request) =>
|
||||
reject.mutate(
|
||||
{ sessionId, request },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
toast.warning('협상 거부가 완료되었습니다.')
|
||||
navigate('/list')
|
||||
},
|
||||
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||
import { numberToKorean } from '@/lib'
|
||||
import { useMeQuery } from '@/apis'
|
||||
import type { SessionField } from '@/apis/auth/auth.type'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { numberToKorean } from '@/lib'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { parseRejectSubmission } from '@/features/chat/lib/rejectForm'
|
||||
|
||||
// 결렬 제출 요약 — 타결의 BidSummary 와 대칭.
|
||||
export function RejectSummary({ script }: { script: string }) {
|
||||
// VAT 표기는 회사 설정 기준(item_vat_yn). 미관리(hidden)면 비어 라벨을 생략한다.
|
||||
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
||||
const { price, reasonLabel, reasonDetail, opinion, extras } = parseRejectSubmission(script)
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-[#E5484D]" />
|
||||
<h3 className="text-sm font-bold text-neutral-90">협상 결렬 사유</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60 rounded-xl border border-border">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-sm text-neutral-60">공급 희망 가격</span>
|
||||
<span className="min-w-0 flex-1 break-keep text-right text-sm">
|
||||
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}원</span>
|
||||
<span className="text-neutral-70"> ({numberToKorean(price)}원)</span>
|
||||
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-sm text-neutral-60">합의 불가 사유</span>
|
||||
<span className="min-w-0 flex-1 break-keep text-right">
|
||||
<span className="text-sm font-semibold text-neutral-90">{reasonLabel || '-'}</span>
|
||||
{reasonDetail && <span className="mt-1 block text-xs text-neutral-70">{reasonDetail}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{extras.map((e) => (
|
||||
<Row key={e.label} label={e.label} value={e.value} />
|
||||
))}
|
||||
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
|
||||
<Row label="의견" value={opinion || '-'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
|
||||
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { SessionStatus } from '@/apis'
|
||||
import { numberToKorean } from '@/lib'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
|
||||
// 협상 거부로 끝난 세션의 제출 내역 — 결렬의 RejectSummary 와 같은 카드 규격.
|
||||
// 거부는 팝업으로 내 대화에 남지 않아 대화 끝에 따로 붙인다.
|
||||
export function RejectedNotice() {
|
||||
const sessionStatus = useChatInitStore((s) => s.session_status)
|
||||
const reason = useChatInitStore((s) => s.reject_reason)
|
||||
const price = useChatInitStore((s) => s.reject_price)
|
||||
const custom = useChatInitStore((s) => s.custom)
|
||||
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
|
||||
|
||||
if (sessionStatus !== SessionStatus.REJECTED || !reason) return null
|
||||
|
||||
const opinion = String(custom?.opinion ?? '')
|
||||
|
||||
return (
|
||||
<div className="mb-6 w-full rounded-2xl border-2 border-[#FFD9D9] bg-white p-5 shadow-md">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-[#E5484D]" />
|
||||
<h3 className="text-sm font-bold text-neutral-90">협상 거부 사유</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60 rounded-xl border border-border">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-sm text-neutral-60">공급 희망 가격</span>
|
||||
<span className="min-w-0 flex-1 break-keep text-right text-sm">
|
||||
{price ? (
|
||||
<>
|
||||
<span className="font-bold text-[#E5484D]">{price.toLocaleString()}원</span>
|
||||
<span className="text-neutral-70"> ({numberToKorean(price)}원)</span>
|
||||
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-semibold text-neutral-90">미입력</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Row label="거부 사유" value={reason} />
|
||||
{/* 의견은 타결·결렬·거부 공통 기본 필드 — 값이 없어도 항상 표시 */}
|
||||
<Row label="의견" value={opinion || '-'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-sm text-neutral-60">{label}</span>
|
||||
<span className="min-w-0 flex-1 break-keep text-right text-sm font-semibold text-neutral-90">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
|
||||
import { numberToKorean } from '@/lib'
|
||||
import { formatLeadTime } from '@/features/chat/lib/format'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import type { ChatSummary } from '@/features/chat/types'
|
||||
|
||||
@ -24,8 +24,9 @@ const TERMINAL_CODES = new Set<number>([
|
||||
ErrorCode.NEGO_NOT_FOUND,
|
||||
])
|
||||
|
||||
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 /
|
||||
// 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
|
||||
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / 진입 불가 상태.
|
||||
// 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
|
||||
// 종료된 세션(완료·미참여·거부)은 열람 진입이 허용되므로 여기 걸리지 않는다 — 입력만 잠긴다(ChatSection).
|
||||
const INVALID_ACCESS_CODES = new Set<number>([
|
||||
ErrorCode.NEGO_FORBIDDEN,
|
||||
ErrorCode.NEGO_NOT_FOUND,
|
||||
@ -115,8 +116,8 @@ export function useChatController(sessionId: string) {
|
||||
if (data.message) s.appendMessage(mapMessage(data.message))
|
||||
s.setIsLoading(false)
|
||||
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
|
||||
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에
|
||||
// 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
|
||||
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 방금 종료한 화면의 입력이 열람 모드로
|
||||
// 갈아치워지지 않게 한다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
|
||||
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
|
||||
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||
}
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import type { ChatMessage } from '@/features/chat/types'
|
||||
|
||||
// value = sessions.reject_reason 에 저장되는 값
|
||||
export const REJECT_REASONS = [
|
||||
{ value: '단가인상', text: "'원재료 가격 상승' 또는 '제조사 가격 인상'으로 요청한 공급가격을 맞출 수 없습니다." },
|
||||
{ value: '수량', text: '주문 수량이 적어, 소량 생산 시 발생하는 제조비용으로 맞출 수 없습니다.' },
|
||||
{ value: '단종', text: '현재 단종된 제품으로 물량 수급이 원활하지 않아 가격을 맞출 수 없습니다.' },
|
||||
{ value: '품절', text: '해당 상품이 품절되어 납품할 수 없습니다.' },
|
||||
] as const
|
||||
|
||||
// reject 폼 직전 사용자 답변(script)을 찾아 복원용으로 반환
|
||||
export function findRestoreScript(messages: ChatMessage[], type: 'rejectCM' | 'rejectRSP'): string | null {
|
||||
const reversed = [...messages].reverse()
|
||||
@ -22,3 +30,28 @@ export function restoreRejectRSP(script: string | null) {
|
||||
if (reasonRaw.startsWith('기타-')) return { price, selectedReason: '기타', reason: reasonRaw.replace('기타-', '') }
|
||||
return { price, selectedReason: reasonRaw, reason: '' }
|
||||
}
|
||||
|
||||
// 제출 script → 표시값
|
||||
export function parseRejectSubmission(script: string | null) {
|
||||
const { price, selectedReason, reason } = restoreRejectRSP(script)
|
||||
const preset = REJECT_REASONS.find((r) => r.value === selectedReason)
|
||||
return {
|
||||
price: price ? parseInt(price) : 0,
|
||||
reasonLabel: selectedReason,
|
||||
reasonDetail: selectedReason === '기타' ? reason : (preset?.text ?? ''),
|
||||
opinion: extractPart(script, '의견-'),
|
||||
extras: extraParts(script),
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 폼 이전 제출분의 '배송형태-' 등. 사유 뒤는 자유서술이 섞여 앞 구간만 훑는다.
|
||||
function extraParts(script: string | null): { label: string; value: string }[] {
|
||||
const head = (script ?? '').split(', 의견-')[0].split(', 합의불가사유-')[0]
|
||||
return head
|
||||
.split(', ')
|
||||
.filter((p) => p && !p.startsWith('공급희망가격-'))
|
||||
.map((p) => {
|
||||
const i = p.indexOf('-')
|
||||
return i > 0 ? { label: p.slice(0, i), value: p.slice(i + 1) } : { label: '항목', value: p }
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,6 +2,17 @@ import type { ChatMessage, UserButtonConfig } from '@/features/chat/types'
|
||||
|
||||
export const GO_TO_LIST_TEXT = '상품 목록으로 가기'
|
||||
|
||||
// 협상 거부 진입점을 숨기는 액션바 상태.
|
||||
// reject=봇이 이미 결렬을 선언(그 폼이 최종가를 받는다) / extra-info=타결 후 동의 단계 / loading=응답 대기.
|
||||
const NO_REJECT_TYPES = ['', 'loading', 'reject', 'extra-info']
|
||||
|
||||
// 채팅 안에서 협상을 거부할 수 있는 상태인지 — 대화가 끝나지 않고 협력사 입력을 기다리는 동안만.
|
||||
export function canRejectNegotiation(messages: ChatMessage[], config: UserButtonConfig): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
if (messages[messages.length - 1].chat_end) return false
|
||||
return !NO_REJECT_TYPES.includes(config.type)
|
||||
}
|
||||
|
||||
// 마지막 봇 메시지의 next_input_mode 로 하단 입력 UI 구성을 결정한다.
|
||||
export function deriveUserButtonConfig(
|
||||
messages: ChatMessage[],
|
||||
|
||||
@ -32,6 +32,8 @@ const initialState: ChatInitData = {
|
||||
quotation_end_time: '',
|
||||
custom: {},
|
||||
labels: {},
|
||||
reject_reason: '',
|
||||
reject_price: null,
|
||||
}
|
||||
|
||||
export const useChatInitStore = create<ChatInitStore>((set) => ({
|
||||
|
||||
@ -86,4 +86,6 @@ export type ChatInitData = {
|
||||
quotation_end_time: string
|
||||
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
|
||||
labels: Record<string, string> // 회사 커스텀 라벨(companies.settings.labels). 상품 상세 필드명 치환용
|
||||
reject_reason: string // 협상 거부로 끝난 세션의 제출 사유(의견은 custom.opinion)
|
||||
reject_price: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export function GuidePopup({ onClose }: { onClose: () => void }) {
|
||||
<Row badge="협상 중" tone="prog" text="가격을 조율하는 중입니다. 이어서 진행하세요." />
|
||||
<Row badge="협상 완료" tone="done" text="가격 제출을 마쳤습니다. 최종 결과는 견적 마감 후 아래 '결과'로 표시됩니다." />
|
||||
<Row badge="협상 미참여" tone="none" text="기한 내 참여하지 않아 종료된 건입니다." />
|
||||
<Row badge="협상 거절" tone="reject" text="내가 참여를 거절한 건입니다." />
|
||||
<Row badge="협상 거부" tone="reject" text="내가 거부한 건입니다." />
|
||||
</Section>
|
||||
|
||||
<Section title="협상 결과" desc="견적이 마감된 뒤 정해지는 낙찰 결과입니다. 마감 전에는 표시되지 않습니다.">
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Modal } from '@/components'
|
||||
import { cn } from '@/lib'
|
||||
|
||||
const REASONS = ['단종', '품절', '기타'] as const
|
||||
|
||||
export interface RejectPopupProps {
|
||||
onClose: () => void
|
||||
/** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
|
||||
onSubmit: (reason: string) => void
|
||||
}
|
||||
|
||||
// 거부 사유 입력 팝업 (단종/품절/기타).
|
||||
export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
|
||||
const [selectedReason, setSelectedReason] = useState<string | null>(null)
|
||||
const [customReason, setCustomReason] = useState('')
|
||||
const [showError, setShowError] = useState(false)
|
||||
|
||||
const isEtcOpen = selectedReason === '기타'
|
||||
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
|
||||
|
||||
const handleReasonClick = (reason: string) => {
|
||||
setShowError(false)
|
||||
if (selectedReason === reason) {
|
||||
setSelectedReason(null)
|
||||
setCustomReason('')
|
||||
} else {
|
||||
setSelectedReason(reason)
|
||||
if (reason !== '기타') setCustomReason('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isEtcOpen && !customReason.trim()) {
|
||||
setShowError(true)
|
||||
return
|
||||
}
|
||||
if (isSubmitDisabled || !selectedReason) return
|
||||
onSubmit(isEtcOpen ? customReason.trim() : selectedReason)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-border p-5">
|
||||
<h3 className="text-base font-bold text-neutral-90">거부 사유를 입력해주세요</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{REASONS.map((reason) => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
onClick={() => handleReasonClick(reason)}
|
||||
className={cn(
|
||||
'h-11 rounded-xl border text-sm font-bold transition-all active:scale-[0.98]',
|
||||
selectedReason === reason
|
||||
? 'border-brand-600 bg-brand-light text-brand-600'
|
||||
: 'border-border bg-white text-neutral-70 hover:bg-neutral-10',
|
||||
)}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEtcOpen && (
|
||||
<div className="animate-fade-in">
|
||||
<textarea
|
||||
placeholder="사유를 입력하여 주십시오"
|
||||
value={customReason}
|
||||
onChange={(e) => {
|
||||
setCustomReason(e.target.value)
|
||||
setShowError(false)
|
||||
}}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full resize-none rounded-xl border bg-white p-3 text-sm text-neutral-90 outline-none transition-all',
|
||||
'placeholder:text-neutral-50 focus:ring-1',
|
||||
showError
|
||||
? 'border-destructive focus:border-destructive focus:ring-destructive'
|
||||
: 'border-border focus:border-brand-600 focus:ring-brand-600',
|
||||
)}
|
||||
/>
|
||||
{showError && <p className="mt-1.5 text-xs font-medium text-destructive">기타 사유를 입력해주세요</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-11 flex-1 rounded-xl border border-border bg-white text-sm font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-40"
|
||||
>
|
||||
거부 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
|
||||
import { cn, formatKstDateTime } from '@/lib'
|
||||
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
import { statusMeta, RESULT_META } from '@/features/list/lib/status'
|
||||
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
|
||||
|
||||
interface WorkspaceCardsProps {
|
||||
items: ListItem[]
|
||||
@ -13,10 +13,11 @@ interface WorkspaceCardsProps {
|
||||
onExtraInfo: (item: ListItem) => void
|
||||
onRenegotiate: (item: ListItem) => void
|
||||
onMemo: (item: ListItem) => void
|
||||
onRejectDetail: (item: ListItem) => void
|
||||
}
|
||||
|
||||
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
|
||||
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceCardsProps) {
|
||||
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceCardsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
@ -31,7 +32,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, on
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.map((item) => (
|
||||
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
|
||||
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@ -45,6 +46,7 @@ function Card({
|
||||
onExtraInfo,
|
||||
onRenegotiate,
|
||||
onMemo,
|
||||
onRejectDetail,
|
||||
}: {
|
||||
item: ListItem
|
||||
busy: boolean
|
||||
@ -53,12 +55,16 @@ function Card({
|
||||
onExtraInfo: (item: ListItem) => void
|
||||
onRenegotiate: (item: ListItem) => void
|
||||
onMemo: (item: ListItem) => void
|
||||
onRejectDetail: (item: ListItem) => void
|
||||
}) {
|
||||
const meta = statusMeta(item.session_status)
|
||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||
const ended = isEndedStatus(item.session_status)
|
||||
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
|
||||
const isRejected = item.session_status === '협상거부'
|
||||
const isDone = item.session_status === '협상완료'
|
||||
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
|
||||
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
|
||||
|
||||
@ -112,8 +118,17 @@ function Card({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(canEnter || canReject || isDone || item.renegotiable) && (
|
||||
{(canEnter || canReject || isDone || isRejected || item.renegotiable) && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
{isRejected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRejectDetail(item)}
|
||||
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||
>
|
||||
거부 사유 보기
|
||||
</button>
|
||||
)}
|
||||
{item.renegotiable && (
|
||||
<button
|
||||
type="button"
|
||||
@ -138,7 +153,7 @@ function Card({
|
||||
onClick={() => onReject(item)}
|
||||
className="flex-1 rounded-lg border border-border py-2 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
||||
>
|
||||
거절
|
||||
협상 거부
|
||||
</button>
|
||||
)}
|
||||
{canEnter && (
|
||||
|
||||
@ -3,7 +3,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
|
||||
import { cn, formatKstDateTime } from '@/lib'
|
||||
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
import { statusMeta, RESULT_META } from '@/features/list/lib/status'
|
||||
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
|
||||
|
||||
interface WorkspaceTableProps {
|
||||
items: ListItem[]
|
||||
@ -14,13 +14,14 @@ interface WorkspaceTableProps {
|
||||
onExtraInfo: (item: ListItem) => void
|
||||
onRenegotiate: (item: ListItem) => void
|
||||
onMemo: (item: ListItem) => void
|
||||
onRejectDetail: (item: ListItem) => void
|
||||
}
|
||||
|
||||
// th 기본 정렬은 center — 정렬은 베이스에 넣지 않고 컬럼마다 명시한다(cn 이 tailwind-merge 가 아니라 충돌 시 승자가 불명확).
|
||||
const HEAD = 'px-5 py-3.5 text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
|
||||
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
|
||||
|
||||
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo }: WorkspaceTableProps) {
|
||||
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo, onRenegotiate, onMemo, onRejectDetail }: WorkspaceTableProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] border-collapse">
|
||||
@ -46,7 +47,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, on
|
||||
</StateRow>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} />
|
||||
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} onRenegotiate={onRenegotiate} onMemo={onMemo} onRejectDetail={onRejectDetail} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
@ -63,6 +64,7 @@ function Row({
|
||||
onExtraInfo,
|
||||
onRenegotiate,
|
||||
onMemo,
|
||||
onRejectDetail,
|
||||
}: {
|
||||
item: ListItem
|
||||
busy: boolean
|
||||
@ -71,12 +73,16 @@ function Row({
|
||||
onExtraInfo: (item: ListItem) => void
|
||||
onRenegotiate: (item: ListItem) => void
|
||||
onMemo: (item: ListItem) => void
|
||||
onRejectDetail: (item: ListItem) => void
|
||||
}) {
|
||||
const meta = statusMeta(item.session_status)
|
||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||
const ended = isEndedStatus(item.session_status)
|
||||
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||
// 거부 건은 제출 내역이 대화에 남지 않는다 — 세션에 적힌 사유를 여기서만 다시 볼 수 있다.
|
||||
const isRejected = item.session_status === '협상거부'
|
||||
const isDone = item.session_status === '협상완료'
|
||||
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
||||
@ -144,13 +150,22 @@ function Row({
|
||||
부가정보 보기
|
||||
</button>
|
||||
)}
|
||||
{isRejected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRejectDetail(item)}
|
||||
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
|
||||
>
|
||||
거부 사유 보기
|
||||
</button>
|
||||
)}
|
||||
{canReject && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReject(item)}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs font-bold text-neutral-70 transition-all hover:bg-neutral-10 active:scale-[0.98]"
|
||||
>
|
||||
거절
|
||||
협상 거부
|
||||
</button>
|
||||
)}
|
||||
{canEnter ? (
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
useRequestRenegotiationMutation,
|
||||
useSaveExtraInfoMutation,
|
||||
} from '@/apis'
|
||||
import { RejectDetailPopup, RejectPopup, type RejectSubmitPayload } from '@/components'
|
||||
import { cn, toast } from '@/lib'
|
||||
import { useList } from '@/features/list/hooks/useList'
|
||||
import { useListStore } from '@/features/list/stores/useListStore'
|
||||
@ -19,11 +20,11 @@ import { SortControl } from '@/features/list/components/SortControl'
|
||||
import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
|
||||
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
|
||||
import { Pagination } from '@/features/list/components/Pagination'
|
||||
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
||||
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
||||
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
|
||||
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
|
||||
import { GuidePopup } from '@/features/list/components/GuidePopup'
|
||||
import { isEndedStatus } from '@/features/list/lib/status'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
|
||||
// 상태별 거부 불가 안내
|
||||
@ -67,9 +68,15 @@ export function ListWorkspace() {
|
||||
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
|
||||
const [renegoTarget, setRenegoTarget] = useState<ListItem | null>(null)
|
||||
const [memoTarget, setMemoTarget] = useState<ListItem | null>(null)
|
||||
const [rejectDetailTarget, setRejectDetailTarget] = useState<ListItem | null>(null)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
|
||||
const handleEnter = (item: ListItem) => {
|
||||
// 종료 건(완료·미참여·거부)은 열람 전용 — 참여 API 는 미참여/거부를 막으므로 바로 채팅으로 보낸다.
|
||||
if (isEndedStatus(item.session_status)) {
|
||||
navigate(`/chat?session_id=${item.session_id}`)
|
||||
return
|
||||
}
|
||||
setEnteringId(item.session_id)
|
||||
participate.mutate(item.session_id, {
|
||||
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
|
||||
@ -89,13 +96,16 @@ export function ListWorkspace() {
|
||||
setRejectTarget(item)
|
||||
}
|
||||
|
||||
const handleRejectSubmit = (reason: string) => {
|
||||
const handleRejectSubmit = (request: RejectSubmitPayload) => {
|
||||
if (!rejectTarget) return
|
||||
reject.mutate(
|
||||
{ sessionId: rejectTarget.session_id, request: { reject_reason: reason } },
|
||||
{ sessionId: rejectTarget.session_id, request },
|
||||
{
|
||||
onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
|
||||
onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
|
||||
onSuccess: () => {
|
||||
setRejectTarget(null)
|
||||
toast.warning('협상 거부가 완료되었습니다.')
|
||||
},
|
||||
onError: (error) => toast.error(getApiErrorMessage(error, '거부 처리에 실패했습니다.')),
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -184,6 +194,7 @@ export function ListWorkspace() {
|
||||
onExtraInfo={setExtraTarget}
|
||||
onRenegotiate={setRenegoTarget}
|
||||
onMemo={setMemoTarget}
|
||||
onRejectDetail={setRejectDetailTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
@ -196,6 +207,7 @@ export function ListWorkspace() {
|
||||
onExtraInfo={setExtraTarget}
|
||||
onRenegotiate={setRenegoTarget}
|
||||
onMemo={setMemoTarget}
|
||||
onRejectDetail={setRejectDetailTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -207,7 +219,11 @@ export function ListWorkspace() {
|
||||
{guideOpen && <GuidePopup onClose={() => setGuideOpen(false)} />}
|
||||
|
||||
{rejectTarget && (
|
||||
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
|
||||
<RejectPopup
|
||||
onClose={() => setRejectTarget(null)}
|
||||
onSubmit={handleRejectSubmit}
|
||||
isPending={reject.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{extraTarget && (
|
||||
@ -222,6 +238,16 @@ export function ListWorkspace() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{rejectDetailTarget && (
|
||||
<RejectDetailPopup
|
||||
onClose={() => setRejectDetailTarget(null)}
|
||||
subtitle={`${rejectDetailTarget.qt_number} · ${rejectDetailTarget.item_name}`}
|
||||
reason={rejectDetailTarget.rejectReason}
|
||||
price={rejectDetailTarget.rejectPrice}
|
||||
opinion={String(rejectDetailTarget.custom?.opinion ?? '')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{memoTarget && (
|
||||
<RenegotiationMemoPopup target={memoTarget} onClose={() => setMemoTarget(null)} />
|
||||
)}
|
||||
|
||||
@ -42,5 +42,8 @@ export function toListItem(api: SessionListItem): ListItem {
|
||||
renegotiationStatus: api.renegotiation_status ?? 0,
|
||||
renegotiationMemo: api.renegotiation_memo ?? '',
|
||||
result: api.result ?? 0,
|
||||
hasChat: api.has_chat ?? false,
|
||||
rejectReason: api.reject_reason ?? '',
|
||||
rejectPrice: api.reject_price ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,13 +11,25 @@ export const STATUS_META: Record<string, StatusMeta> = {
|
||||
협상중: { display: '협상 중', badge: 'bg-[#FFF3E5] text-[#F5A623]', dot: 'bg-[#F5A623]' },
|
||||
협상완료: { display: '협상 완료', badge: 'bg-[#EAFDF3] text-success', dot: 'bg-success' },
|
||||
미참여: { display: '협상 미참여', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' },
|
||||
협상거부: { display: '협상 거절', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
|
||||
협상거부: { display: '협상 거부', badge: 'bg-[#FFEBEB] text-[#FF4D4F]', dot: 'bg-[#FF4D4F]' },
|
||||
}
|
||||
|
||||
export function statusMeta(label: string): StatusMeta {
|
||||
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
|
||||
}
|
||||
|
||||
// 종료 상태 — 대화를 이어갈 수 없고 지난 이력만 '결과 보기'로 열람한다(백엔드 send 도 협상중만 허용).
|
||||
const ENDED_STATUSES = ['협상완료', '미참여', '협상거부']
|
||||
|
||||
export function isEndedStatus(label: string): boolean {
|
||||
return ENDED_STATUSES.includes(label)
|
||||
}
|
||||
|
||||
// 채팅 진입 가능 여부. 진행 건은 항상, 종료 건은 볼 대화가 있을 때만 — 초청만 받고 끝난 건은 열어도 빈 화면이다.
|
||||
export function canEnterChat(label: string, hasChat: boolean): boolean {
|
||||
return !isEndedStatus(label) || hasChat
|
||||
}
|
||||
|
||||
// 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다.
|
||||
// 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다.
|
||||
export const RESULT_META: Record<number, { label: string; badge: string }> = {
|
||||
|
||||
@ -13,4 +13,7 @@ export type ListItem = {
|
||||
renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회
|
||||
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
||||
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
|
||||
rejectReason: string // 협상 거부로 끝난 건이 제출한 사유. 거부 건이 아니면 ''
|
||||
rejectPrice: number | null // 거부와 함께 낸 공급 희망 가격(원)
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ export function MainLayout({
|
||||
header,
|
||||
children,
|
||||
}: MainLayoutProps) {
|
||||
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
|
||||
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정
|
||||
const panes = (
|
||||
<>
|
||||
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
||||
|
||||
@ -4,3 +4,4 @@ export type { ClassValue } from '@/lib/cn'
|
||||
export { interactive } from '@/lib/interactive'
|
||||
export { toast } from '@/lib/toast'
|
||||
export { formatKstDateTime, KST_TIME_ZONE } from '@/lib/datetime'
|
||||
export { numberToKorean } from '@/lib/koreanNumber'
|
||||
|
||||
@ -4,7 +4,7 @@ import { Logo } from '@/components'
|
||||
import { LoginForm, usePreLoginBranding } from '@/features/auth'
|
||||
|
||||
export function LoginPage() {
|
||||
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다.
|
||||
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩(서비스명·로고)을 먼저 그린다.
|
||||
const branding = usePreLoginBranding()
|
||||
|
||||
// 이미 로그인된 상태면 목록으로
|
||||
@ -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>
|
||||
|
||||
@ -175,9 +175,9 @@ export function Contact() {
|
||||
<div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Check className="w-6 h-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
|
||||
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">메일 앱에서 문의 내용을 전송해 주세요</h3>
|
||||
<p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
|
||||
{formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
|
||||
도입 문의 메일 작성 창을 열었습니다. 내용을 확인하고 전송해 주시면, {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -238,11 +238,11 @@ const DEMO_TABS: DemoTab[] = [
|
||||
],
|
||||
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
|
||||
mockup: "phone",
|
||||
/* mp4 를 붙이기 전까지 이 탭만 1.7MB GIF(390x780)를 내려받고 있었다. 같은 내용의
|
||||
mp4 가 저장소에 있는데 참조가 빠져 있었다 — 786K, 540x1080 으로 더 가볍고 더 크다.
|
||||
(원본 캡쳐가 좌우로 잘려 문장이 끊기는 문제는 남아 있다. 재촬영이 필요하다.) */
|
||||
/* 현행 공급사 포털(토스풍 UI)에서 실제로 진행한 협상 스크린캐스트. 390x780 모바일 뷰포트로
|
||||
인트로→가격 밀당(협상 카드 4장)→타결(18,480원 합의)까지, 1.5배속. gif 는 무JS 폴백(폭 228). */
|
||||
gif: "/gifs/negotiation.gif",
|
||||
video: "/gifs/negotiation.mp4",
|
||||
poster: "/gifs/negotiation.jpg",
|
||||
gifAlt: "협력사 포털 모바일 협상 화면",
|
||||
fallback: {
|
||||
icon: Smartphone,
|
||||
|
||||
@ -125,12 +125,10 @@ export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
|
||||
데모 신청이 접수되었습니다
|
||||
메일 앱에서 요청을 전송해 주세요
|
||||
</Typography>
|
||||
{/* 폼에서 "Demo를 보내드립니다"라고 약속했으므로 성공 화면도 같은 약속을 지킨다.
|
||||
여기서 "일정을 잡아 연락드립니다"로 새면 방문자가 무엇을 기다려야 하는지 모른다. */}
|
||||
<Typography variant="small">
|
||||
입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
|
||||
데모 요청 메일 작성 창을 열었습니다. 전송해 주시면 입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
/*
|
||||
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
|
||||
*
|
||||
* 목적지는 같은 오리진의 서버리스 함수(api/lead.ts)다. 랜딩은 ssr:false 정적 빌드지만
|
||||
* Vercel 의 /api 디렉터리 함수는 별개로 배포되므로 서버 없이도 POST 를 받을 수 있다.
|
||||
* 같은 오리진이라 엔드포인트 환경변수도, CORS 도 필요 없다.
|
||||
*
|
||||
* 성공을 위조하지 않는다. 예전 상담 신청 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도
|
||||
* 보내지 않았다 — 화면은 "접수되었습니다"인데 영업이 받을 리드는 없었다.
|
||||
* 여기서는 서버가 리드를 확보했을 때만 ok 를 돌려준다.
|
||||
* [임시] 백엔드/서버리스 연결 전까지는 mailto 로 방문자의 메일 앱을 열어 문의를 보내게 한다.
|
||||
* 메일 앱이 '열릴' 뿐 실제 전송 여부는 알 수 없으므로, UI 도 "접수 완료"가 아니라
|
||||
* "메일 앱을 열었습니다 — 전송해 주세요"로 정직하게 안내한다(fake success 금지). 나중에 API/시트로 교체.
|
||||
*/
|
||||
|
||||
const ENDPOINT = "/api/lead"
|
||||
// 리드 수신 주소 — 임시. 실제 영업 주소로 바꾸고, 이후 API 붙이면 mailto 자체를 교체할 것.
|
||||
const LEAD_EMAIL = "o2odev@o2o.kr"
|
||||
|
||||
export type LeadSource = "demo-request" | "contact"
|
||||
|
||||
@ -36,15 +33,26 @@ export function isEmailLike(value: string) {
|
||||
export async function submitLead(lead: Lead): Promise<LeadResult> {
|
||||
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
|
||||
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(lead),
|
||||
})
|
||||
return res.ok ? { ok: true } : { ok: false, reason: "rejected" }
|
||||
} catch {
|
||||
// 로컬 dev(react-router dev)에는 /api 가 없어서 여기로 온다. 확인은 `vercel dev` 로.
|
||||
return { ok: false, reason: "network" }
|
||||
const label = lead.source === "demo-request" ? "데모 요청" : "도입 문의"
|
||||
const body = [
|
||||
`[${label}]`,
|
||||
`회사명: ${lead.company || "-"}`,
|
||||
`담당자: ${lead.name}`,
|
||||
`이메일: ${lead.email}`,
|
||||
`연락처: ${lead.phone || "-"}`,
|
||||
`내용: ${lead.message || "-"}`,
|
||||
].join("\n")
|
||||
const href = `mailto:${LEAD_EMAIL}?subject=${encodeURIComponent(`[${label}] ${lead.company || lead.name}`)}&body=${encodeURIComponent(body)}`
|
||||
|
||||
// 방문자의 메일 앱을 연다 — 앵커 클릭이 location.href 할당보다 핸들러 발동이 확실하다.
|
||||
// 실제 전송은 방문자가 하므로 성공을 단정하지 않는다(호출부 UI 는 안내 문구).
|
||||
if (typeof document !== "undefined") {
|
||||
const a = document.createElement("a")
|
||||
a.href = href
|
||||
a.style.display = "none"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 42 KiB |
@ -64,6 +64,22 @@ export interface RequeueRes {
|
||||
requeued: boolean;
|
||||
}
|
||||
|
||||
/** 몰별 확인 상태 — LPS common.enums.SourceState 와 1:1. 정의는 lps/docs/result-states.md. */
|
||||
export type SourceState =
|
||||
| "matched" // 수집·매칭 성공
|
||||
| "no_match" // 수집됐으나 같은 상품이 아님
|
||||
| "empty" // 검색 결과 자체가 0건
|
||||
| "blocked" // 안티봇 차단 — IP 회전으로 자동 회복
|
||||
| "env_blocked" // 회전 무효 — 사람이 환경/설정을 고쳐야 함
|
||||
| "unavailable" // 전송 실패·가용 IP 없음 등 일시적
|
||||
| "skipped"; // 그 소스를 쓰지 않음
|
||||
|
||||
export interface SourceInfo {
|
||||
state: SourceState;
|
||||
count?: number; // 수집 건수(성공 시)
|
||||
error?: string; // 실패 사유 원문(운영 진단용)
|
||||
}
|
||||
|
||||
export interface ProductItem {
|
||||
product_code: string;
|
||||
display_name?: string;
|
||||
@ -74,6 +90,10 @@ export interface ProductItem {
|
||||
final_lowest?: number;
|
||||
final_source?: string;
|
||||
searches: number;
|
||||
/** 몰별 확인 상태. 운영 화면은 원인까지 봐야 조치를 가른다(검색어 문제 vs IP·환경 문제). */
|
||||
sources?: Record<string, SourceInfo>;
|
||||
/** 못 본 몰이 있어 결과가 최종이 아님 */
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductListRes {
|
||||
@ -105,6 +125,9 @@ export interface PricePoint {
|
||||
coupang_name?: string;
|
||||
coupang_url?: string;
|
||||
by_mall?: MallEntry[];
|
||||
/** 몰별 확인 상태. by_mall 은 가격이 있는 몰만 담으므로 '못 본 몰'은 여기에만 있다. */
|
||||
sources?: Record<string, SourceInfo>;
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export interface PriceHistoryRes {
|
||||
|
||||
62
lps-admin/src/lib/sourceState.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 몰별 확인 상태의 표기 — **운영자용**(상세). 정의는 lps/docs/result-states.md.
|
||||
*
|
||||
* 실사용자 화면(negodata)은 이걸 3가지로 접어서 보여준다. 여기서 접지 않는 이유는
|
||||
* 운영자의 목적이 **진단**이기 때문이다: `blocked`(자동 회복)와 `env_blocked`(사람이 고쳐야 함)를
|
||||
* 뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다.
|
||||
*
|
||||
* 색은 전부 index.css @theme 토큰을 참조한다(raw hex 금지 — 토큰을 바꾸면 여기도 함께 바뀌어야 함).
|
||||
*/
|
||||
import type { SourceState } from "../api/types";
|
||||
|
||||
type Meta = {
|
||||
label: string;
|
||||
color: string;
|
||||
/** 한 줄 설명 — '이게 무슨 뜻이고 내가 뭘 해야 하나' */
|
||||
desc: string;
|
||||
/** 그 몰을 실제로 확인했는가. false 면 '없다'고 말하면 안 된다 */
|
||||
confirmed: boolean;
|
||||
};
|
||||
|
||||
export const SOURCE_STATES: Record<SourceState, Meta> = {
|
||||
matched: {
|
||||
label: "매칭", color: "var(--color-ok-600)", confirmed: true,
|
||||
desc: "수집·매칭 성공 — 가격 확보",
|
||||
},
|
||||
no_match: {
|
||||
label: "같은 상품 없음", color: "var(--color-neutral-400)", confirmed: true,
|
||||
desc: "수집은 됐으나 같은 상품이 아님 — 검색어·규격을 의심할 것",
|
||||
},
|
||||
empty: {
|
||||
label: "결과 0건", color: "var(--color-neutral-400)", confirmed: true,
|
||||
desc: "그 몰의 검색 결과 자체가 0건 — 확인했고 정말 없다",
|
||||
},
|
||||
blocked: {
|
||||
label: "차단", color: "var(--color-warn-600)", confirmed: false,
|
||||
desc: "안티봇 차단 — IP 회전으로 자동 회복된다. 반복되면 IP 풀·예산 확인",
|
||||
},
|
||||
env_blocked: {
|
||||
label: "환경 차단", color: "var(--color-dead-600)", confirmed: false,
|
||||
desc: "회전해도 회복 불가 — 사람이 환경/게이트웨이 설정을 고쳐야 한다",
|
||||
},
|
||||
unavailable: {
|
||||
label: "확인 못함", color: "var(--color-warn-600)", confirmed: false,
|
||||
desc: "전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복",
|
||||
},
|
||||
skipped: {
|
||||
label: "미사용", color: "var(--color-neutral-300)", confirmed: true,
|
||||
desc: "그 소스를 쓰지 않음(폴백 OFF 등)",
|
||||
},
|
||||
};
|
||||
|
||||
/** 미지의 상태도 화면을 깨뜨리지 않는다 — 값 그대로 보여주고 '모름'으로 취급한다. */
|
||||
export const stateMeta = (s: string | undefined): Meta =>
|
||||
SOURCE_STATES[s as SourceState] ?? {
|
||||
label: s || "-", color: "var(--color-ink-400)", desc: "알 수 없는 상태", confirmed: false,
|
||||
};
|
||||
|
||||
/** 못 본 몰 목록 — 결과가 왜 최종이 아닌지 한 줄로 설명할 때 쓴다. */
|
||||
export const unconfirmedMalls = (sources?: Record<string, { state: string }>): string[] =>
|
||||
Object.entries(sources ?? {})
|
||||
.filter(([, v]) => !stateMeta(v?.state).confirmed)
|
||||
.map(([mall]) => mall);
|
||||
@ -6,7 +6,8 @@ import {
|
||||
Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get, post } from "../api/client";
|
||||
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
|
||||
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes, SourceInfo } from "../api/types";
|
||||
import { stateMeta, unconfirmedMalls } from "../lib/sourceState";
|
||||
import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui";
|
||||
import { gridProps, xAxisProps, yAxisProps } from "../lib/chart";
|
||||
import { dateShort, timeAgo, won } from "../lib/format";
|
||||
@ -83,7 +84,17 @@ export default function Products() {
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
|
||||
<span>{p.product_code} · 검색 {p.searches}회</span>
|
||||
<span>{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{/* 못 본 몰이 있으면 이 값은 최종이 아니다 — 가격 옆에서 바로 보여야 오해가 없다 */}
|
||||
{p.partial && (
|
||||
<span className="rounded px-1 font-semibold text-warn-600"
|
||||
style={{ background: "color-mix(in srgb, var(--color-warn-600) 12%, transparent)" }}
|
||||
title={`확인 못한 몰: ${unconfirmedMalls(p.sources).join(", ")}`}>
|
||||
일부 확인 못함
|
||||
</span>
|
||||
)}
|
||||
{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@ -215,6 +226,39 @@ const MALL_COLS = [
|
||||
const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
|
||||
const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타");
|
||||
|
||||
/**
|
||||
* 몰별 확인 상태 — 아래 가격표가 **왜 그렇게 생겼는지**를 설명한다.
|
||||
* 가격표에 없는 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 줄에만 있다.
|
||||
* 운영 화면이라 상태를 접지 않는다: blocked(자동 회복)와 env_blocked(사람이 고쳐야 함)는 조치가 다르다.
|
||||
*/
|
||||
function SourceStates({ sources, partial }: { sources?: Record<string, SourceInfo>; partial?: boolean }) {
|
||||
const entries = Object.entries(sources ?? {});
|
||||
if (entries.length === 0) return null; // 이 컬럼 추가 이전 이력 — 조용히 숨긴다
|
||||
return (
|
||||
<div className="mb-2 shrink-0 space-y-1 rounded border border-line-100 bg-surface-2 px-2 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{entries.map(([mall, info]) => {
|
||||
const m = stateMeta(info?.state);
|
||||
return (
|
||||
<span key={mall} className="inline-flex items-center gap-1 text-[11px]"
|
||||
title={`${m.desc}${info?.error ? `\n\n${info.error}` : ""}`}>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: m.color }} aria-hidden />
|
||||
<span className="font-semibold text-ink-500">{mall}</span>
|
||||
<span style={{ color: m.color }}>{m.label}</span>
|
||||
{info?.count != null && <span className="tnum text-ink-400">{info.count}건</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{partial && (
|
||||
<p className="text-[11px] leading-snug text-warn-600">
|
||||
{unconfirmedMalls(sources).join("·")} 을(를) 확인하지 못했습니다 — 이 최저가는 최종이 아닙니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MallCompare({ point }: { point: PricePoint }) {
|
||||
const [topN, setTopN] = useState<number | "all">(5);
|
||||
const malls = (point.by_mall ?? [])
|
||||
@ -230,6 +274,7 @@ function MallCompare({ point }: { point: PricePoint }) {
|
||||
<p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
|
||||
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> 기준 · 그래프의 시점을 클릭해 이동
|
||||
</p>
|
||||
<SourceStates sources={point.sources} partial={point.partial} />
|
||||
{shown.length === 0 ? (
|
||||
<div className="grid h-full place-items-center"><Empty>몰별 데이터 없음</Empty></div>
|
||||
) : (
|
||||
|
||||
@ -9,13 +9,17 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
# 실제 Chrome(구글 apt 저장소, amd64 전용) + Xvfb + 한글 폰트
|
||||
# 폰트를 넉넉히 깐다: 네이버는 한국 IP·ko-KR 로케일로 접근하는데 브라우저에 한글 폰트가
|
||||
# 나눔 하나뿐이면 폰트 열거 지문이 부자연스럽다. noto-cjk(한중일)·liberation(서구권 표준)까지
|
||||
# 넣어 실사용 데스크톱에 가깝게 맞춘다(이미지 ~100MB 증가, 크롤 성공률과 맞바꿀 가치가 있다).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg \
|
||||
&& wget -qO- https://dl.google.com/linux/linux_signing_key.pub \
|
||||
| gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \
|
||||
> /etc/apt/sources.list.d/google-chrome.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
google-chrome-stable xvfb xauth fonts-nanum ca-certificates \
|
||||
google-chrome-stable xvfb xauth ca-certificates \
|
||||
fonts-nanum fonts-noto-cjk fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@ -43,4 +47,9 @@ HEALTHCHECK --interval=30s --timeout=8s --start-period=120s --retries=3 \
|
||||
|
||||
# Xvfb(가상 디스플레이)를 백그라운드로 띄우고 python 을 exec 로 승계 실행.
|
||||
# → 헤드풀 Chromium 이 :99 에 뜨고, 워커 로그는 그대로 docker logs 로 나온다(xvfb-run 은 로그를 삼킴).
|
||||
CMD ["bash", "-c", "Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; exec python worker_main.py"]
|
||||
#
|
||||
# stale lock 제거가 먼저다(2026-08-04): `docker compose start`(재시작)는 컨테이너 파일시스템을
|
||||
# 그대로 재사용하므로 지난 실행의 /tmp/.X99-lock·/tmp/.X11-unix/X99 가 남는다. Xvfb 는 이걸
|
||||
# '이미 켜진 디스플레이'로 보고 죽고 → DISPLAY 없이 headful Chrome 이 못 떠 **모든 크롤이 조용히 실패**한다
|
||||
# (증상: 워커는 살아있는데 launch_persistent_context 가 'Missing X server or $DISPLAY' 로 실패).
|
||||
CMD ["bash", "-c", "rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; exec python worker_main.py"]
|
||||
|
||||
111
lps/README.md
@ -39,25 +39,111 @@
|
||||
**핵심 포인트**
|
||||
- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림)
|
||||
- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함)
|
||||
- **차단 대응**: IP당 요청 예산(기본 3회)에 닿으면 **차단당하기 전에 IP를 선제 교체**하고(평판 보존 — 그 IP는 로테이션 복귀 시 재사용), 그래도 감지되면 그 포트를 쿨다운 격리 후 다른 IP로 재시도합니다. 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
|
||||
- **차단 대응**: IP당 요청 예산(쿠팡 3회·네이버 10회)에 닿으면 **차단당하기 전에 IP를 선제 교체**하고(평판 보존 — 그 IP는 쉬었다가 복귀), 그래도 감지되면 그 포트를 쿨다운 격리 후 다른 IP로 재시도합니다. 회전해도 소용없는 차단(환경·설정 문제)은 **따로 알아보고 IP를 태우지 않습니다**. 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
|
||||
- **원가 투명**: 검색 1건이 쓴 AI 비용·프록시 대역폭·시간을 함께 기록합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔬 안을 열어보면 — 크롤 · 매칭 · IP 로테이션
|
||||
|
||||
### 1) 소스별 크롤링 방식
|
||||
|
||||
네이버·쿠팡 **둘 다 실제 Chrome(patchright)으로 긁습니다.** 막는 방식이 달라서 세부 전략은 정반대입니다.
|
||||
|
||||
| | 네이버 | 쿠팡 |
|
||||
|---|---|---|
|
||||
| 경로 | 모바일 `msearch.shopping.naver.com` | `www.coupang.com/np/search` |
|
||||
| 왜 이 경로? | 쇼핑 검색 오픈API가 **2026-07-31 영구 종료**(404 `SE05`, 대체 없음). PC 경로는 405+캡차, 내부 API는 418 → 모바일만 열려 있음 | 애초에 공개 API 없음 |
|
||||
| 막는 주체 | **WTM 캡차** | **Akamai Bot Manager** JS 행동 챌린지 |
|
||||
| 리소스 차단 | ❌ **끈다** — 이미지만 막아도 즉시 캡차. '무엇을 막느냐'가 아니라 **요청 가로채기(CDP Fetch) 자체**가 탐지 신호다(검색당 ~3MB 감수) | ✅ 이미지·미디어·폰트·CSS 차단 — 파싱·챌린지에 불필요해서 대역폭만 줄어든다 |
|
||||
| 필수 조건 | **한국 IP**(해외면 2.6KB 하드차단) + 브라우저 로케일 `ko-KR`·`Asia/Seoul` | 프로필 재사용으로 챌린지 쿠키 유지(한 번만 풀면 됨) |
|
||||
| 결과 수집 | 무한스크롤 — 카드가 **더 안 늘 때까지** 바닥으로 내린다(횟수가 아니라 '안 늘어남'이 종료 조건, 상한 6회) → 상위 40건 | `listSize` 파라미터로 한 번에 → 상위 40건 |
|
||||
|
||||
> 로케일 두 줄이 캡차를 가릅니다 — 같은 한국 IP·같은 브라우저에서 `en-US`면 캡차, `ko-KR`이면 정상이었습니다(실측).
|
||||
> 스크롤도 **횟수로 세면 안 됩니다**: 프록시 지연이 있으면 아직 아무것도 안 그려진 화면을 스크롤하고 끝나 40건 나올 페이지에서 14건만 건집니다.
|
||||
|
||||
카드 셀렉터는 클래스명이 webpack 해시(`product_price__O3ZGH`)라 **prefix 매칭**으로만 잡습니다 — 해시가 바뀌어도 안 깨집니다. 한 페이지에 광고·슈퍼적립·브랜드블록 카드가 유기 검색결과와 섞여 나오므로 유기 결과만 골라냅니다.
|
||||
|
||||
### 2) "같은 상품" 판정
|
||||
|
||||
수집한 카드를 그대로 쓰면 최저가가 오염됩니다 — 빨대·커버 같은 액세서리가 끼거나, 60롤 가격을 30롤 최저가로 쓰는 수량 왜곡이 생깁니다. 4단계로 좁힙니다.
|
||||
|
||||
```
|
||||
수집 N건 ──▶ ① 몰 필터 ──▶ ② 가격 밴드 ──▶ ③ 이상치 제거 ──▶ ④ AI 같은상품 판정 ──▶ 최저가
|
||||
(기준가 대비) (IQR) (gpt-4o-mini)
|
||||
```
|
||||
|
||||
①~③은 규칙이고, 판단은 ④가 합니다. AI에게 주는 기준은 **무시할 차이**와 **불일치로 볼 차이**로 갈라놓았습니다:
|
||||
|
||||
| 무시한다 (같은 상품) | 불일치로 본다 (다른 상품) |
|
||||
|---|---|
|
||||
| 판매자·스토어, 색상/향, 사은품, 배송 문구, 상품명 수식어('무형광'·'프리미엄') | **종류**(프라이팬 ≠ 볶음팬 ≠ 웍팬), 브랜드·모델, 용량·크기, **수량**(30롤 1팩 ≠ 30롤 2팩), 액세서리·호환부품 |
|
||||
|
||||
> 예전엔 "포장 차이는 같은 상품"과 "규격이 다르면 불일치"가 같이 있어 모델이 어느 쪽으로도 답할 수 없었습니다. 최저가 관점에선 **수량이 다르면 다른 상품**입니다.
|
||||
|
||||
**후보는 반드시 10건씩 쪼개서 묻습니다.** 37건을 한 번에 넣으면 gpt-4o-mini가 전 항목에 같은 점수를 매기고 **전부 불일치**로 답합니다(`temperature=0`에서 3회 재현). 10건씩 나누면 같은 모델·같은 입력으로 12건이 매칭됐습니다. 배치는 병렬로 던지므로 지연은 1개분입니다.
|
||||
|
||||
매칭이 0건이면 **검색어를 바꿔 최대 3라운드**(원본 → 정밀 → 광역) 돌고, 그래도 없으면 '없음'으로 확정해 일정 시간 캐시합니다(무한 재시도 방지).
|
||||
|
||||
### 3) IP 프록시 로테이션
|
||||
|
||||
DECODO residential 프록시를 씁니다. 여기선 **포트 1개 = sticky 세션 1개**라, **IP를 바꾼다 = 포트를 바꾼다** 입니다.
|
||||
|
||||
```
|
||||
gate.decodo.com:10001-10100 국가 무지정 → 쿠팡
|
||||
kr.decodo.com :10001-10100 한국 전용 → 네이버 (해외 IP면 하드차단)
|
||||
```
|
||||
> 같은 포트 번호라도 **게이트웨이가 다르면 다른 IP**입니다(실측: port 10061 → gate=인도네시아 / kr=한국). 그래서 장부의 키는 (게이트웨이, 포트)입니다.
|
||||
|
||||
**누가 어떤 IP를 쓰는지는 DB(`proxy_port`)가 관리합니다** — 워커 프로세스가 여러 개여도 한 계정을 나눠 쓰기 때문입니다. 잡 큐와 같은 방식으로 `FOR UPDATE SKIP LOCKED`를 써서 **후보 선택과 임대를 한 문장에서** 끝냅니다(두 프로세스가 같은 IP를 동시에 잡을 수 없음). 배정은 **가장 오래 안 쓴 IP(LRU)** 순이라 프로세스가 몇 개든 알아서 골고루 돕니다.
|
||||
|
||||
포트는 세 가지 상태로 묶입니다:
|
||||
|
||||
| 상태 | 언제 | 기간 |
|
||||
|---|---|---|
|
||||
| **임대** | 지금 누가 쓰는 중 | sticky 수명(10분). 프로세스가 죽어도 만료로 자동 회수 — 별도 정리 프로세스 불필요 |
|
||||
| **휴식** | 예산 도달로 **선제 교체**한 IP | sticky 수명. 탄 게 아니라 쉬는 것(곧바로 재사용되면 예산의 의미가 없어짐) |
|
||||
| **쿨다운** | 차단이 확인된 IP | max(sticky, 30분). 누가 태웠든 **전역**으로 적용 |
|
||||
|
||||
IP를 바꾸는 계기는 넷입니다:
|
||||
|
||||
| 계기 | 처리 |
|
||||
|---|---|
|
||||
| **요청 예산 도달** (쿠팡 3회 / 네이버 10회) | 차단당하기 **전에** 선제 교체 — 평판 보존이 목적. 태우지 않고 휴식만 준다 |
|
||||
| **차단 감지** | 그 포트를 쿨다운 격리하고 다른 IP로 인라인 재시도 |
|
||||
| **프록시 전송오류** (407·터널 실패) | 사이트가 아니라 포트가 죽은 것 → 교체 후 재시도 |
|
||||
| **sticky 수명 만료** | 제공자 쪽 세션도 끝났으므로 임대를 놓아주고 새 IP를 받는다 |
|
||||
|
||||
> 예산은 **브라우저가 아니라 IP를 기준으로** 셉니다. 유휴 브라우저 정리(120초)는 브라우저만 닫고 같은 IP로 돌아오기 때문에, 브라우저 기준으로 세면 카운터가 매번 초기화돼 예산이 영영 발화하지 않습니다.
|
||||
|
||||
**태우지 않는 경우가 두 가지** 있습니다. 회전해도 소용없는데 태우면 원인은 그대로인 채 풀만 마르기 때문입니다.
|
||||
|
||||
- **구조적 차단** — 해외 IP로 네이버에 접근한 경우처럼 IP를 바꿔도 결과가 같은 차단. 즉시 실패시키고 "설정을 고치라"고 알립니다.
|
||||
- **환경 차단(서킷브레이커)** — 서로 다른 IP가 **연속 3개 모두 첫 요청부터** 막히면 IP로 설명되지 않습니다(평판 문제라면 몇 개는 통과하고, 과사용이라면 첫 요청이 아니라 뒤쪽에서 막힙니다). 소각을 멈추고 알린 뒤, 검색이 한 번 성공하면 자동으로 풀립니다.
|
||||
|
||||
> 이 판정이 없던 때는 전면 차단 상태에서 **잡 16건이면 100포트가 전부 30분 쿨다운**에 묶였습니다(웜업만으로 워커당 6포트). 지금은 판정 근거로 2개를 쓰고 멈춥니다.
|
||||
|
||||
또 **확신이 없으면 태우지 않습니다.** 결과 0건인데 알려진 차단 마커가 없으면 '페이지가 짧다'는 정황뿐이라, 진짜 검색결과 없음일 수 있습니다. 이럴 땐 IP 교체·재시도까지만 하고 30분 쿨다운은 걸지 않습니다.
|
||||
|
||||
> 더 깊은 내용은 [아키텍처](docs/architecture.md)·[운영 가이드](docs/operations.md)를 보세요.
|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 |
|
||||
| 멀티 소스 검색 | 네이버 모바일 쇼핑(WTM 우회) + 쿠팡(Akamai 우회) 동시 크롤·병합. 오픈API는 2026-07-31 종료돼 **둘 다 크롤** |
|
||||
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`[WorkerConfig].fallbacks`, [배경](docs/decision-openmarket-crawler.md)) |
|
||||
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) |
|
||||
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격·다른 수량 제외). 후보를 10건씩 쪼개 병렬 판정 |
|
||||
| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 |
|
||||
| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 |
|
||||
| 검색 원가 계측 | 검색 1건의 AI 토큰·비용 + DECODO 대역폭(실측 CDP) + 시간을 집계 |
|
||||
| 다중 상품 병렬 | 워커별 브라우저 세트로 여러 상품 동시 검색(`WORKER_CONCURRENCY`) |
|
||||
| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 |
|
||||
| 프록시 IP 선제 회전 | 요청 예산(기본 3회) 도달 시 **차단 전 선제 교체** + 불탄 포트 쿨다운 + 봇 감지·전송오류 즉시 순환 + 시작 웜업(DECODO). 예산 튜닝용 `ip_session` 관측 로그 |
|
||||
| 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용 등 10룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) |
|
||||
| 프록시 IP 로테이션 | 포트 임대를 **DB 장부(`proxy_port`)로 관리** — 프로세스가 여러 개여도 같은 IP 중복 사용 없음(LRU 배정). 예산 도달 시 차단 전 선제 교체 + 불탄 포트 쿨다운 + 전송오류 즉시 순환 + 시작 웜업. 예산 튜닝용 `ip_session` 관측 로그 |
|
||||
| 회전 무효 차단 감지 | 구조적 차단(해외 IP 등)과 **환경 차단 서킷브레이커**(서로 다른 IP 3개가 연속 첫 요청부터 차단)를 구분해 **포트를 태우지 않고** 즉시 알림 — 풀 고갈 방지 |
|
||||
| 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용·회전무효 차단 등 11룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) |
|
||||
| API guard | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방 모드) |
|
||||
|
||||
---
|
||||
@ -108,9 +194,11 @@ docker compose up -d # negosium 스택 + lps-api·lps-worker·lps
|
||||
| 문서 | 대상 | 내용 |
|
||||
|------|------|------|
|
||||
| **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 |
|
||||
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 5종 구조와 코드값(+by_mall·ip_session) |
|
||||
| **[결과 상태 정의](docs/result-states.md)** | 개발자/기획자 | '못 찾음'과 '못 봄'의 구분 — 몰별·상품별 상태 정의와 화면 표기 매핑 |
|
||||
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 6종 구조와 코드값(+by_mall·ip_session·proxy_port 장부) |
|
||||
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 |
|
||||
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 |
|
||||
| **[2026-08-07 세션 기록](docs/2026-08-07-session-notes.md)** | 팀 | 직전 작업 요약 — 결정 근거·미해결(쿠팡 차단)·다음 할 일·주의사항 |
|
||||
| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 |
|
||||
|
||||
---
|
||||
@ -126,15 +214,16 @@ lps/
|
||||
├── run_local_worker.sh # 로컬 워커 실행 (대화형: 동시성·프로필)
|
||||
├── run_docker.sh # lps 서브셋 Docker 실행 (대화형: 설정 검증·guard 안전장치 + admin 포함)
|
||||
├── run_loadtest_gui.sh # 부하 테스트 Locust 웹 UI(:8089) 실행 (대화형)
|
||||
├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋; 배포는 env 주입)
|
||||
├── config/ # 설정·시크릿(config.local.toml 하나 — 미커밋. 컨테이너엔 마운트, env 는 DB 접속점만 override)
|
||||
├── common/ # 공통(enums, DB 세션, 모델, 로거, alerts=임계 알림 관리자)
|
||||
│ └── database/model/models.py # DB 테이블 정의
|
||||
├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계)
|
||||
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session)
|
||||
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session, port_lease=IP 임대 장부)
|
||||
├── services/
|
||||
│ ├── search/ # 소스 어댑터 (coupang, naver, esm=G마켓·옥션, st11=11번가)
|
||||
│ │ ├── browser_base.py # patchright 공통(수명·프록시회전·차단감지·CDP 바이트계측)
|
||||
│ │ ├── proxy.py # DECODO(IP 회전·포트 쿨다운·프리플라이트)
|
||||
│ ├── search/ # 소스 어댑터 (coupang, naver_shop=네이버 크롤, esm=G마켓·옥션, st11=11번가)
|
||||
│ │ ├── browser_base.py # patchright 공통(브라우저 수명·IP 세션·차단감지/서킷브레이커·CDP 바이트계측)
|
||||
│ │ ├── proxy.py # DECODO(포트=IP 임대·회전·쿨다운/휴식·프리플라이트)
|
||||
│ │ ├── profile_slot.py # Chrome 프로필 슬롯 배타 선점(프로세스 여러 개 대응)
|
||||
│ │ └── card_parser.py # 오픈마켓 공용 카드 파서
|
||||
│ ├── pipeline/ # 필터·이상치·최저가 정렬(+몰별 분해)
|
||||
│ ├── ai/ # AI 유사도 판정·검색어 생성 (OpenAI)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Column, Index, Integer, SmallInteger, String, Text, DateTime
|
||||
from sqlalchemy import Boolean, Column, Index, Integer, Numeric, SmallInteger, String, Text, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.sql import text
|
||||
@ -93,9 +93,28 @@ class price_history(MAIN_BASE):
|
||||
coupang_url = Column(Text, nullable=True)
|
||||
final_lowest = Column(Integer, nullable=True) # 전체 최저가(Y축 핵심)
|
||||
final_source = Column(String(20), nullable=True) # 최종 최저가 소스
|
||||
# 최저가 오퍼의 신뢰 신호 — '이 가격에 실제로 살 수 있나'를 사후 판단·분석하기 위함.
|
||||
# 둘 다 NULL = 리뷰·평점이 없는 오퍼(재고 없는 미끼가격일 수 있음). 0 과 NULL 은 다른 뜻이다.
|
||||
final_rating = Column(Numeric(3, 2), nullable=True) # 평점(5점 만점)
|
||||
final_review_count = Column(Integer, nullable=True) # 리뷰 수
|
||||
# 최저가 오퍼의 배송 정보. 순위는 상품가로 매기지만(배송 주체가 다르면 금액 비교가 무의미),
|
||||
# **기록은 남긴다** — 나중에 '배송비까지 더하면 순위가 뒤집히나'를 데이터로 물을 수 있어야 한다.
|
||||
final_shipping_fee = Column(Integer, nullable=True) # 0=무료, NULL=미확인(조건부)
|
||||
final_shipping_type = Column(String(20), nullable=True) # free/paid/rocket/rocket_merchant
|
||||
final_shipping_label = Column(String(120), nullable=True) # 화면 문구 원문
|
||||
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
|
||||
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
|
||||
by_mall = Column(JSONB, nullable=True)
|
||||
# ── 몰별 '확인했는가' ─────────────────────────────────────────────────────
|
||||
# by_mall 은 **가격이 있는 몰만** 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지
|
||||
# '거기를 못 봤다'인지 알 수 없었다 — 안 본 걸 없다고 말하는 셈이었다.
|
||||
# {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
|
||||
# state 값은 common.enums.SourceState (정의·표기 규칙은 docs/result-states.md).
|
||||
sources = Column(JSONB, nullable=True)
|
||||
# 결과가 완전한가. True = 못 본 몰이 있어 이 값이 최종이 아니다.
|
||||
# sources 에서 유도 가능하지만 컬럼으로 둔다 — 소비자가 '어떤 상태가 확인된 것인가'라는
|
||||
# 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 여기서 끝내고 사실만 넘긴다.
|
||||
partial = Column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
||||
|
||||
@ -152,3 +171,42 @@ class bot_detection(MAIN_BASE):
|
||||
headless = Column(Boolean, nullable=True)
|
||||
html_len = Column(Integer, nullable=True) # 응답 길이(차단 페이지는 작음)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
||||
|
||||
|
||||
class proxy_port(MAIN_BASE):
|
||||
"""프록시 포트(=IP 세션) 임대 장부 — **프로세스 간 공유 상태**.
|
||||
|
||||
한 DECODO 계정을 여러 프로세스(워커 컨테이너·PROCESS_COUNT)가 나눠 쓰기 때문에
|
||||
임대·쿨다운·휴식을 프로세스 메모리에 두면 서로의 상태를 모른다. 같은 IP 를 동시에
|
||||
잡거나, 한쪽이 태운 IP 를 다른 쪽이 곧바로 집는다. 그래서 DB 를 단일 진실로 둔다.
|
||||
|
||||
행 1개 = 게이트웨이의 포트 1개(=sticky IP 세션 1개). 상태는 세 시각으로만 표현한다:
|
||||
leased_until 임대 중(만료되면 자동 해제 — 프로세스가 죽어도 IP 가 영구히 묶이지 않는다)
|
||||
rest_until 선제 회전으로 쉬는 중(탄 게 아님)
|
||||
cooldown_until 차단당해 격리 중
|
||||
셋 다 지났으면 가용. 회전은 last_used_at 오래된 순(LRU)이라 프로세스가 늘어도
|
||||
전체가 자연스럽게 한 바퀴씩 돈다.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def DBType():
|
||||
return DBType.MAIN.value
|
||||
|
||||
__tablename__ = "proxy_port"
|
||||
|
||||
host = Column(String(80), primary_key=True) # 게이트웨이(gate/kr — 같은 번호라도 IP 가 다름)
|
||||
port = Column(Integer, primary_key=True)
|
||||
owner = Column(String(80), nullable=True) # 현재 임대자(worker/소스 식별)
|
||||
leased_until = Column(DateTime(timezone=True), nullable=True) # 임대 만료(=sticky 수명)
|
||||
rest_until = Column(DateTime(timezone=True), nullable=True) # 휴식 만료(선제 회전)
|
||||
cooldown_until = Column(DateTime(timezone=True), nullable=True) # 쿨다운 만료(차단)
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True) # 마지막 임대 시각(LRU 회전 기준)
|
||||
last_reason = Column(String(40), nullable=True) # 마지막 상태 변경 사유(block/budget/window…)
|
||||
use_count = Column(Integer, nullable=False, server_default=text("0")) # 누적 임대 횟수(관측)
|
||||
burn_count = Column(Integer, nullable=False, server_default=text("0")) # 누적 차단 횟수(불량 IP 슬롯 식별)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"), onupdate=text("now()"))
|
||||
|
||||
__table_args__ = (
|
||||
# acquire 정렬/필터용 — 가용 판정(3개 시각)과 LRU 정렬을 한 인덱스로 태운다.
|
||||
Index("ix_proxy_port_pick", "host", "last_used_at"),
|
||||
)
|
||||
|
||||
@ -69,3 +69,26 @@ class JobType(Enum):
|
||||
|
||||
SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움
|
||||
OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움
|
||||
|
||||
|
||||
class SourceState(Enum):
|
||||
"""한 상품을 **한 몰에서** 찾은 결과. 정의·표기 규칙은 docs/result-states.md 가 소스다.
|
||||
|
||||
가장 중요한 경계는 `확인함` 과 `못 봄` 사이다:
|
||||
MATCHED·NO_MATCH·EMPTY 그 몰을 실제로 봤다 → "없다"고 말해도 되는 사실
|
||||
BLOCKED·ENV_BLOCKED·UNAVAILABLE 못 봤다 → "없다"고 말하면 거짓이 된다
|
||||
이 경계를 잃으면 '차단당해 못 본 것'이 '그 몰엔 없음'으로 둔갑한다(실측 문제).
|
||||
"""
|
||||
|
||||
MATCHED = 1 # 수집·매칭 성공 — 가격 확보
|
||||
NO_MATCH = 2 # 수집은 됐으나 같은 상품이 없음(액세서리·다른 규격만)
|
||||
EMPTY = 3 # 그 몰의 검색 결과 자체가 0건
|
||||
BLOCKED = 4 # 안티봇 차단 — IP 회전으로 회복 가능(자동)
|
||||
ENV_BLOCKED = 5 # 회전해도 안 되는 차단(환경·게이트웨이 설정) — 사람이 고쳐야 함
|
||||
UNAVAILABLE = 6 # 전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복
|
||||
SKIPPED = 7 # 그 소스를 아예 쓰지 않음(폴백 OFF 등)
|
||||
|
||||
@property
|
||||
def confirmed(self) -> bool:
|
||||
"""그 몰을 **실제로 확인했는지**. False 면 '없다'고 단정하면 안 된다."""
|
||||
return self in (SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY)
|
||||
|
||||