와일들카드 선택 수정 .

This commit is contained in:
hbyang 2026-07-07 17:16:19 +09:00
parent 8c700b1e08
commit 15f3d10eb6
11 changed files with 266 additions and 10 deletions

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,30 @@
ID,??MD,?????,?????,??? ???,????,???,???,????,????,???,???,1???,1? ?? ???,1? ????,????,2???,2? ?? ???,2? ????,????,3???,3? ?? ???,3? ????,????,4???,,,,,
1,,???,,,AT2025042400050,?????,"163,600",??,None (??),"8,180","8,099",,,,,,,,,,,,,,,,,,
2,,???,,,AT2025042200057,????,"350,000",??,Single (??),"35,000","33,600",35000,0,?? ?? ?? ??,o,34900,0.00286533,??? ?? ?? ??,o,33700,0.038575668,??? ?? ?? ??,x,33600,,,,,
3,,???,,,AT2025042300003,TV???,"6,790,000",??,Multiple (??),"48,500","46,560",50000,0,?? ?? ?? ??,x,46560,,?? ??,,,,,,,,,,,
4,,???,,,AT2025042200041,?????,"18,700,000",??,Multiple (??),"1,804,550","1,678,232",2200000,0,?? ?? ?? ??,x,2000000,0.1,?? ?? ?? ??,,1700000,0.294117647,??? ?? ?? ??,,1700000,,,,,
5,,???,,,AT2025041600034,???,"760,320",??,Multiple (??),"73,304","70,372",70000,,?? ??,,,,,,,,,,,,,,,
6,,???,,,AT2025042400010,????? ??,"780,000",??,Multiple (??),"75,642"," 72,617 ",,,,,,,,,,,,,,,,,,
7,,???,,,AT2025042400030,???,"313,000",??,Multiple (??),"31,300","29,735",50000,0,?? ?? ?? ??,,40000,0.25,?? ?? ?? ??,,30500,0.639344262,??? ?? ?? ??,,30500,,,,,
8,,???,,,AT2025042300096,?????,"706,800",??,None (??),"68,162","65,436",115800,0,?? ?? ?? ??,,70000,0.654285714,?? ?? ?? ??,,69000,0.67826087,??? ?? ?? ??,,65400,,,,,
9,,???,,,AT2025042300082,??????,"12,000",??,Multiple (??),"1,200","1,188",,,,,,,,,,,,,,,,,,
2.1,,???,ID 2 ???? ??(??>??),,AT2025042200057,????,"350,000",??,Single (??),"35,000"," 33,250 ",,,,,,,,,,,,,,,,,,
6.1,,???,ID 6 ???? ??(??>??),,AT2025042400010,????? ??,780000,??,Multiple (??),"75,642"," 71,860 ",,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,1,AT2025042400050,SOOT777,?????,,,,,,,,,,,,,,,,,,,,,,
,,,,2,AT2025042200057,JAJE89,????,,,,,,,,,,,,,,,,,,,,,,
,,,,3,AT2025042300003,VOV2,TV???,,,,,,,,,,,,,,,,,,,,,,
,,,,4,AT2025042200041,HEMEELY,?????,,,,,,,,,,,,,,,,,,,,,,
,,,,5,AT2025041600034,KMS5939,???,,,,,,,,,,,,,,,,,,,,,,
,,,,6,AT2025042400010,YTS111,????? ??,,,,,,,,,,,,,,,,,,,,,,
,,,,7,AT2025042400030,SJUN98,???,,,,,,,,,,,,,,,,,,,,,,
,,,,8,AT2025042300096,DREAMBIZ,?????,,,,,,,,,,,,,,,,,,,,,,
,,,,9,AT2025042300082,BWTCO40,??????,,,,,,,,,,,,,,,,,,,,,,
,,,,2.1,AT2025042200057,JAJE89,????,,,,,,,,,,,,,,,,,,,,,,
,,,,6.1,AT2025042400010,YTS111,????? ??,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
1 ID ??MD ????? ????? ??? ??? ???? ??? ??? ???? ???? ??? ??? 1??? 1? ?? ??? 1? ???? ???? 2??? 2? ?? ??? 2? ???? ???? 3??? 3? ?? ??? 3? ???? ???? 4???
2 1 ??? AT2025042400050 ????? 163,600 ?? None (??) 8,180 8,099
3 2 ??? AT2025042200057 ???? 350,000 ?? Single (??) 35,000 33,600 35000 0 ?? ?? ?? ?? o 34900 0.00286533 ??? ?? ?? ?? o 33700 0.038575668 ??? ?? ?? ?? x 33600
4 3 ??? AT2025042300003 TV??? 6,790,000 ?? Multiple (??) 48,500 46,560 50000 0 ?? ?? ?? ?? x 46560 ?? ??
5 4 ??? AT2025042200041 ????? 18,700,000 ?? Multiple (??) 1,804,550 1,678,232 2200000 0 ?? ?? ?? ?? x 2000000 0.1 ?? ?? ?? ?? 1700000 0.294117647 ??? ?? ?? ?? 1700000
6 5 ??? AT2025041600034 ??? 760,320 ?? Multiple (??) 73,304 70,372 70000 ?? ??
7 6 ??? AT2025042400010 ????? ?? 780,000 ?? Multiple (??) 75,642 72,617
8 7 ??? AT2025042400030 ??? 313,000 ?? Multiple (??) 31,300 29,735 50000 0 ?? ?? ?? ?? 40000 0.25 ?? ?? ?? ?? 30500 0.639344262 ??? ?? ?? ?? 30500
9 8 ??? AT2025042300096 ????? 706,800 ?? None (??) 68,162 65,436 115800 0 ?? ?? ?? ?? 70000 0.654285714 ?? ?? ?? ?? 69000 0.67826087 ??? ?? ?? ?? 65400
10 9 ??? AT2025042300082 ?????? 12,000 ?? Multiple (??) 1,200 1,188
11 2.1 ??? ID 2 ???? ??(??>??) AT2025042200057 ???? 350,000 ?? Single (??) 35,000 33,250
12 6.1 ??? ID 6 ???? ??(??>??) AT2025042400010 ????? ?? 780000 ?? Multiple (??) 75,642 71,860
13
14
15
16
17 1 AT2025042400050 SOOT777 ?????
18 2 AT2025042200057 JAJE89 ????
19 3 AT2025042300003 VOV2 TV???
20 4 AT2025042200041 HEMEELY ?????
21 5 AT2025041600034 KMS5939 ???
22 6 AT2025042400010 YTS111 ????? ??
23 7 AT2025042400030 SJUN98 ???
24 8 AT2025042300096 DREAMBIZ ?????
25 9 AT2025042300082 BWTCO40 ??????
26 2.1 AT2025042200057 JAJE89 ????
27 6.1 AT2025042400010 YTS111 ????? ??
28
29
30

