"""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) # 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판) # → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값). # 소스: partner.supplier_items.supply_type(이 협력사×이 상품 매핑). _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 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 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. 미지정 시 None selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number) selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number) 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_items.supply_type). # 매핑이 없거나 미지정이면 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) # 인터넷 최저가(LPS 수집 대표값) — 없으면 0(시장가 인용 카드는 값 있을 때만 치환). _, internet_lowest_price = await self.crud.get_item_lowest_price(s, item_id) # 카드 스크립트 치환용 이름 — 협력사명/상품명. 없으면 None(호출부 기본값 폴백). _, partner_name = await self.crud.get_supplier_name(s, supplier_id) _, product_name = await self.crud.get_item_name(s, item_id) # 파트너사 유형: 상품에 연결된 협력사 수 — supplier_items 매핑(등록 기준) 우선. # 매핑이 아직 없으면 협상 세션 이력 기준 폴백(더미보다 항상 낫다). 실패 시 SINGLE. err, supplier_count = await self.crud.count_item_suppliers(s, item_id) if err == ErrorType.SUCCESS and supplier_count == 0: err, supplier_count = await self.crud.count_item_session_suppliers(s, item_id) 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, anchor_price=anchor, item_price=item_price, internet_lowest_price=internet_lowest_price, partner_name=partner_name, product_name=product_name, 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: 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