- Req_Chat 을 session_id/user_input/client_step 3필드로 축소 — rq_type·목표가·앵커·품목가·
매출액·유통코드·파트너 유형·수용률 필드 전부 제거
- NegotiationContextLoader 신설: 세션 시작 시 공유 DB 1회 조회로 컨텍스트 확정
· rq_type = sessions.qt_type ({1,3}→재협상 / {2,4}→재견적)
· anchor = sessions.anchoring_price(박제) — NULL 이면 무할인 폴백 anchor=target (v1.2 정책 승계)
· 매출액 = suppliers.total_revenue(KTC 미러), 유통코드 = quotations.supplier_type 매핑
· 파트너 유형 = 상품별 distinct supplier 수 → PartnerType enum(0=NONE/1=SINGLE/2=MULTIPLE)
- 가격 수용률은 세션 내 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가)
- DB 쿼리를 backend crud 패턴으로 분리: INegoContextCRUD(ABC)+NegoContextCRUD,
IChatSessionRepository 인터페이스 추가 (테스트 더블 주입 가능)
- 와일드카드 1% 수락 시 합의가=offer_1pct 반영 + Res_Chat.settled_price 신설 —
backend 요약/입찰가가 이를 최우선 사용 (19,800원 수락이 20,000원으로 기록되던 버그 수정)
- backend: agent 전송 바디 3필드로 축소, 앵커/파트너 조회 메서드 제거,
test_anchoring_chat 을 새 구조로 재작업(박제 소비/폴백 검증은 agent 테스트로 이관)
- 데모 페이지(/demo·negotiation_demo.html) 제거 — 컨텍스트 주입 경로 폐지로 무의미
- 테스트: agent 83/83, backend 57/57 (컨텍스트 로더 실데이터 왕복 4종 + CRUD 더블 검증 포함)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
103 lines
5.8 KiB
Python
103 lines
5.8 KiB
Python
"""NegotiationContextLoader — 협상 시작 컨텍스트를 DB 에서 1회 조회 (Req_Chat 슬림화).
|
|
|
|
backend 가 요청마다 실어 보내던 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/
|
|
파트너 유형)를 세션 시작 시 agent 가 직접 조회한다. session_id 는 backend 와 공유하는
|
|
negotiation.sessions.session_id. 행이 없으면(데모/테스트 직접 호출) None 을 반환하고
|
|
호출부(ChatService)가 기본값으로 폴백한다.
|
|
|
|
DB 쿼리는 INegoContextCRUD(negotiation/chat/infra/repository/nego_context_crud.py)에 위임
|
|
— backend crud 패턴 준용(인터페이스 + 함수 호출). 여기는 판정 로직(rq_type·앵커 폴백·
|
|
코드 매핑)과 세션 경계(execute_lambda)만 담당한다.
|
|
|
|
가격 수용률은 여기서 다루지 않는다 — 세션 내 라운드별 제시가로 매 턴 동적 계산(ChatService).
|
|
"""
|
|
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
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.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
|
|
from negotiation.qtable.domain.model.snapshot import PartnerType
|
|
|
|
# 1:1 견적유형 → 재협상 스크립트. QuotationType: 1=renego, 3=new_nego (2=requote, 4=new_quote 는 1:N 재견적).
|
|
_ONE_TO_ONE_QT_TYPES = (1, 3)
|
|
|
|
# 유통 코드: quotations.supplier_type(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
|
|
# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값).
|
|
_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NegotiationDbContext:
|
|
"""세션 시작 시 DB 에서 확정되는 협상 컨텍스트 (라운드 진행 중 불변)."""
|
|
|
|
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
|
|
partner_type: PartnerType # 상품에 연결된 협력사 수(distinct supplier) → NONE/SINGLE/MULTIPLE
|
|
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
|
|
distribution_code: Optional[str] # 유통 코드(A/B/C) — quotations.supplier_type 매핑. 미지정 시 None
|
|
|
|
|
|
class NegotiationContextLoader:
|
|
def __init__(self, crud: Optional[INegoContextCRUD] = None):
|
|
self.crud: INegoContextCRUD = crud or NegoContextCRUD()
|
|
|
|
async def load(self, session_id: Optional[str]) -> Optional[NegotiationDbContext]:
|
|
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백)."""
|
|
if not session_id:
|
|
return None
|
|
try:
|
|
sid = uuid.UUID(session_id)
|
|
except ValueError:
|
|
return None # 데모/테스트의 비-UUID 세션 키
|
|
|
|
async def _load(s) -> Optional[NegotiationDbContext]:
|
|
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
|
|
target = int(target_price or 0)
|
|
|
|
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
|
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
|
|
# 앵커를 재계산하지 않으며, 해당 세션은 앵커링 집계에서 자동 제외된다.
|
|
anchor = int(anchoring_price or 0)
|
|
if anchor <= 0:
|
|
LOG.w(f"[NegotiationContextLoader] 앵커가 박제 없음 session_id={session_id} — 무할인 폴백(anchor=target)")
|
|
anchor = target
|
|
|
|
# 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값.
|
|
_, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id)
|
|
|
|
# 유통 코드: 견적의 협력사 유형(supplier_type) 매핑. 미지정 시 None → 호출부 기본값.
|
|
_, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id)
|
|
|
|
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
|
_, item_price = await self.crud.get_item_price(s, item_id)
|
|
|
|
# 파트너사 유형: 상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier). 실패 시 SINGLE.
|
|
err, supplier_count = await self.crud.count_item_suppliers(s, item_id)
|
|
if err != ErrorType.SUCCESS:
|
|
supplier_count = 1
|
|
|
|
return NegotiationDbContext(
|
|
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
|
|
target_price=target,
|
|
anchor_price=anchor,
|
|
item_price=item_price,
|
|
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,
|
|
)
|
|
|
|
try:
|
|
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _load)
|
|
except Exception as ex: # DB 불가 등 — 컨텍스트 없이 기본값으로 진행(협상 자체는 가능해야 함)
|
|
LOG.e_no_callstack(f"[NegotiationContextLoader] 컨텍스트 조회 실패 session_id={session_id}: {ex}")
|
|
return None
|