View File

@ -28,7 +28,31 @@ _SESSIONS = table(
)
_ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner")
_SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table("quotations", column("qt_id"), column("supplier_type"), column("deleted"), schema="quotation")
_QUOTATIONS = table(
"quotations",
column("qt_id"), column("version_id"), column("supplier_type"), column("deleted"),
schema="quotation",
)
_VERSION_NEGO_CARDS = table(
"version_nego_cards",
column("version_id"), column("nego_card_id"), column("created_at"), column("deleted"),
schema="card",
)
_VERSION_WILD_CARDS = table(
"version_wild_cards",
column("version_id"), column("wild_card_id"), column("created_at"), column("deleted"),
schema="card",
)
_NEGO_CARDS = table(
"nego_cards",
column("nego_card_id"), column("number"), column("deleted"),
schema="card",
)
_WILD_CARDS = table(
"wild_cards",
column("wild_card_id"), column("number"), column("deleted"),
schema="card",
)
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
_SUPPLIER_ITEMS = table(
"supplier_items",
@ -74,6 +98,11 @@ class INegoContextCRUD(ABC):
"""상품에 연결된 협력사 수 — 협상 세션 이력 기준(supplier_items 매핑 부재 시 폴백)."""
pass
@abstractmethod
async def get_quotation_card_numbers(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, tuple[list[str], list[str]]]:
"""견적 version_id 에 연결된 (일반카드 번호 목록, 와일드카드 번호 목록). 없으면 빈 목록."""
pass
class NegoContextCRUD(INegoContextCRUD):
async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
@ -181,3 +210,63 @@ class NegoContextCRUD(INegoContextCRUD):
except Exception as ex:
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]]]:
try:
version_q = (
select(_QUOTATIONS.c.version_id)
.where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, version_q, "get_quotation_version failed.", raise_error=False)
if err_type != ErrorType.SUCCESS:
return err_type, ([], [])
if not rows or rows[0] is None:
return ErrorType.SUCCESS, ([], [])
version_id = rows[0]
nego_q = (
select(_NEGO_CARDS.c.number)
.select_from(
_VERSION_NEGO_CARDS.join(
_NEGO_CARDS,
_VERSION_NEGO_CARDS.c.nego_card_id == _NEGO_CARDS.c.nego_card_id,
)
)
.where(
_VERSION_NEGO_CARDS.c.version_id == version_id,
_VERSION_NEGO_CARDS.c.deleted == False, # noqa: E712
_NEGO_CARDS.c.deleted == False, # noqa: E712
)
.order_by(_VERSION_NEGO_CARDS.c.created_at)
)
n_err, n_rows = await DB_SESSION_MNG.execute(cdb, nego_q, "get_quotation_nego_cards failed.", raise_error=False)
if n_err != ErrorType.SUCCESS:
return n_err, ([], [])
wild_q = (
select(_WILD_CARDS.c.number)
.select_from(
_VERSION_WILD_CARDS.join(
_WILD_CARDS,
_VERSION_WILD_CARDS.c.wild_card_id == _WILD_CARDS.c.wild_card_id,
)
)
.where(
_VERSION_WILD_CARDS.c.version_id == version_id,
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
_WILD_CARDS.c.deleted == False, # noqa: E712
)
.order_by(_VERSION_WILD_CARDS.c.created_at)
)
w_err, w_rows = await DB_SESSION_MNG.execute(cdb, wild_q, "get_quotation_wild_cards failed.", raise_error=False)
if w_err != ErrorType.SUCCESS:
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],
)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, ([], [])

View File

@ -146,7 +146,15 @@ class ChatEngine:
cond = c.get("condition")
ok = False
if cond == "check_wildcard_entry":
ok = (not ctx.get("wildcard_used")) and anchor < price <= anchor * 1.05
ok = (
(not ctx.get("wildcard_used"))
and anchor > 0
and anchor < price
and (
price <= anchor * 1.02
or (bool(ctx.get("allow_selected_wildcards", True)) and price <= anchor * 1.05)
)
)
elif cond == "check_is_supplier_type_c":
ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
@ -176,6 +184,8 @@ class ChatEngine:
if anchor > 0 and price <= anchor * 1.02:
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가
return "wild_card_1pct"
if not ctx.get("allow_selected_wildcards", True):
return "가격협상"
return "wild_card_budget"
# ---- render --------------------------------------------------------

View File

@ -43,6 +43,8 @@ class NegotiationDbContext:
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type → quotations.supplier_type. 미지정 시 None
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
class NegotiationContextLoader:
@ -93,6 +95,11 @@ class NegotiationContextLoader:
if err != ErrorType.SUCCESS:
supplier_count = 1
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
selected_nego_cards, selected_wild_cards = selected_cards
return NegotiationDbContext(
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target,
@ -101,6 +108,8 @@ class NegotiationContextLoader:
partner_type=PartnerType.from_count(supplier_count),
revenue_amount=revenue_amount,
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards,
)
try:

View File

@ -90,14 +90,15 @@ class ScriptRepository:
return self.format_script(text, variables)
async def resolve_card_script(self, action_id: int, card_id: Optional[str],
variables: Optional[Dict[str, Any]] = None) -> Optional[str]:
variables: Optional[Dict[str, Any]] = None,
prefer_db: bool = False) -> Optional[str]:
"""카드 멘트 해석. cards.source_type == 'backoffice_db' 면 card.nego_cards.script(DB)를
우선 조회하고, 없거나 file 모드면 scripts_cards.json(파일) 폴백. 변수 치환 반환.
DB 멘트는 백오피스(negodata) 편집한 정본이라 파일보다 우선한다. 마커(**굵게** )
섞여 있어도 agent 불투명 텍스트로 취급 표현 렌더는 프론트 소유.
"""
if self._config.cards.source_type == _CARD_SOURCE_DB and card_id:
if (prefer_db or self._config.cards.source_type == _CARD_SOURCE_DB) and card_id:
db_text = await self._fetch_card_script_db(card_id)
if db_text:
return self.format_script(db_text, variables)

View File

@ -63,11 +63,20 @@ class ChatService:
)
if session is None:
selected_nego_cards = db_ctx.selected_nego_card_numbers if db_ctx else []
selected_wild_cards = db_ctx.selected_wild_card_numbers if db_ctx else []
# 운영 DB 세션은 견적 version_id 에 묶인 카드만 사용한다. 직접 호출/데모(DB context 없음)는
# 기존 테넌트 기본 action mapping 으로 폴백해 로컬 테스트와 콘솔 데모를 유지한다.
action_space_size = (
min(len(selected_nego_cards), engine.action_space_size)
if db_ctx is not None
else engine.action_space_size
)
# session_id honoring: backend 가 보낸 session_id(= negotiation.sessions.session_id)를
# 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성.
session = ChatSession(
session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id,
rq_type=rq_type, action_space_size=engine.action_space_size,
rq_type=rq_type, action_space_size=action_space_size,
context={
# 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader).
# 미기재/미지정이면 기본값 폴백.
@ -83,6 +92,13 @@ class ChatService:
"round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
"item_price": db_ctx.item_price if db_ctx else 0,
# 견적 생성 시 선택한 카드. 일반카드는 action_id 0..N-1 에 그대로 매핑한다.
# 1% 인하는 기본 와일드카드로 항상 열고, 재원부족 등 선택형 와일드카드는
# 선택된 와일드카드가 있을 때만 허용한다.
"db_context_loaded": db_ctx is not None,
"selected_nego_card_numbers": selected_nego_cards,
"selected_wild_card_numbers": selected_wild_cards,
"allow_selected_wildcards": True if db_ctx is None else bool(selected_wild_cards),
},
)
view = chat_engine.start(session)
@ -105,7 +121,7 @@ class ChatService:
res.settled_price = int(session.context["input_price"])
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
if view.error is None and engine.action_space_size > 0:
if view.error is None and session.action_space_size > 0:
if view.needs_card_selection:
await self._select_and_learn(engine, chat_engine, repo, session, res)
elif view.outcome is not None:
@ -172,11 +188,11 @@ class ChatService:
LOG.e_no_callstack(f"[ChatService] state error: {ex}")
return
policy, version_id, repo = await QTablePolicyStore.load(engine)
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size,
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=session.action_space_size,
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
decision = policy.select(ctx)
session.used_action_ids.add(decision.action_id)
card_id = engine.mapper.get_card_id(decision.action_id)
card_id = self._card_id_for_action(engine, session, decision.action_id)
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)
@ -195,7 +211,12 @@ class ChatService:
# ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉.
# backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다.
# 카드 멘트: backoffice_db 모드면 card.nego_cards.script(negodata 편집 정본), 아니면 파일 폴백.
card_script = await scripts.resolve_card_script(decision.action_id, card_id, chat_engine.vars_for(session))
card_script = await scripts.resolve_card_script(
decision.action_id,
card_id,
chat_engine.vars_for(session),
prefer_db=bool(session.context.get("selected_nego_card_numbers")),
)
if card_script:
res.script = card_script
c = session.context
@ -218,9 +239,16 @@ 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,
engine.mapper.get_card_id(last_action), snap, reward, None, done=True)
self._card_id_for_action(engine, session, last_action), snap, reward, None, done=True)
res.updated_q = float(policy.qtable.q[last_state, last_action])
@staticmethod
def _card_id_for_action(engine: TenantEngine, session: ChatSession, action_id: int) -> Optional[str]:
selected = session.context.get("selected_nego_card_numbers") or []
if 0 <= action_id < len(selected):
return str(selected[action_id])
return engine.mapper.get_card_id(action_id)
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
data = {
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,

View File

@ -15,6 +15,8 @@ import pytest
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
from negotiation.chat.service.script_repository import ScriptRepository
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
@ -122,6 +124,75 @@ def test_acceptance_ratio_dynamic_calc():
assert calc({"input_price": 9800}) == 0.0 # 기준가 전무(과거 세션 호환)
def test_selected_card_mapping_overrides_tenant_default():
"""운영 세션은 견적 version_id 에 묶인 카드 번호를 action_id 순서로 사용한다."""
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000001",
tenant_id="ktcommerce",
company_id="ktcommerce",
context={"selected_nego_card_numbers": ["2", "7"]},
action_space_size=2,
)
class _Mapper:
def get_card_id(self, action_id):
return f"DEFAULT-{action_id}"
class _Engine:
mapper = _Mapper()
assert ChatService._card_id_for_action(_Engine(), session, 0) == "2"
assert ChatService._card_id_for_action(_Engine(), session, 1) == "7"
assert ChatService._card_id_for_action(_Engine(), session, 8) == "DEFAULT-8"
def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
"""1% 인하는 기본 제공 카드라 DB 견적에서 와일드카드를 선택하지 않아도 발동한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
repo = ScriptRepository(cfg, _TENANTS_DIR)
engine = ChatEngine(repo, rq_type="재협상")
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000002",
tenant_id="ktcommerce",
company_id="ktcommerce",
step="가격협상_확인",
action_space_size=0,
context={
"input_price": 10000,
"anchor_price": 9900,
"target_price": 10000,
"round": 1,
"allow_selected_wildcards": False,
},
)
view = engine.advance(session, "")
assert view.step == "wild_card_1pct"
def test_budget_wildcard_requires_selected_wildcard_for_db_context():
"""재원부족 구간은 DB 견적에서 와일드카드를 선택했을 때만 발동한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
repo = ScriptRepository(cfg, _TENANTS_DIR)
engine = ChatEngine(repo, rq_type="재협상")
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000003",
tenant_id="ktcommerce",
company_id="ktcommerce",
step="가격협상_확인",
action_space_size=0,
context={
"input_price": 10200,
"anchor_price": 9900,
"target_price": 10000,
"round": 1,
"allow_selected_wildcards": False,
},
)
view = engine.advance(session, "")
assert view.step == "가격협상"
assert not view.step.startswith("wild_card_")
@pytest.mark.asyncio
async def test_priority_completes_without_wildcard(db_engine):
reset_sessions()

View File

@ -151,3 +151,20 @@ async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
assert out and "9800" in out # 파일 폴백 멘트
@pytest.mark.asyncio
async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
"""견적에서 선택된 백오피스 카드 번호는 file 모드여도 DB 멘트를 우선한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
assert cfg.cards.source_type == "file"
fake = _FakeCardRepo({"2": "선택 카드 DB 멘트 **{input_price}원**"})
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
async def _fake_lambda(_db, _wr, func):
return await func(None)
from negotiation.chat.service import script_repository as _sr
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
assert out == "선택 카드 DB 멘트 **10200원**"

File diff suppressed because one or more lines are too long