Merge branch 'refactor/backend'
This commit is contained in:
commit
d0b6e785af
@ -30,7 +30,7 @@ class supplier_users(MAIN_BASE):
|
||||
status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive
|
||||
role = Column(SmallInteger, nullable=False, server_default=text("1")) # 권한: 1=user, 2=manager
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ class suppliers(MAIN_BASE):
|
||||
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
|
||||
priority = Column(String(10), nullable=True) # 우선순위 (고객사별 문자열 값 가능)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ class items(MAIN_BASE):
|
||||
selling_price = Column(BigInteger, nullable=True)
|
||||
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -111,7 +111,10 @@ class sessions(MAIN_BASE):
|
||||
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
|
||||
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
|
||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||
target_anchoring_price = Column(BigInteger, nullable=True)
|
||||
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지
|
||||
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지
|
||||
last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적"
|
||||
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용
|
||||
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
|
||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
||||
@ -120,7 +123,7 @@ class sessions(MAIN_BASE):
|
||||
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
|
||||
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -157,12 +160,12 @@ class quotations(MAIN_BASE):
|
||||
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
|
||||
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
class quotation_settings(MAIN_BASE):
|
||||
# quotation.quotation_settings (견적 설정). 앵커링값(anchoring_value) 조회용 — agent RL state 입력.
|
||||
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.target_anchoring_price 박제값).
|
||||
@staticmethod
|
||||
def DBType():
|
||||
return DBType.QUOTATION.value
|
||||
@ -173,10 +176,10 @@ class quotation_settings(MAIN_BASE):
|
||||
qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 견적 설정 식별자(PK)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율
|
||||
anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value))
|
||||
anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — 구 방식 앵커 비율(현행 앵커 산출에는 미사용 — schedules/anchoring 참조)
|
||||
card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -200,7 +203,7 @@ class chats(MAIN_BASE):
|
||||
card_type = Column(SmallInteger, nullable=True) # 카드 유형: 1=nego_card, 2=wild_card
|
||||
meta = Column(JSONB, nullable=True) # 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
|
||||
@ -220,5 +223,5 @@ class supplier_user_tokens(MAIN_BASE):
|
||||
issued_at = Column(DateTime(timezone=True), nullable=False) # 발급 시각
|
||||
expired_at = Column(DateTime(timezone=True), nullable=False) # 만료 시각
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||
|
||||
@ -81,10 +81,6 @@ class DBWRType(Enum):
|
||||
DB_WRITE = 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 도메인 코드값. 스키마는 SMALLINT 정수 코드(1부터)로 두고, 의미 매핑은 여기 enum 으로 한다.
|
||||
# (postgres-init/01-schema.sql: "코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다")
|
||||
# ============================================================
|
||||
class AccountStatus(Enum):
|
||||
"""계정 상태 코드. company.users / supplier.supplier_users 의 status 컬럼."""
|
||||
|
||||
@ -117,9 +113,7 @@ class QtType(Enum):
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
"""협상 세션 진행 상태 코드. negotiation.sessions.status.
|
||||
⚠️ 세션을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
|
||||
"""
|
||||
"""협상 세션 진행 상태 코드. negotiation.sessions.status"""
|
||||
|
||||
CREATED = 1 # 협상생성
|
||||
IN_PROGRESS = 2 # 협상중
|
||||
@ -129,9 +123,7 @@ class SessionStatus(Enum):
|
||||
|
||||
|
||||
class QuotationStatus(Enum):
|
||||
"""견적 진행 상태 코드. quotation.quotations.status.
|
||||
⚠️ 견적을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
|
||||
"""
|
||||
"""견적 진행 상태 코드. quotation.quotations.status """
|
||||
|
||||
CREATED = 1 # 견적생성
|
||||
IN_PROGRESS = 2 # 견적진행중
|
||||
@ -139,18 +131,14 @@ class QuotationStatus(Enum):
|
||||
|
||||
|
||||
class ChatSender(Enum):
|
||||
"""채팅 발신자 코드. negotiation.chats.sender.
|
||||
BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다.
|
||||
"""
|
||||
"""채팅 발신자 코드. negotiation.chats.sender """
|
||||
|
||||
BOT = 1 # 갑(바이어/agent) — bot 메시지
|
||||
USER = 2 # 공급사(을) — user 입력
|
||||
BOT = 1 # 공급사(갑) — bot 메시지
|
||||
USER = 2 # 협력사(을) — user 입력
|
||||
|
||||
|
||||
class DeliveryType(Enum):
|
||||
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type.
|
||||
재견적(CM) 협상의 '배송형태선택' 단계 라벨과 1:1 (SHARED_ENUMS §6, negodata 정의 채택).
|
||||
"""
|
||||
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type """
|
||||
|
||||
SUPPLIER = 1 # 협력사배송
|
||||
COURIER = 2 # 지정택배배송
|
||||
|
||||
@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import chats, items, sessions
|
||||
from common.enums import ErrorType
|
||||
from common.enums import ErrorType, SessionStatus
|
||||
from common.logger import LOG
|
||||
|
||||
|
||||
@ -43,6 +43,10 @@ class IChatCRUD(ABC):
|
||||
) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class ChatCRUD(IChatCRUD):
|
||||
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||
@ -121,7 +125,30 @@ class ChatCRUD(IChatCRUD):
|
||||
values["reject_reason"] = reject_reason[:255]
|
||||
if reject_price is not None:
|
||||
values["reject_price"] = reject_price
|
||||
query = update(sessions).where(sessions.session_id == session_id).values(**values)
|
||||
# 진행중일 때만 전이 — negodata 일괄마감/중복 전송 경합이 종료된 세션을 되살리지 못하게 가드
|
||||
query = (
|
||||
update(sessions)
|
||||
.where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value)
|
||||
.values(**values)
|
||||
)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||
"""협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출.
|
||||
|
||||
진행 중엔 매 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서
|
||||
"가격을 한 번이라도 써낸 협상"을 가르는 기준값(NULL=가격 흔적 없음 → 집계 제외).
|
||||
status 가드: 일괄마감 등으로 이미 종료된 세션의 흔적을 사후에 바꾸지 못하게 한다(판정 결정성 보호).
|
||||
"""
|
||||
try:
|
||||
query = (
|
||||
update(sessions)
|
||||
.where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value)
|
||||
.values(last_offered_price=price)
|
||||
)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -11,7 +11,7 @@ from common.utils.gtime import GTime
|
||||
from config.server_configs import web_server_config
|
||||
import router.v1.auth.account
|
||||
import router.v1.negotiation.session
|
||||
import router.v1.negotiation.chat
|
||||
import router.v1.chat.chat
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@ -58,4 +58,4 @@ async def healthz():
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.negotiation.session.router)
|
||||
app.include_router(router.v1.negotiation.chat.router)
|
||||
app.include_router(router.v1.chat.chat.router)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -7,28 +9,28 @@ class AuthProtocol(WebPacketProtocol):
|
||||
|
||||
|
||||
class Req_Login(AuthProtocol):
|
||||
id: str = ""
|
||||
pw: str = ""
|
||||
id: str = Field("", description="로그인 ID")
|
||||
pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시·검증)")
|
||||
|
||||
|
||||
class Res_Login(Res_WebPacketProtocol):
|
||||
su_id: str = ""
|
||||
name: str = "" # 유저 개인 이름
|
||||
supplier_id: str = "" # 소속 공급사(partner.suppliers)
|
||||
supplier_name: str = "" # 공급사명
|
||||
role: int = 0
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
su_id: str = Field("", description="유저 식별자(uuid)")
|
||||
name: str = Field("", description="유저 개인 이름")
|
||||
supplier_id: str = Field("", description="소속 공급사 uuid(partner.suppliers)")
|
||||
supplier_name: str = Field("", description="공급사명")
|
||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
access_token: str = Field("", description="액세스 토큰(JWT)")
|
||||
refresh_token: str = Field("", description="리프레시 토큰(JWT)")
|
||||
|
||||
|
||||
class Req_CreateAccount(AuthProtocol):
|
||||
supplier_id: str = "" # 소속 공급사(partner.suppliers.supplier_id)
|
||||
id: str = "" # 로그인 ID
|
||||
pw: str = ""
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
contact_number: str = ""
|
||||
role: int = 1 # 1=user, 2=manager (UserRole)
|
||||
supplier_id: str = Field("", max_length=36, description="소속 공급사 uuid(partner.suppliers.supplier_id)")
|
||||
id: str = Field("", max_length=20, description="로그인 ID")
|
||||
pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시)")
|
||||
name: str = Field("", max_length=50, description="이름")
|
||||
email: str = Field("", max_length=255, description="이메일")
|
||||
contact_number: str = Field("", max_length=20, description="연락처")
|
||||
role: int = Field(1, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
|
||||
|
||||
class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
@ -36,16 +38,16 @@ class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
access_token: str = ""
|
||||
access_token: str = Field("", description="재발급된 액세스 토큰(JWT)")
|
||||
|
||||
|
||||
class Res_Me(Res_WebPacketProtocol):
|
||||
su_id: str = ""
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
supplier_id: str = ""
|
||||
supplier_name: str = ""
|
||||
role: int = 0
|
||||
su_id: str = Field("", description="유저 식별자(uuid)")
|
||||
id: str = Field("", description="로그인 ID")
|
||||
name: str = Field("", description="유저 개인 이름")
|
||||
supplier_id: str = Field("", description="소속 공급사 uuid")
|
||||
supplier_name: str = Field("", description="공급사명")
|
||||
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
|
||||
|
||||
|
||||
class Res_Logout(Res_WebPacketProtocol):
|
||||
|
||||
@ -4,9 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||
from services.chat_service import ChatService
|
||||
from .chat_protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from .protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}})
|
||||
# URL 은 협상 세션의 하위 리소스라 prefix 는 /v1/negotiation 유지(파일만 chat/ 로 분리).
|
||||
router = APIRouter(prefix="/v1/negotiation", tags=["Chat"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -9,6 +9,8 @@ summary(요약카드 데이터)는 backend 가 비즈니스 데이터로 조립
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -43,25 +45,25 @@ class ChatMessage(WebPacketProtocol):
|
||||
chat_id: str = ""
|
||||
session_id: str = ""
|
||||
seq: int = 0
|
||||
sender: int = 0 # ChatSender 코드
|
||||
sender: int = Field(0, description="발신자 코드 (ChatSender: 1=BOT, 2=USER)")
|
||||
script: str = ""
|
||||
user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price
|
||||
user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price")
|
||||
step: str = ""
|
||||
display_step: str = "" # agent client_step
|
||||
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
|
||||
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
|
||||
display_step: str = Field("", description="agent client_step (표시용 단계)")
|
||||
next_input_mode: Optional[str] = Field(None, description="다음 입력 모드: confirm|yes_no|percent|price|delivery_type")
|
||||
next_input_type: Optional[list[str]] = Field(None, description="다음 입력 선택지(버튼 라벨)")
|
||||
chat_end: bool = False
|
||||
indicator_value: Optional[float] = None # 협상 지표(1~99). agent 가 가격협상 턴에 내려주면 표시.
|
||||
bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator
|
||||
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐
|
||||
indicator_value: Optional[float] = Field(None, description="협상 지표(1~99). 가격협상 턴에 표시")
|
||||
bot_chat_type: Optional[str] = Field(None, description="폼 종류: summaryRSP|summaryCM|rejectRSP|rejectCM|indicator")
|
||||
summary: Optional[ChatSummary] = Field(None, description="summaryRSP/summaryCM 일 때만 채워짐")
|
||||
|
||||
|
||||
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
|
||||
class Res_ChatInit(Res_WebPacketProtocol):
|
||||
session_id: str = ""
|
||||
session_status: int = 0 # SessionStatus 코드
|
||||
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
|
||||
quotation_id: str = ""
|
||||
quotation_end_time: str = "" # ISO 8601 (마감 시각)
|
||||
quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)")
|
||||
quotation_memo: str = ""
|
||||
item_id: str = ""
|
||||
item_name: str = ""
|
||||
@ -84,8 +86,8 @@ class Res_ChatMessages(Res_WebPacketProtocol):
|
||||
|
||||
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
|
||||
class Req_ChatSend(WebPacketProtocol):
|
||||
user_input_type: Optional[str] = None # text|percent|price
|
||||
user_input: str = ""
|
||||
user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price")
|
||||
user_input: str = Field("", description="버튼 선택 텍스트 또는 가격/퍼센트 문자열")
|
||||
|
||||
|
||||
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)
|
||||
@ -1,13 +1,15 @@
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
# 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다.
|
||||
class ListItem(WebPacketProtocol):
|
||||
session_id: str = ""
|
||||
session_status: int = 0 # SessionStatus 코드
|
||||
qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적)
|
||||
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
|
||||
qt_type: int = Field(0, description="견적 종류 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적)")
|
||||
qt_number: str = ""
|
||||
qt_end_time: str = "" # ISO 8601 (마감 시각)
|
||||
qt_end_time: str = Field("", description="견적 마감 시각 (ISO 8601)")
|
||||
item_code: str = ""
|
||||
item_name: str = ""
|
||||
model_name: str = ""
|
||||
@ -22,12 +24,12 @@ class Res_SessionList(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class Res_Participate(Res_WebPacketProtocol):
|
||||
session_id: str = "" # 참여 성공한 세션 (채팅 진입용)
|
||||
session_id: str = Field("", description="참여 성공한 세션 uuid (채팅 진입용)")
|
||||
|
||||
|
||||
class Req_Reject(WebPacketProtocol):
|
||||
reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력)
|
||||
reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
|
||||
|
||||
|
||||
class Res_Reject(Res_WebPacketProtocol):
|
||||
session_id: str = "" # 거부 처리된 세션
|
||||
session_id: str = Field("", description="거부 처리된 세션 uuid")
|
||||
|
||||
@ -43,7 +43,7 @@ class AgentChatContext:
|
||||
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
|
||||
rq_type: str = "재협상" # 재협상 | 재견적
|
||||
target_price: int = 0 # 갑 목표 매입가(원)
|
||||
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산.
|
||||
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.target_anchoring_price.
|
||||
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
|
||||
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
|
||||
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
|
||||
|
||||
@ -18,63 +18,25 @@ from fastapi import Depends
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import chats, items, quotation_settings, quotations, sessions, suppliers
|
||||
from common.database.model.models import chats, items, quotations, sessions, suppliers
|
||||
from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||
from router.v1.negotiation.chat_protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||
from services.auth_service import AuthService
|
||||
|
||||
# 종료 스텝 → 프론트 폼 종류(bot_chat_type).
|
||||
# 폼 접미사: RSP=재협상(renegotiation), CM=재견적(requote). qt_type 1=재협상, 2=재견적.
|
||||
# 협상완료/결과안내 → 요약카드, 협상실패 → 합의불가(거부) 폼.
|
||||
# 종료 step → 프론트 폼 종류(bot_chat_type). RSP=재협상, CM=재견적.
|
||||
_SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"}
|
||||
_REJECT_STEPS = {"협상실패"}
|
||||
|
||||
|
||||
def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]:
|
||||
if not step:
|
||||
return None
|
||||
is_reneg = qt_type == 1 # 1=재협상(RSP), 그 외(2)=재견적(CM)
|
||||
if step in _SUMMARY_STEPS:
|
||||
return "summaryRSP" if is_reneg else "summaryCM"
|
||||
if step in _REJECT_STEPS:
|
||||
return "rejectRSP" if is_reneg else "rejectCM"
|
||||
return None
|
||||
|
||||
|
||||
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
|
||||
# 가격 허용 범위 배수(목표가 기준). 벗어나면 CHAT_PRICE_OUT_OF_RANGE.
|
||||
PRICE_FLOOR_RATIO = 0.3
|
||||
PRICE_CEIL_RATIO = 1.7
|
||||
|
||||
|
||||
def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool:
|
||||
"""직전 봇이 요구한 입력 모드(meta.input_mode)와 이번 유저 입력의 '타입'이 정합한지 검사.
|
||||
불일치(예: price 단계인데 버튼/텍스트, yes_no 단계인데 가격/퍼센트 숫자)면 False
|
||||
→ agent 로 넘기지 않고 CHAT_INPUT_MODE_MISMATCH(제자리걸음/오진행 방지).
|
||||
직전 봇 메시지/모드가 없으면(제약 없음) True.
|
||||
|
||||
주의: 버튼 선택형(confirm/yes_no/delivery_type)에서 '선택지 텍스트 일치'까지는 강제하지 않는다.
|
||||
- 프론트 버튼은 항상 올바른 라벨을 보내고, 거부 폼 등은 자유 텍스트(사유)를 보내기 때문.
|
||||
- 타입(가격/퍼센트 숫자) 오입력만 막아도 실제 stuck/오진행 케이스는 차단된다.
|
||||
"""
|
||||
if not last_meta:
|
||||
return True
|
||||
mode = last_meta.get("input_mode")
|
||||
if not mode:
|
||||
return True
|
||||
if mode == "price":
|
||||
return user_input_type == "price"
|
||||
if mode == "percent":
|
||||
return user_input_type == "percent"
|
||||
if mode in ("confirm", "yes_no", "delivery_type"):
|
||||
# 버튼 선택형 단계에 가격/퍼센트 '숫자 입력'이 오면 오입력 → 차단. 그 외 텍스트는 허용.
|
||||
return user_input_type not in ("price", "percent")
|
||||
return True
|
||||
|
||||
|
||||
class ChatService:
|
||||
def __init__(
|
||||
@ -89,6 +51,97 @@ class ChatService:
|
||||
self.chat_crud = chat_crud
|
||||
self.agent = agent
|
||||
|
||||
# ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ----
|
||||
@staticmethod
|
||||
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
@staticmethod
|
||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||
if not target_price:
|
||||
return price > 0
|
||||
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)
|
||||
|
||||
@staticmethod
|
||||
def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool:
|
||||
# 직전 봇 input_mode 와 이번 입력 타입 정합 검사(숫자 오입력만 차단).
|
||||
if not last_meta:
|
||||
return True
|
||||
mode = last_meta.get("input_mode")
|
||||
if not mode:
|
||||
return True
|
||||
if mode == "price":
|
||||
return user_input_type == "price"
|
||||
if mode == "percent":
|
||||
return user_input_type == "percent"
|
||||
if mode in ("confirm", "yes_no", "delivery_type"):
|
||||
return user_input_type not in ("price", "percent")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]:
|
||||
# 종료 step → 폼 종류. qt_type 1=재협상(RSP), 그 외=재견적(CM).
|
||||
if not step:
|
||||
return None
|
||||
is_reneg = qt_type == 1
|
||||
if step in _SUMMARY_STEPS:
|
||||
return "summaryRSP" if is_reneg else "summaryCM"
|
||||
if step in _REJECT_STEPS:
|
||||
return "rejectRSP" if is_reneg else "rejectCM"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_user_chat(sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.USER.value,
|
||||
target_price=int(price) if price is not None else 0,
|
||||
meta={"script": user_input, "user_input_type": user_input_type},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.BOT.value,
|
||||
target_price=int(sess.target_price or 0),
|
||||
indicator_value=turn.indicator_value,
|
||||
meta={
|
||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||
"chat_end": turn.chat_end, "card_id": turn.card_id,
|
||||
"bot_chat_type": bot_chat_type, "summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _chat_to_message(c: chats) -> ChatMessage:
|
||||
# chats 행 → 응답 ChatMessage (DB 재조회 없이).
|
||||
meta = c.meta or {}
|
||||
summary_d = meta.get("summary")
|
||||
return ChatMessage(
|
||||
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
|
||||
script=meta.get("script") or "",
|
||||
user_input_type=meta.get("user_input_type"),
|
||||
step=meta.get("step") or "",
|
||||
display_step=meta.get("client_step") or "",
|
||||
next_input_mode=meta.get("input_mode"),
|
||||
next_input_type=meta.get("input_options"),
|
||||
chat_end=bool(meta.get("chat_end", False)),
|
||||
indicator_value=float(c.indicator_value) if c.indicator_value is not None else None,
|
||||
bot_chat_type=meta.get("bot_chat_type"),
|
||||
summary=ChatSummary(**summary_d) if summary_d else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(r) -> ChatMessage:
|
||||
# DB 행(chats) → 응답 ChatMessage.
|
||||
return ChatService._chat_to_message(r)
|
||||
|
||||
# ---- 공통 전처리 ----------------------------------------------------
|
||||
async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
|
||||
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
|
||||
@ -235,9 +288,9 @@ class ChatService:
|
||||
return res
|
||||
|
||||
# 가격 입력이면 범위 검증
|
||||
price = _parse_price(user_input) if user_input_type == "price" else None
|
||||
price = self._parse_price(user_input) if user_input_type == "price" else None
|
||||
if user_input_type == "price":
|
||||
if price is None or not _in_price_range(price, sess.target_price):
|
||||
if price is None or not self._in_price_range(price, sess.target_price):
|
||||
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
|
||||
return res
|
||||
|
||||
@ -254,7 +307,7 @@ class ChatService:
|
||||
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
|
||||
return res
|
||||
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지).
|
||||
if not _input_matches_mode(last_meta, user_input, user_input_type):
|
||||
if not self._input_matches_mode(last_meta, user_input, user_input_type):
|
||||
LOG.i(f"[chat] 입력-모드 불일치 session_id={sess.session_id} "
|
||||
f"mode={last_meta.get('input_mode') if last_meta else None} input_type={user_input_type} input={user_input!r}")
|
||||
res.result.SetResult(ErrorType.CHAT_INPUT_MODE_MISMATCH)
|
||||
@ -301,7 +354,7 @@ class ChatService:
|
||||
|
||||
# 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도.
|
||||
# → agent 가 표현 계약을 책임지면 backend 의 step-이름 결합(_resolve_bot_chat_type)은 폴백으로만 남는다.
|
||||
bot_chat_type = turn.bot_chat_type or _resolve_bot_chat_type(sess.qt_type, turn.step)
|
||||
bot_chat_type = turn.bot_chat_type or self._resolve_bot_chat_type(sess.qt_type, turn.step)
|
||||
# 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 1회만 조회한다.
|
||||
need_last_price = bot_chat_type in ("summaryRSP", "summaryCM") or (turn.chat_end and turn.outcome == "success")
|
||||
last_price = await self._last_user_price(sess) if need_last_price else None
|
||||
@ -313,6 +366,10 @@ class ChatService:
|
||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
||||
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||
if price is not None:
|
||||
funcs.append(lambda s: self.chat_crud.update_last_offered_price(s, sess.session_id, price))
|
||||
new_status = sess.status
|
||||
if turn.chat_end:
|
||||
if turn.outcome == "success":
|
||||
@ -362,8 +419,7 @@ class ChatService:
|
||||
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
|
||||
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
|
||||
target_price = int(sess.target_price or 0)
|
||||
# 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식.
|
||||
# anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지).
|
||||
# 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||
anchor = await self._resolve_anchor_price(sess, target_price)
|
||||
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
|
||||
partner_count = await self._count_partners(sess)
|
||||
@ -378,25 +434,19 @@ class ChatService:
|
||||
)
|
||||
|
||||
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
|
||||
"""견적설정 anchoring_value(비율) → anchor=round(target*(1-value)). 실패 시 target*0.99 폴백."""
|
||||
"""세션에 박제된 앵커가(target_anchoring_price — negodata 가 생성 시 기록)를 그대로 사용.
|
||||
|
||||
박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다
|
||||
("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다.
|
||||
박제가 없으면(데이터 이상 — 사실상 발생하지 않음) 무할인 폴백 anchor=target + WARN.
|
||||
이때 박제하지 않으므로 해당 세션은 앵커링 집계에서 자동 제외(EXCLUDED)된다 — 학습 무오염.
|
||||
"""
|
||||
if not target_price:
|
||||
return 0
|
||||
fallback = int(round(target_price * 0.99))
|
||||
|
||||
def _q(s):
|
||||
stmt = (
|
||||
select(quotation_settings.anchoring_value)
|
||||
.join(quotations, quotations.qt_setting_id == quotation_settings.qt_setting_id)
|
||||
.where(quotations.qt_id == sess.quotation_id, quotation_settings.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
return DB_SESSION_MNG.execute(s, stmt)
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(quotation_settings.DBType(), DBWRType.DB_READ.value, _q)
|
||||
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
|
||||
LOG.w(f"[chat] anchoring_value 조회 실패 session_id={sess.session_id} — anchor=target*0.99 폴백")
|
||||
return fallback
|
||||
return int(round(target_price * (1.0 - float(rows[0]))))
|
||||
if sess.target_anchoring_price is not None:
|
||||
return int(sess.target_anchoring_price)
|
||||
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
|
||||
return target_price
|
||||
|
||||
async def _count_partners(self, sess) -> int:
|
||||
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
|
||||
@ -413,48 +463,6 @@ class ChatService:
|
||||
return 1
|
||||
return int(rows[0])
|
||||
|
||||
def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.USER.value,
|
||||
target_price=int(price) if price is not None else 0,
|
||||
meta={"script": user_input, "user_input_type": user_input_type},
|
||||
)
|
||||
|
||||
def _build_bot_chat(self, sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다.
|
||||
# indicator_value 는 전용 컬럼(분석/replay용)에도 적재. meta 는 순수 표시용.
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.BOT.value,
|
||||
target_price=int(sess.target_price or 0),
|
||||
indicator_value=turn.indicator_value,
|
||||
meta={
|
||||
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||
"chat_end": turn.chat_end, "card_id": turn.card_id,
|
||||
"bot_chat_type": bot_chat_type, "summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
def _chat_to_message(self, c: chats) -> ChatMessage:
|
||||
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
|
||||
meta = c.meta or {}
|
||||
summary_d = meta.get("summary")
|
||||
return ChatMessage(
|
||||
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
|
||||
script=meta.get("script") or "",
|
||||
user_input_type=meta.get("user_input_type"),
|
||||
step=meta.get("step") or "",
|
||||
display_step=meta.get("client_step") or "",
|
||||
next_input_mode=meta.get("input_mode"),
|
||||
next_input_type=meta.get("input_options"),
|
||||
chat_end=bool(meta.get("chat_end", False)),
|
||||
indicator_value=float(c.indicator_value) if c.indicator_value is not None else None,
|
||||
bot_chat_type=meta.get("bot_chat_type"),
|
||||
summary=ChatSummary(**summary_d) if summary_d else None,
|
||||
)
|
||||
|
||||
async def _last_user_price(self, sess) -> Optional[int]:
|
||||
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
|
||||
def _q(s):
|
||||
@ -554,21 +562,3 @@ class ChatService:
|
||||
supplier_manager_phone=sup_mgr_phone or "",
|
||||
delivery_type=delivery_label,
|
||||
).model_dump()
|
||||
|
||||
def _row_to_message(self, r) -> ChatMessage:
|
||||
"""DB 행(chats) → 응답 ChatMessage."""
|
||||
return self._chat_to_message(r)
|
||||
|
||||
|
||||
# ---- 가격 유틸 ----------------------------------------------------------
|
||||
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||
if not target_price:
|
||||
return price > 0
|
||||
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)
|
||||
|
||||
193
backend/tests/test_anchoring_chat.py
Normal file
193
backend/tests/test_anchoring_chat.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""앵커링 채팅 연동 테스트 — 박제값 소비 / 무할인 폴백 / 마지막 제시가(가격 흔적) 기록.
|
||||
|
||||
배치·조정 로직은 schedules/anchoring/tests 소관 — 여기는 backend 채팅 경로만 검증한다.
|
||||
실제 agent 대신 결정론적 더블(_AnchorAgent)을 주입하고, dev negosium_db 에 전용 행만 시드/정리한다.
|
||||
(규범: schedules/anchoring/docs/개발용.md §9.2 — 표본 기준은 노출이 아니라 "가격을 써냈는가")
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from services.agent_client import AgentTurn, IAgentClient, get_agent_client
|
||||
|
||||
TEST_LOGIN_ID = "pytest_anchor_user"
|
||||
TEST_PW = "pytest1234"
|
||||
TEST_SUPPLIER_NAME = "파이테스트앵커공급사"
|
||||
MARK = "PYTESTANCHOR-"
|
||||
|
||||
TARGET = 100_000
|
||||
ANCHOR = 99_000 # negodata 가 생성 시 박제하는 값(rate 10‰) 시뮬레이션
|
||||
|
||||
|
||||
def _parse_price(text_):
|
||||
digits = "".join(ch for ch in (text_ or "") if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
class _AnchorAgent(IAgentClient):
|
||||
"""결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료.
|
||||
|
||||
앵커보다 높은 가격이면 같은 step 을 반복(마지막 제시가 덮어쓰기 검증용).
|
||||
매 턴 수신한 ctx.anchor_price 를 기록해 backend 의 앵커 해석을 관찰한다.
|
||||
(script 에 앵커가 보이는 건 테스트 관찰 편의일 뿐 — 실제 agent 는 비노출.)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.seen_anchors: list[int] = []
|
||||
|
||||
async def chat(self, session_id, user_input, ctx) -> AgentTurn:
|
||||
self.seen_anchors.append(ctx.anchor_price)
|
||||
sid = session_id or "fake-session"
|
||||
if user_input is None: # 오프닝(턴0)
|
||||
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
|
||||
script="협상을 시작하시겠어요?", input_mode="confirm",
|
||||
input_options=["네, 시작할게요"])
|
||||
if ctx.client_step == "서비스안내":
|
||||
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
|
||||
script=f"저희가 제안드리는 첫 목표 가격은 {ctx.anchor_price}원입니다. "
|
||||
f"제안하실 가격을 입력해 주세요.", input_mode="price")
|
||||
price = _parse_price(user_input)
|
||||
if price is not None and price <= ctx.anchor_price:
|
||||
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
|
||||
script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success")
|
||||
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
|
||||
script="조금 더 조정된 가격을 부탁드립니다.", input_mode="price")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_agent():
|
||||
from router.router import app
|
||||
|
||||
agent = _AnchorAgent()
|
||||
app.dependency_overrides[get_agent_client] = lambda: agent
|
||||
yield agent
|
||||
app.dependency_overrides.pop(get_agent_client, None)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def anchor_seed(db_engine):
|
||||
"""공급사+유저 + 세션 2건: A(앵커 박제됨 — 정상 경로) / N(박제 NULL — 폴백 경로)."""
|
||||
supplier_id = uuid.uuid4()
|
||||
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
sids = {}
|
||||
|
||||
async def _cleanup(conn):
|
||||
await conn.execute(text(f"DELETE FROM negotiation.chats WHERE session_id IN (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}%'"))
|
||||
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
||||
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
await _cleanup(conn)
|
||||
await conn.execute(
|
||||
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
||||
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
||||
)
|
||||
await conn.execute(
|
||||
text("INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
||||
"VALUES (:sid, :id, :pw, '앵커담당자', now(), 1, 1)"),
|
||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||
)
|
||||
for code, anchor, rate in (("A", ANCHOR, 10), ("N", None, None)):
|
||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
sids[code] = session_id
|
||||
await conn.execute(
|
||||
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, price) "
|
||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 100000)"),
|
||||
{"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{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, supplier_type) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours', 1)"),
|
||||
{"qid": qt_id, "name": f"앵커견적 {code}", "num": f"{MARK}{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text("INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
" target_price, target_anchoring_price, anchor_rate_permille, status, end_time) "
|
||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, 1, :tp, :ap, :rate, 2, now() + interval '2 hours')"),
|
||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id,
|
||||
"qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate},
|
||||
)
|
||||
|
||||
yield {"sids": sids}
|
||||
|
||||
async with db_engine.begin() as conn:
|
||||
await _cleanup(conn)
|
||||
|
||||
|
||||
async def _login_token(client):
|
||||
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _h(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def _messages(client, token, sid):
|
||||
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/messages", headers=_h(token))
|
||||
|
||||
|
||||
async def _send(client, token, sid, user_input, user_input_type=None):
|
||||
body = {"user_input": user_input, "user_input_type": user_input_type}
|
||||
return await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", headers=_h(token), json=body)
|
||||
|
||||
|
||||
async def _anchor_columns(db_engine, session_id):
|
||||
async with db_engine.begin() as conn:
|
||||
row = (await conn.execute(text(
|
||||
"SELECT target_anchoring_price, anchor_rate_permille, last_offered_price, bid_price "
|
||||
"FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one()
|
||||
return row
|
||||
|
||||
|
||||
# ── 정상 경로: 박제값 소비 + 마지막 제시가 기록(가격 흔적) ──
|
||||
async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anchor_seed, _fake_agent):
|
||||
sid = str(anchor_seed["sids"]["A"])
|
||||
token = await _login_token(client)
|
||||
|
||||
r = await _messages(client, token, sid) # 오프닝 seed
|
||||
assert r.status_code == 200
|
||||
r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음)
|
||||
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert row.last_offered_price is None
|
||||
assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달
|
||||
|
||||
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
|
||||
assert r.json()["message"]["step"] == "기존가격제시"
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert row.last_offered_price == 99_500 # 가격 흔적 기록
|
||||
# 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offered_price 로 실패 표본이 된다.
|
||||
|
||||
r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료
|
||||
assert r.json()["session_status"] == 3 # DONE
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert row.last_offered_price == 98_000 # 마지막 값으로 갱신
|
||||
assert row.bid_price == 98_000
|
||||
assert (row.target_anchoring_price, row.anchor_rate_permille) == (ANCHOR, 10) # 박제 불변
|
||||
|
||||
|
||||
# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ──
|
||||
async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed, _fake_agent):
|
||||
sid = str(anchor_seed["sids"]["N"])
|
||||
token = await _login_token(client)
|
||||
|
||||
await _messages(client, token, sid)
|
||||
r = await _send(client, token, sid, "네, 시작할게요")
|
||||
assert r.json()["message"]["step"] == "기존가격제시"
|
||||
assert _fake_agent.seen_anchors[-1] == TARGET # 무할인 폴백: anchor = target
|
||||
|
||||
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료)
|
||||
assert r.json()["session_status"] == 3
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert row.target_anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
|
||||
assert row.anchor_rate_permille is None
|
||||
assert row.last_offered_price == 97_000 # 가격 흔적 기록은 정상 동작
|
||||
@ -14,6 +14,7 @@ import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from services.agent_client import AgentTurn, IAgentClient, get_agent_client
|
||||
from services.chat_service import ChatService
|
||||
|
||||
TEST_LOGIN_ID = "pytest_chat_user"
|
||||
TEST_PW = "pytest1234"
|
||||
@ -358,3 +359,42 @@ async def test_init_marks_expired_created_as_not_participated(client, chat_seed,
|
||||
assert body["result"]["success"] is True
|
||||
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
|
||||
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨
|
||||
|
||||
|
||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||
def test_parse_price():
|
||||
assert ChatService._parse_price("530,000원") == 530000 # 콤마/통화기호 제거
|
||||
assert ChatService._parse_price("abc") is None
|
||||
assert ChatService._parse_price("") is None
|
||||
assert ChatService._parse_price(None) is None
|
||||
|
||||
|
||||
def test_in_price_range():
|
||||
assert ChatService._in_price_range(100000, 100000) is True
|
||||
assert ChatService._in_price_range(29000, 100000) is False # floor(0.3) 미만
|
||||
assert ChatService._in_price_range(180000, 100000) is False # ceil(1.7) 초과
|
||||
assert ChatService._in_price_range(50000, None) is True # 목표가 없으면 양수면 통과
|
||||
assert ChatService._in_price_range(0, None) is False
|
||||
|
||||
|
||||
def test_input_matches_mode():
|
||||
f = ChatService._input_matches_mode
|
||||
assert f(None, "x", "text") is True # 직전 메타 없음 → 제약 없음
|
||||
assert f({}, "x", "price") is True # input_mode 없음
|
||||
assert f({"input_mode": "price"}, "100", "price") is True
|
||||
assert f({"input_mode": "price"}, "예", "text") is False # price 단계에 텍스트
|
||||
assert f({"input_mode": "percent"}, "5", "percent") is True
|
||||
assert f({"input_mode": "yes_no"}, "예", "text") is True
|
||||
assert f({"input_mode": "yes_no"}, "100", "price") is False # 버튼 단계에 가격 숫자
|
||||
assert f({"input_mode": "delivery_type"}, "픽업", "text") is True
|
||||
|
||||
|
||||
def test_resolve_bot_chat_type():
|
||||
f = ChatService._resolve_bot_chat_type
|
||||
assert f(1, "협상완료") == "summaryRSP" # 재협상
|
||||
assert f(2, "협상완료") == "summaryCM" # 재견적
|
||||
assert f(1, "협상실패") == "rejectRSP"
|
||||
assert f(2, "협상실패") == "rejectCM"
|
||||
assert f(2, "결과제출") == "summaryCM"
|
||||
assert f(1, "가격협상") is None # 일반 step
|
||||
assert f(1, None) is None
|
||||
|
||||
@ -97,6 +97,22 @@ async def _participate(client, token, session_id):
|
||||
return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
|
||||
async def _reject(client, token, session_id, reason):
|
||||
return await client.post(
|
||||
f"/v1/negotiation/sessions/{session_id}/reject",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"reject_reason": reason},
|
||||
)
|
||||
|
||||
|
||||
async def _session_reject(db_engine, session_id):
|
||||
async with db_engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"),
|
||||
{"sid": session_id},
|
||||
)).first()
|
||||
|
||||
|
||||
async def _session_status(db_engine, session_id):
|
||||
async with db_engine.begin() as conn:
|
||||
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
|
||||
@ -209,3 +225,44 @@ async def test_participate_session_not_found(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _participate(client, token, str(uuid.uuid4()))
|
||||
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
||||
|
||||
|
||||
# ---- 거부 -------------------------------------------------------------------
|
||||
async def test_reject_success(client, nego_seed, db_engine):
|
||||
token = await _login_token(client)
|
||||
sid = nego_seed["sids"]["B"] # 협상중 → 거부 가능
|
||||
r = await _reject(client, token, sid, "단종 상품입니다")
|
||||
assert r.json()["result"]["success"] is True
|
||||
assert r.json()["session_id"] == str(sid)
|
||||
status, reason = await _session_reject(db_engine, sid)
|
||||
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
||||
|
||||
|
||||
async def test_reject_empty_reason(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
||||
assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA
|
||||
|
||||
|
||||
async def test_reject_forbidden_other_supplier(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["X"], "사유") # 타 공급사 세션
|
||||
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||
|
||||
|
||||
async def test_reject_not_participable_when_done(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, nego_seed["sids"]["C"], "사유") # 협상완료(3) → 거부 불가
|
||||
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
||||
|
||||
|
||||
async def test_reject_session_not_found(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
r = await _reject(client, token, str(uuid.uuid4()), "사유")
|
||||
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
||||
|
||||
|
||||
async def test_reject_requires_auth(client, nego_seed):
|
||||
sid = nego_seed["sids"]["B"]
|
||||
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
@ -295,7 +295,10 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
||||
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
|
||||
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
|
||||
target_price BIGINT NOT NULL, -- 목표가(원)
|
||||
target_anchoring_price BIGINT NULL, -- 앵커링가(원)
|
||||
target_anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
|
||||
anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
|
||||
last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
|
||||
anchoring_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
|
||||
status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
|
||||
bid_price BIGINT NULL, -- 입찰가(원)
|
||||
bid_at TIMESTAMPTZ NULL, -- 입찰 시각
|
||||
|
||||
@ -51,3 +51,12 @@ CREATE TABLE IF NOT EXISTS company.notifications (
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종
|
||||
-- (신규 DB 는 01-schema.sql 에 반영됨. anchoring 스키마 자체(rate_adjustments·뷰)는
|
||||
-- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.)
|
||||
ALTER TABLE negotiation.sessions
|
||||
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
|
||||
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적)
|
||||
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹
|
||||
|
||||
5
schedules/anchoring/.gitignore
vendored
Normal file
5
schedules/anchoring/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
config.toml
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
14
schedules/anchoring/Dockerfile
Normal file
14
schedules/anchoring/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src ./src
|
||||
COPY config.toml* ./
|
||||
|
||||
ENV PYTHONPATH=/app/src \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["python", "-m", "anchoring.main"]
|
||||
77
schedules/anchoring/README.md
Normal file
77
schedules/anchoring/README.md
Normal file
@ -0,0 +1,77 @@
|
||||
# anchoring — 앵커링 값 자동 조정 배치 (자립 모듈)
|
||||
|
||||
회사 × 협력사유형(1유통/2제조/3총판) × 가격구간(자릿수 계단식 사다리, 46칸 — 예: 3만 원대)별 앵커링 값(‰)을
|
||||
**격주 토 00:00 KST** 배치로 협상 성공률에 따라 자동 조정한다.
|
||||
`schedules/anchoring` 아래에서 **완전 독립**으로 동작 — backend 코드를 import 하지 않는다.
|
||||
|
||||
> 규범 문서: **`docs/개발용.md`** (정책: `docs/기획용.md`, 흐름 해설: `docs/워크플로우.md`, 타 팀 적용: `docs/인수인계.md`)
|
||||
> **처음 오신 분 / 운영 담당자** → **`docs/운영및유지보수.md`** 부터 보세요 (설치·실행·로그 읽기·트러블슈팅).
|
||||
> 과제 이력·백로그 → **`TODO.md`** (주요 과제는 전부 종결)
|
||||
|
||||
## 경계
|
||||
|
||||
| 구분 | 대상 |
|
||||
|---|---|
|
||||
| 소유(쓰기) | `anchoring.rate_adjustments`(append-only 조정 이력), `sessions.anchoring_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 |
|
||||
| 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` |
|
||||
| 소비자 | negodata 가 `reader.get_anchor_rate` 이식 + 이 Redis 를 참조해 세션 생성 시 앵커가 박제 (인수인계) |
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
schema.sql # 모듈 소유 DDL (rate_adjustments + sessions 3컬럼) — psql 수동 적용
|
||||
src/anchoring/
|
||||
constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의)
|
||||
resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스
|
||||
base_table.py # 로드+검증(실패 시 기동 중단)
|
||||
service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상
|
||||
reader.py # 현재 rate 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
|
||||
redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백
|
||||
batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백)
|
||||
scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝)
|
||||
main.py # 엔트리 (상주 / --once)
|
||||
tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵)
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 0) DDL 적용 (신규 DB: postgres-init/01~04 이후)
|
||||
psql -h 127.0.0.1 -U postgres -d negosium_db -f schema.sql
|
||||
|
||||
# 로컬(가상환경)
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
cp config.toml.example config.toml # DB/Redis 채우기 (env 로 대체 가능)
|
||||
PYTHONPATH=src .venv/bin/python -m anchoring.main --once --dry-run # 예행 연습(DB/Redis 무변경)
|
||||
PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 수동 1회(격주 게이트 무시)
|
||||
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
|
||||
|
||||
# 도커(자립 compose: redis 동봉)
|
||||
docker compose up -d --build
|
||||
|
||||
# 테스트
|
||||
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
## 로그 확인
|
||||
|
||||
```bash
|
||||
docker logs anchoring | grep "batch 20260705" # 특정 회차 전체
|
||||
docker logs anchoring | grep "company=<uuid>" # 특정 회사만 (조정·회사요약 라인)
|
||||
docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
|
||||
```
|
||||
|
||||
- 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과
|
||||
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.rate_adjustments` 행과 교차 확인한다.
|
||||
- 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다.
|
||||
- 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
|
||||
|
||||
## 운영 런북
|
||||
|
||||
- **미스파이어**: 토 00:00 에 서비스가 내려가 있었고 1시간(misfire_grace) 초과로 그 회차가 스킵됐다면,
|
||||
재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변).
|
||||
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
|
||||
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
|
||||
- **가격 제시율 0% WARN**: backend 의 `last_offered_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
|
||||
- **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청.
|
||||
- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `anchoring_adjustment_id` 하나뿐.
|
||||
50
schedules/anchoring/TODO.md
Normal file
50
schedules/anchoring/TODO.md
Normal file
@ -0,0 +1,50 @@
|
||||
# TODO — 앵커링 모듈 후속 과제
|
||||
|
||||
> 남은 과제는 **정책 재확정 + 스펙(v1.3) 개정 사안**이다. 현재 규범(`docs/개발용.md` §12)은
|
||||
> 정적 테이블 변경·파라미터 조정을 봉인하고 있으므로, 착수 전 정책 확정 → 문서 개정 → 구현 순서를 지킨다.
|
||||
> **운영 데이터(조정 이력)가 쌓이기 전에 확정하는 것이 가장 저렴하다** — negodata 적용 전이 적기.
|
||||
|
||||
---
|
||||
|
||||
## ~~1. 정적 기본 테이블의 가격구간을 계단식으로 재설계~~ ✅ 완료 (2026-07-02)
|
||||
|
||||
**자릿수 계단식 사다리(46칸)로 확정·구현 완료.** 폭 = 구간 상한의 10%(선행 자릿수 밴드):
|
||||
[0, 1,000) 통일 1칸 + 자릿수(1천~1억, 5개)당 9칸 — "1천 원대·2천 원대 … 9천만 원대".
|
||||
1억 초과는 마지막 인덱스(45) 클램프. 사다리 단일 소스 = `constants.UPPER_BOUNDS`,
|
||||
산식 = `bisect_right`. 조정 이력 0건 시점에 적용해 마이그레이션 없음.
|
||||
상세: `docs/개발용.md` §2·§4.1.
|
||||
|
||||
## ~~2. `anchoring_records` — 회사별 앵커링 값 전체 조회 테이블~~ ✅ 뷰로 종결 (2026-07-02)
|
||||
|
||||
**신규 테이블 없이 조회용 뷰 2개로 해결.** 요구(회사별 값 업데이트 리스트업 + 이전 값 판별)는
|
||||
`rate_adjustments` 한 행에 before→after 가 박제되어 있어 이미 충족 — 테이블 추가는 사본만 만든다고
|
||||
판단해 기각하고, 조회를 제품화하는 뷰를 추가했다:
|
||||
|
||||
- `anchoring.rate_history` — 회사별 값 변경 이력(이전→새 값, 변화폭, 성공률, 시각)
|
||||
- `anchoring.current_rates` — 칸별 현재값(없는 칸 = 시작값 10‰)
|
||||
|
||||
상세: `docs/개발용.md` §6.3, 사용법: `docs/운영및유지보수.md` §8.
|
||||
추후 대시보드에서 "전체 칸 나열(무조정 칸 포함)·페이징" 요구가 생기면 그때 스냅샷 테이블로 승격을 재검토한다.
|
||||
|
||||
---
|
||||
|
||||
## 백로그 (저우선 — 리뷰에서 식별, 착수 조건 명시)
|
||||
|
||||
- [ ] **전환기 점프 정책 결정 (negodata 적용 직전 필수)**: backend 배포~negodata 적용 사이에
|
||||
학습된 rate 가 적용 순간 한 번에 반영된다("한 계단" 원칙의 1회 예외).
|
||||
적용 직전 `SELECT max(anchor_rate_after) FROM anchoring.current_rates` 로 폭 확인 후
|
||||
점프 감수 vs 이력 아카이브·리셋을 결정할 것 — 절차는 `docs/인수인계.md` 적용 순서 ③.
|
||||
|
||||
- [ ] **percent 입력 모드 대비**: `chat_service.send` 는 `user_input_type == "price"` 만 가격으로
|
||||
파싱한다. agent 에 percent 스크립트가 도입되면 percent 턴이 가격 흔적 없이 지나가
|
||||
학습에서 조용히 빠진다(현재 agent 스크립트에 percent 없음 — 잠복). 도입 시
|
||||
percent→price 변환(`target*(100-pct)//100`) 후 동일 경로로 태울 것.
|
||||
- [ ] **스캔 스트리밍**: 배치 스캔이 pending 전량을 메모리에 올린다. 레거시 수백만 행
|
||||
규모 DB 에 첫 적용할 때는 keyset 페이지네이션으로 전환 검토(제외 마킹은 이미 청크
|
||||
커밋이라 트랜잭션 장기화 없음).
|
||||
- [ ] **Redis 통합 테스트**: 자동 스위트는 무Redis(폴백 경로)로 돈다. CI 에 redis 컨테이너가
|
||||
생기면 §11.5 의 re-SET 회복·TTL·오염 값 방어(get_rate 범위 검증) 케이스를 자동화.
|
||||
- [ ] **config 오류 메시지**: config.toml 의 오타 키가 TypeError 로 죽는다 — 파일/섹션명을
|
||||
알려주는 검증 메시지로 개선.
|
||||
- [ ] **운영 Redis 인증**: compose 는 127.0.0.1 바인딩으로 방어했지만, 운영 네트워크에서
|
||||
negodata 가 원격 접속하는 구성이면 `requirepass` + `REDIS_PASSWORD` 설정을 적용할 것.
|
||||
17
schedules/anchoring/config.toml.example
Normal file
17
schedules/anchoring/config.toml.example
Normal file
@ -0,0 +1,17 @@
|
||||
# anchoring 모듈 설정 — config.toml 로 복사 후 채운다 (config.toml 은 gitignore).
|
||||
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 이 파일 > 코드 기본값.
|
||||
|
||||
log_level = "info"
|
||||
|
||||
[db]
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
name = "negosium_db"
|
||||
|
||||
[redis]
|
||||
host = "127.0.0.1"
|
||||
port = 6379
|
||||
db = 0
|
||||
password = ""
|
||||
30
schedules/anchoring/docker-compose.yml
Normal file
30
schedules/anchoring/docker-compose.yml
Normal file
@ -0,0 +1,30 @@
|
||||
# anchoring 자립 서비스 — 루트 compose 와 독립(다른 서버를 건드리지 않음).
|
||||
# DB 는 기존 외부 PostgreSQL(host.docker.internal), Redis 는 여기 동봉.
|
||||
# negodata(견적 생성 측)는 이 redis 인스턴스를 REDIS_HOST 로 바라본다(docs/인수인계.md).
|
||||
# 로그: stdout(json-file) — 로테이션 필수(장기 운영 디스크 보호). 로그 시각은 코드가 KST 로 고정.
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
anchoring-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: anchoring-redis
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # 호스트 로컬만 — 무인증 Redis 를 외부에 열지 않는다(앵커 값 오염 방지)
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
anchoring:
|
||||
build: .
|
||||
container_name: anchoring
|
||||
environment:
|
||||
DB_HOST: host.docker.internal
|
||||
REDIS_HOST: anchoring-redis
|
||||
TZ: Asia/Seoul
|
||||
depends_on:
|
||||
- anchoring-redis
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
712
schedules/anchoring/docs/개발용.md
Normal file
712
schedules/anchoring/docs/개발용.md
Normal file
@ -0,0 +1,712 @@
|
||||
# 앵커링 시스템 구현 스펙 (개발용)
|
||||
|
||||
> **문서 성격**: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본).
|
||||
> **규범 언어**: `MUST` = 반드시 준수, `MUST NOT` = 금지, `SHOULD` = 권장, `MAY` = 선택.
|
||||
> **스택**: Python(asyncio) + PostgreSQL(SQLAlchemy async / SQL 수동 적용, Alembic 없음) + Redis + APScheduler — **`schedules/anchoring` 자립 컨테이너**(backend 내장 아님, FastAPI 미사용).
|
||||
> **버전**: v1.2 (2026-07-02 확정) — 정책 배경은 `기획용.md`, 흐름 해설은 `워크플로우.md`, 타 팀(negodata) 적용 명세는 `인수인계.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [확정 결정 요약](#1-확정-결정-요약)
|
||||
2. [정적 기본 테이블](#2-정적-기본-테이블)
|
||||
3. [상수 정의](#3-상수-정의)
|
||||
4. [도메인 규칙](#4-도메인-규칙)
|
||||
5. [아키텍처](#5-아키텍처)
|
||||
6. [DB 스키마](#6-db-스키마)
|
||||
7. [Redis 캐시 규약](#7-redis-캐시-규약)
|
||||
8. [배치 잡 명세](#8-배치-잡-명세)
|
||||
9. [견적 생성·협상 플로우](#9-견적-생성협상-플로우)
|
||||
10. [참조 구현](#10-참조-구현)
|
||||
11. [검증 벡터 (Golden Tests)](#11-검증-벡터-golden-tests)
|
||||
12. [금지·봉인 사항](#12-금지봉인-사항)
|
||||
13. [선행·연계 작업](#13-선행연계-작업)
|
||||
|
||||
---
|
||||
|
||||
## 1. 확정 결정 요약
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| 역할 분담 | **`schedules/anchoring` 자립 모듈** = 정적 테이블·rate 조회(reader)·조정 배치·Redis 규약·DDL 소유, 독립 컨테이너로 자체 스케줄 실행 / **negodata** = 세션 생성 시 reader 로 rate 조회 → 앵커가·rate 박제 (인수인계, §9.1) / **backend** = 협상 채팅(박제값 소비 + 마지막 제시가 기록, anchoring 모듈 **무의존**) (§9.2) / **agent** = **변경 없음**(앵커 비노출 — 정보 비대칭 전략) |
|
||||
| 소유·수정 범위 | 직접 수정 가능 = `backend`·`frontend`·`schedules`(우리 모듈). `negodata`·`agent`는 인수인계 문서로 전달 → 담당 개발자가 적용 |
|
||||
| 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 |
|
||||
| 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 |
|
||||
| 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 |
|
||||
| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`target_anchoring_price`, `anchor_rate_permille`, `last_offered_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
|
||||
| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offered_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
|
||||
| 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 |
|
||||
| 조정 이력 저장 | **append-only 조정 이력** `anchoring.rate_adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 |
|
||||
| 소비 경계 | `sessions.anchoring_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** |
|
||||
| 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 |
|
||||
| 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월 |
|
||||
| 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 |
|
||||
| 값 표현 | 앵커링 값은 **정수 천분율(‰)** 로 저장·계산 (부동소수점 산술 금지) |
|
||||
| 코드값 | 프로젝트 컨벤션: **SMALLINT 1-based 코드 + 앱 enum 매핑, DB CHECK/FK/ENUM 없음** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 정적 기본 테이블
|
||||
|
||||
서비스 시작 시 메모리에 로드되는 불변 리스트. **DB에 저장하지 않으며, 런타임에 절대 수정하지 않는다** (MUST NOT).
|
||||
|
||||
파일: `src/anchoring/resources/anchoring_base.json` (리포에 커밋, **46행**). 키는 프로젝트 컨벤션대로 snake_case. 구간은 **자릿수 계단식 사다리**:
|
||||
|
||||
| 구간 | 폭 | 칸 수 |
|
||||
|---|---|---|
|
||||
| 0 ~ 1,000 | (한 칸으로 통일) | 1 |
|
||||
| 1,000 ~ 1만 | 1,000원 | 9 |
|
||||
| 1만 ~ 10만 | 1만 | 9 |
|
||||
| 10만 ~ 100만 | 10만 | 9 |
|
||||
| 100만 ~ 1,000만 | 100만 | 9 |
|
||||
| 1,000만 ~ 1억 | 1,000만 | 9 |
|
||||
| **합계** | 폭 = 자릿수 시작값(구간 상한의 10%) | **46** |
|
||||
|
||||
```json
|
||||
[
|
||||
{ "idx": 1, "upper_bound": 1000, "anchoring_value": 0.01 },
|
||||
{ "idx": 2, "upper_bound": 2000, "anchoring_value": 0.01 },
|
||||
...
|
||||
{ "idx": 10, "upper_bound": 10000, "anchoring_value": 0.01 },
|
||||
{ "idx": 11, "upper_bound": 20000, "anchoring_value": 0.01 },
|
||||
...
|
||||
{ "idx": 46, "upper_bound": 100000000, "anchoring_value": 0.01 }
|
||||
]
|
||||
```
|
||||
|
||||
> 각 칸은 사람이 부르는 가격대와 일치한다 — idx 13 = "3만 원대"([30,000, 40,000)). 1억 초과 가격은 전부 마지막 인덱스로 클램프된다(§2.1). 사다리의 단일 소스는 `constants.UPPER_BOUNDS`(생성식)이며, json 은 기동 시 이와 대조 검증된다.
|
||||
|
||||
### 2.1 매핑 규약 (MUST)
|
||||
|
||||
| 항목 | 규약 |
|
||||
|---|---|
|
||||
| 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) |
|
||||
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) |
|
||||
| 내부 인덱스 변환 | `bracket_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `bracket_index` 사용 |
|
||||
| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `bracket_index` 45). **1억 초과도 예외 없이 마지막 인덱스** |
|
||||
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(round(anchoring_value * 1000))`. 현재 전 구간 10‰ |
|
||||
| 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
|
||||
|
||||
- 시작값은 **정적 테이블에서만** 읽는다. 코드에 `0.01`/`10` 하드코딩 **MUST NOT** (테이블이 유일한 소스).
|
||||
- `anchoring_value`는 회사 무관 공통. 회사별 차이는 **조정 이력의 누적**에서만 발생한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 상수 정의
|
||||
|
||||
모든 비율은 정수 천분율(permille). `10‰ = 1%`.
|
||||
|
||||
```python
|
||||
# src/anchoring/constants.py
|
||||
|
||||
ANCHOR_RATE_MIN = 10 # 하한 1%
|
||||
ANCHOR_RATE_MAX = 200 # 상한 20%
|
||||
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드
|
||||
|
||||
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
|
||||
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
|
||||
DELTA_PERMILLE = {
|
||||
1: 20, # 유통(DISTRIBUTION) ±2%
|
||||
2: 10, # 제조(MANUFACTURE) ±1%
|
||||
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
|
||||
}
|
||||
|
||||
SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 스킵·이월)
|
||||
|
||||
# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
|
||||
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
|
||||
UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개
|
||||
BRACKET_COUNT = 46
|
||||
BRACKET_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한
|
||||
|
||||
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
||||
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
||||
|
||||
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값
|
||||
|
||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조
|
||||
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
||||
```
|
||||
|
||||
앱 enum — backend 는 anchoring 판정을 하지 않으므로(무의존) enum 은 **모듈 내부**(`constants.py`)에 둔다 (프로젝트 컨벤션 — plain `Enum`, 1-based, 대상 컬럼 docstring):
|
||||
|
||||
```python
|
||||
class SupplierType(Enum):
|
||||
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
|
||||
(negodata SupplierType 과 동일 코드)"""
|
||||
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
|
||||
DISTRIBUTION = 1 # 유통
|
||||
MANUFACTURE = 2 # 제조
|
||||
SOLE_AGENCY = 3 # 총판
|
||||
|
||||
class AnchoringSampleType(Enum):
|
||||
"""앵커링 표본 판정 결과(파생값 — DB 에 저장하지 않음, 평가 로직·로그용).
|
||||
기준 = "가격 흔적": 가격을 써낸 협상만 표본."""
|
||||
BID_SUCCESS = 1 # 정상종료 + bid ≤ 박제 앵커
|
||||
BID_FAIL = 2 # 가격 흔적 있으나 성공 아님 (앵커 초과 합의 / 결렬 / 가격 쓰고 이탈·만료)
|
||||
EXCLUDED = 3 # 가격 흔적 없음 / 앵커 박제 없음 / 유형 미지정
|
||||
```
|
||||
|
||||
- 상수 변경은 정책 재확정 사안이다. 코드에서 임의 조정 **MUST NOT**.
|
||||
- 앵커링 값을 float으로 저장·연산 **MUST NOT**. 모든 산술은 정수로 수행한다 (성공률 비교도 §10처럼 정수 비교).
|
||||
|
||||
---
|
||||
|
||||
## 4. 도메인 규칙
|
||||
|
||||
### 4.1 칸(cell) 식별
|
||||
|
||||
칸 = **`(company_id, supplier_type, bracket_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
|
||||
|
||||
```
|
||||
bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
|
||||
```
|
||||
|
||||
- `bracket_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
|
||||
- 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨).
|
||||
- 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**.
|
||||
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
|
||||
- `supplier_type ∉ {1,2,3}` 이거나 `company_id` 미해석 세션은 칸을 구성할 수 없다 → 가격 산출은 정적 테이블 시작값으로 동작(§9), 집계에서는 제외(§4.3).
|
||||
|
||||
### 4.2 앵커링가 계산
|
||||
|
||||
```
|
||||
anchor_price = target_price × (1000 − anchor_rate_permille) // 1000
|
||||
```
|
||||
|
||||
- `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다.
|
||||
- 부동소수점 곱셈 경유 **MUST NOT** (`int(price * 0.99)`, `round(price * 0.99)` 형태 금지).
|
||||
- 결과는 항상 1원 단위 정수.
|
||||
|
||||
### 4.3 표본 판정 (배치 시점 파생 — "가격 흔적" 기준)
|
||||
|
||||
표본 = 종료된 재협상 세션 중 **협력사가 가격을 한 번이라도 써낸 것**. 전용 테이블 없이, 배치가 아래 종료 후 불변 입력에서 판정을 파생한다.
|
||||
|
||||
> 한 줄 요약: **"가격을 써낸 협상만 세고 — 앵커 이하로 합의됐으면 성공, 나머지는 전부 실패."**
|
||||
|
||||
판정 입력:
|
||||
|
||||
| 컬럼 | 의미 | 기록 시점 |
|
||||
|---|---|---|
|
||||
| `sessions.target_anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) |
|
||||
| `sessions.anchor_rate_permille` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 |
|
||||
| `sessions.last_offered_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 |
|
||||
| `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 |
|
||||
|
||||
판정 대상: `qt_type = 1(재협상)` AND `status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED}` AND `deleted = false`.
|
||||
|
||||
| 판정 | 조건 | 유효 표본 | 성공 |
|
||||
|---|---|---|---|
|
||||
| `BID_SUCCESS` | `status=DONE` AND `bid_price ≤ target_anchoring_price` | O | O |
|
||||
| `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X |
|
||||
| `EXCLUDED` | `last_offered_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — |
|
||||
|
||||
- "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
|
||||
- **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
|
||||
- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offered_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.
|
||||
|
||||
### 4.4 평가 산식 (누적 전량 평가)
|
||||
|
||||
배치 시점에 칸별로 수행한다.
|
||||
|
||||
```
|
||||
pending = 해당 칸의 미처리(anchoring_adjustment_id IS NULL) 유효 표본 전부
|
||||
n = |pending|
|
||||
|
||||
n < 10 → 평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
|
||||
n ≥ 10 → r = (pending 중 BID_SUCCESS 건수) / n
|
||||
|
||||
┌ +δ(p) if r ≥ 0.60
|
||||
delta = ┤ 0 if 0.30 ≤ r < 0.60
|
||||
└ −δ(p) if r < 0.30
|
||||
|
||||
anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200)
|
||||
→ 한 트랜잭션으로:
|
||||
① anchoring.rate_adjustments INSERT (n, success, before/after, consumed_session_ids 박제)
|
||||
② 소비 세션 UPDATE sessions SET anchoring_adjustment_id = <조정 id>
|
||||
WHERE session_id IN (...) AND anchoring_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
|
||||
```
|
||||
|
||||
- **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
|
||||
- delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST).
|
||||
- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `anchoring_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다.
|
||||
- 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).
|
||||
|
||||
### 4.5 현재 앵커링 값 조회
|
||||
|
||||
값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다.
|
||||
|
||||
```
|
||||
rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after
|
||||
없으면 → 정적 테이블 시작값 (§2.1)
|
||||
```
|
||||
|
||||
- 재현성: 조정 행에 박제된 `consumed_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST).
|
||||
- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `consumed_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능).
|
||||
- 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 아키텍처
|
||||
|
||||
```
|
||||
[anchoring 서비스 기동 — schedules/anchoring 독립 컨테이너]
|
||||
정적 기본 테이블 메모리 로드·검증 (불변, §2) ── Redis 클라이언트 init ── APScheduler 기동
|
||||
|
||||
[견적/세션 생성 — negodata, 인수인계 §9.1]
|
||||
│ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
|
||||
│ → 세션 INSERT 에 target_anchoring_price + anchor_rate_permille 박제 (재생성 상속 폐지)
|
||||
▼
|
||||
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
|
||||
│ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
|
||||
│ 가격 입력 턴마다 last_offered_price 갱신 (가격 흔적)
|
||||
▼
|
||||
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
|
||||
│
|
||||
│ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
|
||||
│ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
|
||||
▼
|
||||
anchoring.rate_adjustments ────────── 진실 원천 (INSERT only, consumed_session_ids·값 변화 박제)
|
||||
│
|
||||
│ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
|
||||
▼
|
||||
Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7일
|
||||
│
|
||||
│ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
|
||||
▼
|
||||
[다음 견적/세션 생성] 조정된 rate 로 앵커가 산출
|
||||
```
|
||||
|
||||
- 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**.
|
||||
- 배치가 `sessions`에 쓰는 것은 `anchoring_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT.
|
||||
- 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
|
||||
- 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
|
||||
- Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).
|
||||
|
||||
---
|
||||
|
||||
## 6. DB 스키마
|
||||
|
||||
프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 **모듈 소유** — `schema.sql` 한 파일(스키마+테이블+sessions ALTER+인덱스, psql 수동 적용). `postgres-init` 에는 anchoring 파일을 두지 않는다.
|
||||
|
||||
**네이밍 결정** — 기존 코드베이스 용어와 통일:
|
||||
|
||||
| 개념 | 명칭 | 이유 |
|
||||
|---|---|---|
|
||||
| 조정 이력 테이블 | **`anchoring.rate_adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 |
|
||||
| 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 |
|
||||
| 가격구간 | **`price_bracket_index`** | 가격구간임을 명시 (코드 내부 변수는 `bracket_index`) |
|
||||
| 표본 수 | **`nego_count`** | "협상 결과 n건" — 정책 문서 용어 |
|
||||
| 값 변화 | **`anchor_rate_before` / `anchor_rate_after`** | `sessions.anchor_rate_permille`와 계열 통일 (‰) |
|
||||
| 소비 창 | **`consumed_session_ids`** | "이 조정이 소비한 세션"임을 명시 |
|
||||
| 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) |
|
||||
| 소비 마킹 | **`sessions.anchoring_adjustment_id`** | 조정 테이블명과 정합 |
|
||||
|
||||
### 6.1 조정 이력 (신설 — 유일한 새 테이블)
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS anchoring;
|
||||
|
||||
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
|
||||
CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
|
||||
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
|
||||
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
|
||||
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
|
||||
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
|
||||
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
|
||||
anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
|
||||
consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 현재 값 조회 최적화: 칸별 최신 조정
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell
|
||||
ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC);
|
||||
```
|
||||
|
||||
### 6.2 sessions 확장 (기존 테이블 ALTER)
|
||||
|
||||
```sql
|
||||
ALTER TABLE negotiation.sessions
|
||||
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제
|
||||
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
|
||||
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
|
||||
|
||||
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST —
|
||||
-- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장)
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
|
||||
ON negotiation.sessions (status)
|
||||
WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
|
||||
```
|
||||
|
||||
### 6.3 조회용 뷰 (파생 — 상태 없음)
|
||||
|
||||
회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 rate_adjustments 그대로):
|
||||
|
||||
```sql
|
||||
anchoring.rate_history -- 값 변경 이력 리스트업: 이전 값(anchor_rate_before)→새 값 + delta_permille·success_rate·created_at
|
||||
anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
|
||||
```
|
||||
|
||||
- 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
|
||||
- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 rate_adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.
|
||||
|
||||
주의사항:
|
||||
|
||||
- `sessions.target_anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).
|
||||
- 신규 DB 구축 시 적용 순서: `postgres-init/01~04` → `schedules/anchoring/schema.sql` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema.sql`·`04-alter.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유.
|
||||
- backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `rate_adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).
|
||||
|
||||
---
|
||||
|
||||
## 7. Redis 캐시 규약
|
||||
|
||||
| 항목 | 규약 |
|
||||
|---|---|
|
||||
| 키 | `anchor:{company_id}:{supplier_type}:{bracket_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` |
|
||||
| 값 | 정수 천분율 문자열. 예: `"30"` |
|
||||
| TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
|
||||
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 |
|
||||
| 갱신 | 배치가 평가한 칸 SET + **매주 토 잡 실행 시(격주 게이트 무관) 조정 이력 보유 칸 전체 re-SET** (§8 절차 0.5) |
|
||||
| 장애 내성 | Redis 에러 시 GET→None 취급(DB 폴백), SET 은 로그만 남기고 무시 (MUST — 견적 생성·배치를 Redis 가 막으면 안 됨). socket timeout **0.2~0.5초** 설정 MUST(행 방지) |
|
||||
| 값 검증 | GET 값이 정책 범위 **[10, 200] 밖이면 오염**(외부 SET 등)으로 간주 — WARN 후 미스 취급(DB 폴백 + 재적재로 자가 교정). 캐시 값을 검증 없이 제안가에 쓰지 않는다 (MUST) |
|
||||
|
||||
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
|
||||
- ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
|
||||
- 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
|
||||
- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)와 **negodata**(reader GET, 인수인계). backend 는 Redis 를 쓰지 않는다. 설정은 모듈 `config.toml` + `REDIS_HOST/PORT/PASSWORD` env 오버라이드. Redis 인스턴스는 모듈 docker-compose 에 동봉(negodata 가 같은 인스턴스를 바라봄).
|
||||
- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. 모듈 compose 는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그).
|
||||
|
||||
---
|
||||
|
||||
## 8. 배치 잡 명세
|
||||
|
||||
- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지).
|
||||
- **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST).
|
||||
- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND anchoring_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
|
||||
- **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
|
||||
|
||||
```
|
||||
절차 (run_evaluation_batch(force=False)):
|
||||
0. force 아니고 격주 게이트 미충족 → 절차 0.5 만 수행 후 종료
|
||||
0.5. 캐시 정합(매주, 게이트 무관): Redis ping 확인 후 조정 이력 보유 칸 전체의 최신 rate 를 일괄 re-SET
|
||||
(Redis 미가용이면 WARN 후 즉시 건너뜀 — 셀마다 timeout 을 태우며 지연되지 않게)
|
||||
(SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
|
||||
1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조):
|
||||
sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false)
|
||||
WHERE s.anchoring_adjustment_id IS NULL AND s.deleted = false
|
||||
AND s.qt_type = 1 AND s.status IN (3, 4, 5)
|
||||
2. 세션별 파생 판정(§4.3):
|
||||
- EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
|
||||
→ anchoring_adjustment_id = 0 일괄 마킹 (재스캔 방지)
|
||||
- 유효 표본 → 칸별 그룹 적재
|
||||
- 박제 정합 감시: rate 가 박제된 세션에 대해 tp×(1000−anchor_rate_permille)//1000 과
|
||||
박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
|
||||
— negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
|
||||
검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
|
||||
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
|
||||
anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값)
|
||||
anchor_rate_after = evaluate_pending(...) # §4.4 / §10
|
||||
① anchoring.rate_adjustments INSERT (consumed_session_ids 박제)
|
||||
② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
|
||||
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchor_rate_after (best effort, TTL 7일)
|
||||
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / 상·하한 도달 / 이월 칸 수 / 제외 마킹 건수
|
||||
+ 가격 제시율(종료 재협상 세션 중 last_offered_price 보유 비율) — 0% 면 WARN
|
||||
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
|
||||
```
|
||||
|
||||
**로그 규약** (운영 추적):
|
||||
|
||||
- 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
|
||||
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= bracket=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`).
|
||||
- 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약.
|
||||
- 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.
|
||||
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
|
||||
|
||||
- **dry-run 모드** (`--once --dry-run` / `run_evaluation_batch(dry_run=True)`): 절차 0.5 re-SET·제외 마킹·조정 INSERT·캐시 SET 을 전부 건너뛰고, 판정 결과·예상 조정(`조정예정` 라인, adj_id=None)·제외 예정 건수만 로그로 남긴다(종료 status `dry_run`). 상태를 소비하지 않으므로 직후 실제 실행 결과와 동일하다 — 첫 운영 실행(레거시 세션 전량 판정) 전에 규모를 확인하는 예행 용도.
|
||||
- 수동·테스트 실행은 `run_evaluation_batch(company_ids=[...])` 로 대상 회사를 한정할 수 있다 — 공유 DB 에서 다른 회사의 미처리 세션을 소비하지 않는다(테스트 스위트가 사용).
|
||||
- INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.
|
||||
- n < 10 칸의 유효 표본은 **마킹하지 않는다** — 그것이 이월이다.
|
||||
- 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.
|
||||
- 별도 batch_runs 테이블 없음 — 조정 이력이 곧 실행 기록이며, 회차 요약은 LOG로 남긴다.
|
||||
- **운영 런북**: 토 00:00 에 서비스가 내려가 있었다면(misfire_grace 1h 초과) 그 회차는 스킵되고 패리티 게이트 때문에 2주 뒤 실행된다. 누적 평가라 데이터 손실은 없으나, 재기동 후 `--once` 수동 1회 실행으로 즉시 따라잡을 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 견적 생성·협상 플로우 (역할 분담)
|
||||
|
||||
앵커가의 **산출·박제 주체는 negodata(견적 생성 측)** 이고, backend(협상 채팅)는 박제값의 소비자다. negodata 변경은 직접 수정하지 않고 `인수인계.md`로 전달한다. **agent 는 변경하지 않는다.**
|
||||
|
||||
### 9.1 견적/세션 생성 — negodata (인수인계 대상)
|
||||
|
||||
현재 negodata `_build_quotation`은 세션 생성 시 `target_anchoring_price`를 구 방식으로 채운다
|
||||
(신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속).
|
||||
새 앵커링 모듈 전달 후 아래로 교체된다:
|
||||
|
||||
```
|
||||
세션(상품 × 공급사) 생성 시마다:
|
||||
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
|
||||
bracket_index = calc_bracket_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식
|
||||
2. rate 조회 (모듈의 reader 이식):
|
||||
supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
|
||||
그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12)
|
||||
3. anchor_price = target_price × (1000 − rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기)
|
||||
4. 세션 INSERT 에 target_anchoring_price = anchor_price, anchor_rate_permille = rate 포함 (박제)
|
||||
```
|
||||
|
||||
- **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
|
||||
- `quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다(컬럼·화면 표기는 유지 가능).
|
||||
- **전환기 동작**: negodata 적용 전까지는 구 방식 값이 계속 박제된다 — 판정(§4.3)은 박제된 anchor 기준이므로 표본·조정은 그동안에도 유효하게 쌓이고, negodata 적용 시점부터 조정된 rate 가 실제 기준가에 반영되기 시작한다(자연 부트스트랩, 별도 마이그레이션 불필요).
|
||||
|
||||
### 9.2 협상 채팅 — backend (직접 구현, anchoring 모듈 무의존)
|
||||
|
||||
`backend/services/chat_service.py::_resolve_anchor_price` — `quotation_settings.anchoring_value` 읽기 **삭제**. `_agent_context`가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다.
|
||||
|
||||
```
|
||||
1. 박제값 사용 (MUST): sessions.target_anchoring_price 를 그대로 사용.
|
||||
→ negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
|
||||
→ 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
|
||||
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
|
||||
박제하지 않는다 → 이 세션은 anchor 박제가 없어 배치 판정에서 자동 EXCLUDED (학습 무오염).
|
||||
agent 에는 양수 anchor 가 보장되어 기존 검증(ValueError) 안전.
|
||||
3. agent 컨텍스트로 anchor_price 전달 (기존 AgentChatContext.anchor_price 그대로)
|
||||
```
|
||||
|
||||
- 퇴화 케이스: `target_price` 가 0/NULL 인 세션은 anchor 0 을 반환한다(기존 동작 보존) — 정상 데이터에서는 발생하지 않는다.
|
||||
|
||||
**가격 흔적 기록** (MUST):
|
||||
|
||||
- agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
|
||||
- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offered_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
|
||||
- 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).
|
||||
|
||||
---
|
||||
|
||||
## 10. 참조 구현
|
||||
|
||||
```python
|
||||
# src/anchoring/service.py (순수 함수만 — DB/Redis 접근 없음)
|
||||
from bisect import bisect_right
|
||||
|
||||
from anchoring.constants import (
|
||||
ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE,
|
||||
SAMPLE_THRESHOLD, UPPER_BOUNDS, BRACKET_INDEX_MAX,
|
||||
AnchoringSampleType,
|
||||
)
|
||||
from anchoring.base_table import get_base_rate_permille # 정적 테이블 조회 (§2)
|
||||
|
||||
|
||||
def calc_bracket_index(target_price: int) -> int:
|
||||
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
|
||||
좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
|
||||
정적 테이블 idx = 반환값 + 1"""
|
||||
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
|
||||
|
||||
|
||||
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
|
||||
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
|
||||
return target_price * (1000 - rate_permille) // 1000
|
||||
|
||||
|
||||
def judge_sample_type(
|
||||
is_done: bool, # sessions.status == DONE(3)
|
||||
bid_price: int | None, # 확정 투찰가(DONE 시)
|
||||
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
|
||||
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커)
|
||||
) -> int:
|
||||
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
|
||||
if anchor_price is None or last_offered_price is None:
|
||||
return AnchoringSampleType.EXCLUDED.value
|
||||
if is_done and bid_price is not None and bid_price <= anchor_price:
|
||||
return AnchoringSampleType.BID_SUCCESS.value
|
||||
return AnchoringSampleType.BID_FAIL.value
|
||||
|
||||
|
||||
def evaluate_pending(
|
||||
rate_before: int,
|
||||
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
|
||||
supplier_type: int, # SMALLINT 코드 1/2/3
|
||||
) -> int | None:
|
||||
"""누적 전량 평가. §4.4
|
||||
반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월)
|
||||
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
|
||||
"""
|
||||
n = len(sample_types)
|
||||
if n < SAMPLE_THRESHOLD:
|
||||
return None
|
||||
|
||||
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
|
||||
delta = DELTA_PERMILLE[supplier_type]
|
||||
|
||||
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
|
||||
if success * 10 >= n * 6:
|
||||
adjusted = rate_before + delta
|
||||
elif success * 10 < n * 3: # r < 0.30
|
||||
adjusted = rate_before - delta
|
||||
else: # 0.30 ≤ r < 0.60
|
||||
adjusted = rate_before
|
||||
|
||||
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted))
|
||||
|
||||
|
||||
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int:
|
||||
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
|
||||
if latest_adjusted_rate is not None:
|
||||
return latest_adjusted_rate
|
||||
return get_base_rate_permille(bracket_index) # int(round(anchoring_value * 1000))
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 배치의 미처리 세션 스캔 (§8 절차 1)
|
||||
-- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어
|
||||
-- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지.
|
||||
SELECT s.session_id, s.status, s.bid_price, s.target_price,
|
||||
s.target_anchoring_price, s.last_offered_price,
|
||||
q.supplier_type, i.company_id
|
||||
FROM negotiation.sessions s
|
||||
LEFT JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false
|
||||
LEFT JOIN partner.items i ON i.item_id = s.item_id AND i.deleted = false
|
||||
WHERE s.anchoring_adjustment_id IS NULL
|
||||
AND s.deleted = false
|
||||
AND s.qt_type = 1
|
||||
AND s.status IN (3, 4, 5)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 현재 값 조회 (캐시 미스 시)
|
||||
SELECT anchor_rate_after
|
||||
FROM anchoring.rate_adjustments
|
||||
WHERE company_id = :c AND supplier_type = :p AND price_bracket_index = :b
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 검증 벡터 (Golden Tests)
|
||||
|
||||
아래 케이스가 전부 성립해야 한다. 위치: 골든 벡터·배치 통합 = **`schedules/anchoring/tests/`**, 가격 흔적 기록·NULL 폴백 = `backend/tests/`. 단 Redis 의존 케이스(re-SET 회복)와 다중 프로세스 동시 실행·타이밍 케이스는 자동 스위트(무Redis·단일 프로세스)가 아닌 **수동/후속 검증** 대상이다 — 자동화된 것은 pytest 로 고정돼 있다.
|
||||
|
||||
### 11.1 앵커링가 계산 (내림 검증)
|
||||
|
||||
| target_price | rate(‰) | 계산 | anchor_price |
|
||||
|---|---|---|---|
|
||||
| 30,000 | 200 | 30,000 × 800 // 1000 | **24,000** |
|
||||
| 26,706 | 10 | 26,706 × 990 // 1000 = 26,438.94 → 내림 | **26,438** |
|
||||
| 29,999 | 15 | 29,999 × 985 // 1000 = 29,549.015 → 내림 | **29,549** |
|
||||
| 0 | 10 | 0 | **0** |
|
||||
|
||||
### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
|
||||
|
||||
| target_price | bracket_index | 정적 테이블 idx | 칸 |
|
||||
|---|---|---|---|
|
||||
| 0 | 0 | 1 | [0, 1,000) 통일 칸 |
|
||||
| 999 | 0 | 1 | [0, 1,000) |
|
||||
| 1,000 | 1 (경계는 상위 구간) | 2 | 1천 원대 |
|
||||
| 9,999 | 9 | 10 | 9천 원대 |
|
||||
| 10,000 | 10 | 11 | 1만 원대 |
|
||||
| 30,000 | 12 | 13 | 3만 원대 |
|
||||
| 150,000 | 19 | 20 | 10만 원대 |
|
||||
| 99,999,999 | 45 (마지막 구간) | 46 | 9천만 원대 |
|
||||
| 100,000,000 | 45 | 46 | 마지막 칸 |
|
||||
| 150,000,000 | 45 (**1억 초과 → 마지막 인덱스 클램프**) | 46 | 마지막 칸 |
|
||||
|
||||
정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000.
|
||||
|
||||
### 11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10)
|
||||
|
||||
| pending 구성 | n | r | 판정 | anchor_rate_after |
|
||||
|---|---|---|---|---|
|
||||
| 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** |
|
||||
| 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** |
|
||||
| 성공 3 / 실패 10 | 13 | ≈ 0.231 | < 0.30 → −20 | **10** (하한 clamp) |
|
||||
| 성공 6 / 실패 4 | 10 | 0.60 정확히 | 경계 포함 → +20 | **30** |
|
||||
| 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** |
|
||||
| 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None |
|
||||
|
||||
**δ 스왑 가드 (MUST)**: `evaluate_pending(10, [성공10/10], supplier_type=2) == 20` (제조 +10), `supplier_type=3 → 25` (총판 +15).
|
||||
|
||||
clamp·격리 케이스:
|
||||
|
||||
| 시나리오 | 기대 |
|
||||
|---|---|
|
||||
| rate_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 |
|
||||
| A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 |
|
||||
| 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 |
|
||||
| EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리 |
|
||||
|
||||
### 11.4 파생 판정
|
||||
|
||||
| status | bid_price | last_offered_price | anchor_price | 기대 |
|
||||
|---|---|---|---|---|
|
||||
| DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) |
|
||||
| DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) |
|
||||
| REJECTED | NULL | 25,000 | 24,000 | BID_FAIL (가격 쓰고 결렬) |
|
||||
| NOT_PARTICIPATED (일괄마감) | NULL | 25,000 | 24,000 | BID_FAIL (가격 쓰고 중간 이탈) |
|
||||
| 임의 종료 상태 | NULL | NULL | 24,000 | EXCLUDED (가격 흔적 없음) |
|
||||
| DONE | 24,000 | 24,000 | NULL | EXCLUDED (앵커 박제 없음) |
|
||||
|
||||
### 11.5 배치 멱등성·이월·소비 (DB 통합 — 세션 시드 기반)
|
||||
|
||||
| 시나리오 | 기대 |
|
||||
|---|---|
|
||||
| 유효 13건 시드 → 배치 | 조정 1행(n=13, consumed_session_ids 13개 박제, 10→30) + 13건 모두 `anchoring_adjustment_id`=조정 id |
|
||||
| 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 — 마킹 멱등) |
|
||||
| 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 |
|
||||
| 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 |
|
||||
| supplier_type NULL 세션 | 집계 제외 + 마킹 0, 이후 배치에서 재스캔 안 됨 |
|
||||
| 세션 종료가 배치 스캔 직후 커밋 | 마킹 안 됐으므로 다음 배치에서 정상 소비 (영구 누락 없음) |
|
||||
| 마킹 rowcount ≠ n (경합 시뮬레이션: pending 일부를 미리 마킹) | 조정 INSERT 포함 **전체 롤백** — 조정 0건, 이중 조정 없음 |
|
||||
| 배치 2개 프로세스 동시 실행(오설정 시뮬레이션) | 한쪽만 조정 성공, 다른 쪽은 rowcount 불일치 롤백 → 칸당 조정 정확히 1건 |
|
||||
| Redis 에 옛 rate 를 심고 주간 잡 실행(격주 게이트 OFF 주) | 절차 0.5 re-SET 으로 최신 rate 로 회복 |
|
||||
| 가격 제시율 0% 상태에서 배치 실행 | 요약 로그에 WARN 출력 (backend 기록 배선 유실 감지) |
|
||||
| dry-run 실행 (유효 10건 + 제외 1건 시드) | status=`dry_run`·`조정예정` 로그만 — 조정 0행·마킹 없음(제외 포함). 직후 실제 실행 시 그대로 반영(상태 미소비 증명) |
|
||||
| rate=10‰ 박제인데 anchor 가 정수식과 다른 세션 | "박제 정합 불일치 1건" WARN (판정은 박제 anchor 기준 그대로) |
|
||||
|
||||
### 11.6 읽기 경로·가격 흔적 (E2E 스모크)
|
||||
|
||||
| 시나리오 | 기대 |
|
||||
|---|---|
|
||||
| 재협상 채팅 → 가격 입력 턴 | `last_offered_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) |
|
||||
| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offered_price` 보존 → 배치에서 BID_FAIL 표본 |
|
||||
| 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) |
|
||||
| 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 |
|
||||
| Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 금지·봉인 사항
|
||||
|
||||
구현자가 임의로 추가·가정하면 안 되는 항목:
|
||||
|
||||
- **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
|
||||
- **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
|
||||
- **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
|
||||
- **sessions 판정 입력 컬럼(`target_anchoring_price`, `anchor_rate_permille`)의 사후 수정, `last_offered_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `anchoring_adjustment_id` 단 하나.
|
||||
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
|
||||
- **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
|
||||
- **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립.
|
||||
- **float 산술** — 앵커링가·rate 계산에 부동소수점 사용 금지 (`round(target*0.99)` 패턴 금지).
|
||||
- **앵커가 노출** — 앵커가를 협력사 화면에 표시하는 변경은 판정 의미론(§4.3의 무편향 전제)까지 바꾸는 정책 재확정 사안.
|
||||
|
||||
---
|
||||
|
||||
## 13. 선행·연계 작업
|
||||
|
||||
담당 구분: **[우리]** = backend/schedules 직접 구현(완료), **[인수인계]** = 모듈·명세를 전달 → 담당 개발자가 적용.
|
||||
|
||||
| # | 항목 | 담당 | 상태 |
|
||||
|---|---|---|---|
|
||||
| 1 | DDL — `anchoring.rate_adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) |
|
||||
| 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체** — `_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 |
|
||||
| 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 |
|
||||
| 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) |
|
||||
| 5 | company_id 식별 | — | `partner.items.company_id` (세션→item 조인, 기존 `_agent_context` 해석 방식과 동일) |
|
||||
| 6 | `quotations.supplier_type` 기록 | [인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) |
|
||||
| 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 |
|
||||
| 8 | Redis 인프라 | [우리 — 모듈] | 모듈 docker-compose 에 redis 동봉, negodata 가 같은 인스턴스 참조. backend 는 Redis 무의존 |
|
||||
| 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 |
|
||||
| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offered_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 |
|
||||
224
schedules/anchoring/docs/기획용.md
Normal file
224
schedules/anchoring/docs/기획용.md
Normal file
@ -0,0 +1,224 @@
|
||||
# 앵커링 값 자동 조정 시스템 — 정책 안내서 (기획/비개발자용)
|
||||
|
||||
> **한 줄 요약**: 협상에서 "이 가격 이하면 합의한다"는 우리 쪽 기준선을, 시장의 반응을 보면서 시스템이 스스로 조금씩 조절해 나가는 장치입니다. 사람이 일일이 정하지 않아도, 협상 결과가 쌓일수록 "너무 세지도, 너무 약하지도 않은" 적정 강도를 자동으로 찾아갑니다.
|
||||
>
|
||||
> **버전**: v1.2 (2026-07-02 확정) — 기술 상세는 `개발용.md`, 흐름 해설은 `워크플로우.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [1. 앵커링이 뭔가요?](#1-앵커링이-뭔가요)
|
||||
- [2. 시스템이 관리하는 단위: "칸"](#2-시스템이-관리하는-단위-칸)
|
||||
- [3. 작동 원리 — 흥정에 비유하면](#3-작동-원리--흥정에-비유하면)
|
||||
- [4. 규칙 상세](#4-규칙-상세)
|
||||
- [5. 숫자로 따라가 보는 예시 시나리오](#5-숫자로-따라가-보는-예시-시나리오)
|
||||
- [6. 협상이 중간에 끝난 경우는요?](#6-협상이-중간에-끝난-경우는요)
|
||||
- [7. 왜 이렇게 설계했나요?](#7-왜-이렇게-설계했나요)
|
||||
- [8. 자주 나오는 질문 (FAQ)](#8-자주-나오는-질문-faq)
|
||||
- [9. 용어 정리](#9-용어-정리)
|
||||
|
||||
---
|
||||
|
||||
## 1. 앵커링이 뭔가요?
|
||||
|
||||
협상에는 "처음 제시된 숫자가 기준점이 되어 이후 대화 전체를 끌어당긴다"는 심리 효과가 있습니다. 이를 **앵커링(닻 내리기)** 이라고 부릅니다. 배가 닻을 내린 자리 주변에서 움직이듯, 협상도 첫 제안 근처에서 타결되는 경향이 있죠.
|
||||
|
||||
NegoWiz에서는 목표가에서 일정 비율을 깎은 가격을 **합의 기준선(앵커링가)** 으로 삼습니다. 이때 **몇 % 깎을지**가 바로 **앵커링 값**입니다.
|
||||
|
||||
> **앵커링가(기준가) = 목표가 × (1 − 앵커링 값)**, 소수점은 버리고 1원 단위까지 계산
|
||||
>
|
||||
> 예) 목표가 30,000원, 앵커링 값 3% → 기준가 **29,100원** — 협력사가 29,100원 이하를 써내면 그 가격으로 합의
|
||||
|
||||
앵커링 값은 항상 **1% ~ 20%** 사이에서만 움직입니다. 시작값은 시스템에 내장된 기준표에 정해져 있으며, 현재는 모든 가격구간에서 **1%** 입니다. 이 기준표는 절대 바뀌지 않는 고정값이고, 실제 운영에서 쓰이는 값은 협상 결과에 따라 이 시작점에서부터 움직여 갑니다.
|
||||
|
||||
**적용 대상**: 이 시스템이 값을 산출하고 그 결과를 학습(표본 수집·값 조정)하는 대상은 **재협상(1:1)** 건입니다. 여러 협력사가 동시에 참여하는 재견적(1:N) 등 다른 유형의 결과는 값 조정에 사용하지 않습니다.
|
||||
|
||||
**중요 — 앵커링가는 협력사에게 보여주지 않습니다**: 계산된 앵커링가는 협력사 화면에 표시되지 않고, 협상 챗봇이 **합의 가능 여부를 판단하는 내부 기준선**으로만 동작합니다. 협력사가 스스로 써낸 가격이 이 기준선 이하이면 그 가격으로 합의가 성사됩니다. 기준선을 숨기는 이유: 상대가 우리 한계를 모르는 채 먼저 가격을 부르게 하면 (1) 기준선보다 더 싸게 낼 의향이 있던 협력사의 가격을 그대로 얻고(보여주면 딱 그 값에 맞춰 냅니다), (2) 상대의 가격 정보를 먼저 확보하는 협상 우위를 유지할 수 있기 때문입니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 시스템이 관리하는 단위: "칸"
|
||||
|
||||
"모든 협상에 똑같은 %를 적용"하면 안 되는 이유가 있습니다. 3천 원짜리 물건과 5천만 원짜리 물건은 흥정의 여유가 다르고, 유통사와 제조사는 가격을 받아들이는 태도가 다르며, **회사가 다르면 거래하는 협력사와 협상 환경 자체가 다르기** 때문입니다.
|
||||
|
||||
NegoWiz는 여러 회사가 함께 쓰는 플랫폼이므로, 시스템은 협상을 세 기준으로 분류한 **칸(cell)** 단위로 앵커링 값을 따로 관리합니다.
|
||||
|
||||
| 기준 | 내용 |
|
||||
|---|---|
|
||||
| **회사** | 플랫폼을 쓰는 각 고객사. 회사끼리는 값도 협상 기록도 완전히 분리 |
|
||||
| **가격구간** | 목표가를 자릿수 단위 사다리로 나눈 구간 — 1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대 (0~1억 원, 총 46개). **1억 원을 넘는 목표가는 전부 마지막 구간으로 편입** |
|
||||
| **협력사 유형** | 유통 / 총판 / 제조 |
|
||||
|
||||
즉 "A사의 유통 3만 원대"와 "B사의 유통 3만 원대"는 **서로 다른 칸**이고, 각자 자기만의 앵커링 값과 협상 기록을 가집니다. A사의 협상 결과가 B사의 값에 영향을 주는 일은 없습니다. 새 회사가 플랫폼에 들어오면 모든 칸이 기준표의 시작값(1%)에서 출발합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 작동 원리 — 흥정에 비유하면
|
||||
|
||||
시장에서 단골 도매상과 매일 거래하는 상인을 떠올려 보세요.
|
||||
|
||||
- 처음 거래하는 상대에게는 **조심스럽게 아주 조금만** 깎아 부릅니다. (시작 1%)
|
||||
- 깎아 불렀는데도 **상대가 계속 받아주면**, "조금 더 깎아도 되겠는데?" 하고 다음부터 **조금 더 세게** 부릅니다.
|
||||
- 반대로 **거절이 잦아지면**, "너무 셌구나" 하고 **한발 물러섭니다**.
|
||||
- 받아주는 비율이 **적당한 수준이면 그대로 유지**합니다. 굳이 건드리지 않습니다.
|
||||
|
||||
이 시스템은 정확히 이 상인의 감각을 규칙으로 만든 것입니다. 다만 사람과 달리 회사별 모든 칸(유형×가격대 조합 138개)을 전부 동시에, 감정 없이, 데이터로만 판단합니다.
|
||||
|
||||
중요한 특징 하나: **어디까지 깎을 수 있을지는 시스템이 정하는 게 아니라 시장(협력사들)이 정합니다.** 시스템은 상대가 받아주는 한계선을 더듬어 찾아갈 뿐입니다. 그래서 이 값은 "우리가 정한 목표"가 아니라 "시장이 알려준 답"에 가깝습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 규칙 상세
|
||||
|
||||
### 언제 조정하나요? — "2주마다, 10건이 모였다면"
|
||||
|
||||
값 조정은 **격주 토요일 자정(00시)** 에 정기적으로 이루어집니다. 이때 각 칸을 살펴서:
|
||||
|
||||
- 지난 조정 이후 협상 결과가 **10건 이상** 모였으면 → 평가하고 값을 조정합니다.
|
||||
- **10건 미만**이면 → 이번에는 건너뛰고, 모인 결과를 그대로 들고 다음 주기로 넘어갑니다.
|
||||
|
||||
건너뛴 칸은 다음 조정일에 **4주치**를 보게 되고, 그래도 부족하면 6주, 8주… 로 자연스럽게 기간이 늘어납니다. 거래가 드문 칸도 결국 10건이 차는 시점에 반드시 평가됩니다. "몇 주치인가"는 중요하지 않고 **"10건 이상 모였는가"** 만 봅니다.
|
||||
|
||||
> 참고: "격주"는 시스템 달력(주차의 홀짝) 기준입니다. 달력 특성상 수년에 한 번꼴로 조정 간격이 한 차례 3주가 될 수 있는데, 그 기간의 결과는 사라지지 않고 다음 조정일에 그대로 합산 평가되므로 실질적인 영향은 없습니다.
|
||||
|
||||
### 무엇을 보나요? — "성공률"
|
||||
|
||||
모인 결과 **전체**에서 **성공**이 얼마나 되는지 봅니다. 13건이 모였으면 13건 전체로 성공률을 계산합니다.
|
||||
|
||||
> **성공** = 협상이 정상적으로 끝났고, 협력사가 써낸 가격이 우리 기준가(앵커링가) **이하**인 경우
|
||||
|
||||
기준가보다 싸게(또는 같게) 들어왔다면, 우리가 정한 기준이 시장에 통했다는 뜻이니까요. 한번 평가에 쓰인 결과는 비워지고, 다음 평가는 새로 모인 결과만 봅니다.
|
||||
|
||||
### 어떻게 조정하나요? — "3단계"
|
||||
|
||||
| 성공률 | 판단 | 조치 |
|
||||
|---|---|---|
|
||||
| **60% 이상** | 잘 통하고 있다 | 앵커링 값을 **올린다** (더 세게) |
|
||||
| **30% ~ 60%** | 적당하다 | **유지** |
|
||||
| **30% 미만** | 너무 셌다 | 앵커링 값을 **내린다** (완화) |
|
||||
|
||||
올리고 내리는 **폭은 유형마다 다릅니다**. 유통이 가장 큰 폭으로 움직이고(±2%p), 총판(±1.5%p), 제조(±1%p) 순입니다. 유통 쪽이 가격 협상의 여지가 커서 더 과감하게 탐색한다는 뜻입니다.
|
||||
|
||||
올리는 폭과 내리는 폭은 **같습니다(대칭)**. 그래서 성공률이 절반 근처에서 왔다 갔다 하는 균형점에 도달하면 값이 자연스럽게 멈춥니다.
|
||||
|
||||
한 칸의 값은 조정일 한 번에 **딱 한 계단**만 움직입니다. 아무리 많은 결과가 쌓여 있어도 한 번에 여러 계단을 뛰어오르지 않으므로, 협력사 입장에서 가격 강도가 갑자기 널뛰는 일이 없습니다.
|
||||
|
||||
어떤 경우에도 값은 **1% 아래로 내려가지 않고, 20% 위로 올라가지 않습니다.**
|
||||
|
||||
---
|
||||
|
||||
## 5. 숫자로 따라가 보는 예시 시나리오
|
||||
|
||||
**"A사 × 유통 × 3만 원대" 칸**의 몇 달을 따라가 봅시다. 유통이므로 조정폭은 ±2%p입니다.
|
||||
|
||||
| 조정일 | 모인 결과 | 성공률 | 판단 | 앵커링 값 변화 |
|
||||
|---|---|---|---|---|
|
||||
| 시작 | — | — | — | **1%** (기준표 시작값) |
|
||||
| 1차 (2주 후) | 12건 중 성공 9건 | 75% | 잘 통함 → 올림 | 1% → **3%** |
|
||||
| 2차 (4주 후) | 11건 중 성공 8건 | 73% | 잘 통함 → 올림 | 3% → **5%** |
|
||||
| 3차 (6주 후) | **7건뿐** | — | 10건 미만 → **건너뜀** | **5%** (7건 이월) |
|
||||
| 4차 (8주 후) | 이월 7건 + 새 6건 = 13건 중 성공 8건 | 62% | 잘 통함 → 올림 | 5% → **7%** |
|
||||
| 5차 (10주 후) | 10건 중 성공 2건 | 20% | 너무 셌음 → 내림 | 7% → **5%** |
|
||||
| 6차 (12주 후) | 14건 중 성공 9건 | 64% | 잘 통함 → 올림 | 5% → **7%** |
|
||||
| 7차 (14주 후) | 11건 중 성공 5건 | 45% | 적당함 → 유지 | **7%** |
|
||||
|
||||
3차 조정일을 눈여겨보세요 — 10건이 안 돼서 건너뛰었고, 4차 때 **4주치 13건 전체**로 평가했습니다. 이후로 값은 5~7% 사이에서 잔잔하게 오르내립니다. **이 칸의 시장이 받아주는 한계가 대략 7% 언저리**라는 걸 시스템이 스스로 찾아낸 것입니다. 같은 시기 B사의 유통 3만 원대 칸은 B사 자신의 협상 결과에 따라 전혀 다른 값에 가 있을 수 있습니다.
|
||||
|
||||
합의 기준선이 어떻게 달라지는지 보면:
|
||||
|
||||
| 앵커링 값 | 목표가 30,000원일 때 기준가 |
|
||||
|---|---|
|
||||
| 1% (초기) | 29,700원 |
|
||||
| 7% (수렴 후) | 27,900원 |
|
||||
|
||||
초기에는 사실상 목표가 근처면 합의해 주다가, 학습이 진행되면서 협상 여지를 1,800원 더 확보하게 됩니다.
|
||||
|
||||
**도달 속도는 거래량에 달려 있습니다.** 거래가 활발한 칸은 두 달 안에 균형점 근처에 가고, 한산한 칸은 반년 이상 걸릴 수 있습니다. 하지만 도착하는 **목적지는 같습니다** — 속도만 다를 뿐입니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 협상이 중간에 끝난 경우는요?
|
||||
|
||||
모든 협상이 합의까지 가지는 않습니다. 협력사가 가격을 몇 번 써내다가 떠나기도 하고, 아예 참여하지 않은 채 기한이 만료되기도 하죠. 이런 건을 어떻게 셀지가 중요한 정책 결정이었고, 다음과 같이 확정했습니다.
|
||||
|
||||
> 채점 기준 한 줄 요약: **"가격을 한 번이라도 써낸 협상만 세고 — 기준가 이하로 합의됐으면 성공, 나머지는 전부 실패."**
|
||||
|
||||
| 상황 | 처리 |
|
||||
|---|---|
|
||||
| 기준가 이하로 합의 성사 | **성공** |
|
||||
| 가격을 써냈지만 합의 못 함 — 기준 초과로 마무리, 결렬, **가격을 쓰다가 중간 이탈**(이후 기한만료로 정리된 경우 포함) | **실패**로 카운트 |
|
||||
| 가격을 **한 번도 써내지 않고** 끝남 (미참여·무응답 이탈·취소) | 결과에서 **제외** (카운트 안 함) |
|
||||
|
||||
이렇게 정한 이유: 가격을 써냈다는 건 협상에 실제로 응했다는 뜻이고, 그런데도 우리 기준선 아래로 합의가 안 됐다면 그건 **"기준이 시장보다 세다"는 신호**입니다. 이걸 실패로 세지 않으면, 합의된 건만 남아 성공률이 좋아 보이는 착시가 생기고 시스템이 값을 한계 없이 올리게 됩니다. 반면 가격을 한 번도 써내지 않은 건(담당자 부재, 관심 없음 등)은 — 기준가가 화면에 보이지 않으므로 — 우리 기준의 세기와 무관한 이탈입니다. 판단 재료에서 빼도 왜곡이 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 왜 이렇게 설계했나요?
|
||||
|
||||
설계 과정에서 협상 이론 연구와 시뮬레이션을 검토해 내린 결정들입니다.
|
||||
|
||||
**상한이 40%가 아니라 20%인 이유** — 협상 연구(컬럼비아대 Ames & Mason)에 따르면 첫 제안의 효과적인 할인 범위는 5~20%입니다. 그보다 극단적인 제안은 앵커 효과가 사라지고 상대를 협상장 밖으로 밀어냅니다. 그래서 상한을 20%로 정했습니다.
|
||||
|
||||
**올림과 내림의 폭이 같은 이유** — 올리는 폭을 더 크게 하면 값이 위아래로 크게 출렁이며 협상 전략이 불안정해집니다. 대칭으로 맞추면 균형점에서 얌전히 멈춥니다. 시뮬레이션에서 출렁임이 절반으로 줄었습니다.
|
||||
|
||||
**첫 시작이 1%로 소극적인 이유** — 처음부터 세게 나가서 협력사를 잃는 것보다, 낮게 시작해서 시장이 허용하는 만큼 올라가는 쪽이 안전하기 때문입니다. B2B는 반복 거래라 협력사와의 관계가 자산입니다. 대가는 초기 몇 달간 앵커링 이득을 덜 보는 것인데, 이는 의도된 보수적 선택입니다.
|
||||
|
||||
**격주 정기 조정 + 조정일당 한 계단인 이유** — 건건이 가격 강도가 널뛰면 협력사 입장에서 예측 불가능한 상대가 됩니다. 2주라는 통제된 간격, 그리고 한 번에 한 계단이라는 제한이 신뢰를 지킵니다. 또한 최소 10건을 모아 보므로 한두 건의 우연한 결과에 휘둘리지 않습니다.
|
||||
|
||||
**회사별로 값을 분리한 이유** — 회사마다 거래하는 협력사, 상품, 협상 문화가 다르므로 "시장이 알려주는 답"도 회사마다 다릅니다. 섞어서 배우면 어느 회사에도 맞지 않는 어중간한 값이 됩니다.
|
||||
|
||||
**기준가를 숨기는 이유** — 기준선을 보여주면 협력사는 딱 그 값에 맞춰 내게 되어, 더 싸게 낼 의향이 있던 협력사의 가격을 놓칩니다. 숨기면 상대가 먼저 가격을 부르므로 상대의 정보를 얻는 협상 우위도 유지됩니다.
|
||||
|
||||
전체를 관통하는 철학은 하나입니다: **이 시스템의 목표는 "최대한 깎기"가 아니라 "서로 계속 거래할 수 있는 균형점 찾기"입니다.** 지나친 할인으로 성사된 거래는 장기적으로 이탈로 이어진다는 연구 결과도 이 방향을 뒷받침합니다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 자주 나오는 질문 (FAQ)
|
||||
|
||||
**Q. 사람이 개입해서 값을 바꿀 수 있나요?**
|
||||
현재 정책에서는 없습니다. 값은 오직 협상 결과 데이터로만 움직입니다. 다만 모든 협상 기록과 조정 이력이 보존되므로, "이 칸의 값이 왜 7%가 됐는지"는 이력으로 전부 추적·설명할 수 있습니다.
|
||||
|
||||
**Q. 기준표(시작값 표)와 실제 운영 값은 뭐가 다른가요?**
|
||||
기준표는 "출발선"이고 절대 바뀌지 않습니다. 실제 운영 값은 그 출발선에서 협상 결과에 따라 움직여 온 "현재 위치"입니다. 값이 조정된다는 것은 기준표를 고치는 게 아니라, 조정 이력이 한 줄 더 쌓여 현재 위치가 바뀐다는 뜻입니다.
|
||||
|
||||
**Q. 성공률이 높을수록 좋은 건가요?**
|
||||
아닙니다, 이게 가장 오해하기 쉬운 부분입니다. 성공률은 "성과"가 아니라 **"현재 기준 강도에 대한 시장의 수용도"** 입니다. 앵커링 1%에 성공률 90%보다, 15%에 성공률 50%가 사업적으로 훨씬 좋은 상태입니다. 대시보드를 본다면 성공률 단독이 아니라 앵커링 값과 함께 봐야 합니다. 오히려 성공률이 50% 근처라는 건 **시스템이 균형점을 잘 찾았다**는 신호입니다.
|
||||
|
||||
**Q. 13건이 모였는데 왜 10건만 안 보고 13건을 다 보나요?**
|
||||
표본이 많을수록 성공률 판단이 정확해지기 때문입니다. 그리고 몇 건이 모였든 조정은 한 계단만 이루어지므로, 많이 모였다고 값이 더 크게 움직이지는 않습니다.
|
||||
|
||||
**Q. 거래가 거의 없는 칸은 어떻게 되나요?**
|
||||
10건이 찰 때까지 조정일마다 기간을 늘려가며 기다립니다(2주 → 4주 → 6주…). 극단적으로 거래가 드문 칸은 오래도록 시작값(1%) 근처에 머물 수 있는데, 거래가 없는 칸이니 사업 영향도 작습니다. 인접 가격대의 학습 결과를 빌려오는 보완책이 아이디어로 논의됐지만, **아직 확정하지 않은 미결 과제**입니다. 회사별로 값을 분리하면서 칸당 거래가 더 잘게 나뉘므로, 이 과제는 앞으로 중요해질 수 있습니다.
|
||||
|
||||
**Q. 협력사가 이 시스템의 존재를 알면 역이용하지 않을까요?**
|
||||
일부러 초반에 거절을 반복해 값을 낮추는 시도를 상상할 수 있습니다. 다만 기준가가 화면에 보이지 않고, 값은 조정일에 최대 1~2%p씩만 움직이며 하한이 1%라, 역이용의 이득 대비 거래 포기 비용이 큽니다. 그래도 장기 운영에서 모니터링할 가치는 있는 지점입니다.
|
||||
|
||||
**Q. 목표가 자체를 시스템이 정하는 건가요?**
|
||||
아닙니다. 목표가는 기존 프로세스대로 정해지고, 이 시스템은 그 목표가에서 **합의 기준선을 몇 % 아래에 둘지**만 결정합니다.
|
||||
|
||||
**Q. 파라미터(폭, 경계선, 상한)를 나중에 바꿀 수 있나요?**
|
||||
가능합니다. 모든 협상 기록과 조정 이력이 보존되는 구조라, 규칙을 바꾸면 과거 이력에 새 규칙을 다시 적용해 값을 재계산할 수 있습니다. 다만 파라미터 변경은 정책 재확정 절차를 거쳐야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 용어 정리
|
||||
|
||||
| 용어 | 뜻 |
|
||||
|---|---|
|
||||
| **앵커링 값** | 목표가에서 깎아 기준선을 정하는 비율. 1%~20%, 시작은 기준표 값(현재 1%) |
|
||||
| **앵커링가(기준가)** | 목표가 × (1 − 앵커링 값), 소수점 버림. 협력사에게 표시하지 않는 내부 합의 기준선 |
|
||||
| **기준표** | 가격구간별 시작값이 담긴 불변 표. 서비스에 내장되며 절대 변경되지 않음 |
|
||||
| **칸** | 회사 × 가격구간 × 협력사 유형 조합. 값이 관리되는 최소 단위 |
|
||||
| **가격구간** | 목표가를 자릿수 단위 사다리(1천 원대 … 9천만 원대, 46칸)로 나눈 구간 (0~1억 원). 1억 원 초과는 마지막 구간으로 편입 |
|
||||
| **재협상** | 협력사 1곳과 1:1로 진행하는 협상. 이 시스템의 학습(표본 수집·값 조정) 대상 |
|
||||
| **가격 흔적** | 협력사가 협상에서 가격을 한 번이라도 써낸 기록. 가격 흔적이 있는 협상만 채점 대상 |
|
||||
| **성공** | 정상 종료 협상에서 협력사 투찰가 ≤ 기준가(앵커링가) |
|
||||
| **성공률** | 조정일까지 모인 결과 전체 중 성공 비율 |
|
||||
| **조정일** | 격주 토요일 00시. 10건 이상 모인 칸만 평가·조정 |
|
||||
| **이월** | 10건 미만이라 평가를 건너뛰고 결과를 다음 조정일로 넘기는 것 |
|
||||
| **균형점** | 성공률이 절반 근처를 오가며 값이 안정되는 지점. 시장이 정한다 |
|
||||
|
||||
---
|
||||
|
||||
> 기술 구현 상세(데이터 구조, 계산 절차, 검증 기준)는 **`개발용.md`**, 시간 순서 해설은 **`워크플로우.md`** 를 참조하세요. 세 문서는 같은 정책(v1.2, 2026-07-02 확정)을 눈높이만 달리해 기술한 것입니다.
|
||||
249
schedules/anchoring/docs/운영및유지보수.md
Normal file
249
schedules/anchoring/docs/운영및유지보수.md
Normal file
@ -0,0 +1,249 @@
|
||||
# 앵커링 서비스 — 운영 및 유지보수 가이드
|
||||
|
||||
> **대상 독자**: 이 프로젝트를 처음 보는 운영/개발 담당자. 이 문서 하나로 설치 → 실행 → 로그 확인 → 문제 해결까지 따라할 수 있게 쓰였습니다.
|
||||
> **함께 볼 문서**: 무엇을 하는 시스템인지 → `기획용.md` / 흐름 그림 → `워크플로우.md` / 구현 규범 → `개발용.md` / 타 팀 적용 → `인수인계.md`
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [이 서비스는 무엇인가](#1-이-서비스는-무엇인가)
|
||||
2. [구성 요소 한눈에](#2-구성-요소-한눈에)
|
||||
3. [처음 설치하고 실행하기](#3-처음-설치하고-실행하기)
|
||||
4. [정상 동작 확인 체크리스트](#4-정상-동작-확인-체크리스트)
|
||||
5. [로그 읽는 법](#5-로그-읽는-법)
|
||||
6. [자주 하는 운영 작업](#6-자주-하는-운영-작업)
|
||||
7. [문제 해결 (트러블슈팅)](#7-문제-해결-트러블슈팅)
|
||||
8. [DB로 이력 추적하기](#8-db로-이력-추적하기)
|
||||
9. [절대 하면 안 되는 것](#9-절대-하면-안-되는-것)
|
||||
10. [정기 점검 체크리스트](#10-정기-점검-체크리스트)
|
||||
|
||||
---
|
||||
|
||||
## 1. 이 서비스는 무엇인가
|
||||
|
||||
협상 시스템의 **앵커링 값**(협상 합의 기준선을 목표가에서 몇 % 아래에 둘지)을 **격주 토요일 00:00(KST)** 에 협상 성공률을 보고 자동 조정하는 배치 서비스입니다.
|
||||
|
||||
- backend/negodata/agent 와 **완전히 독립**된 컨테이너로 돕니다. 이 서비스가 꺼져 있어도 협상·견적은 정상 동작합니다(값 조정만 멈춤).
|
||||
- 켜두기만 하면 스케줄이 자동으로 돕니다. 사람이 정기적으로 할 일은 없고, 격주 배치 다음 날 로그 한 번 확인이 전부입니다(§10).
|
||||
|
||||
## 2. 구성 요소 한눈에
|
||||
|
||||
```
|
||||
[anchoring 컨테이너] ──── 격주 배치 실행 (APScheduler 내장)
|
||||
│ 읽기: negotiation.sessions / quotation.quotations / partner.items
|
||||
│ 쓰기: anchoring.rate_adjustments (조정 이력) + sessions.anchoring_adjustment_id (채점 마킹)
|
||||
▼
|
||||
[PostgreSQL (외부, negosium_db)] [anchoring-redis 컨테이너]
|
||||
진실 원천 — 영구 이력 조회 캐시(사본) — 없어져도 복구됨
|
||||
```
|
||||
|
||||
| 구성 요소 | 역할 | 죽으면? |
|
||||
|---|---|---|
|
||||
| anchoring 컨테이너 | 격주 조정 배치 + 캐시 갱신 | 조정만 멈춤. 재기동 후 `--once`로 캐치업 |
|
||||
| anchoring-redis | rate 조회 캐시 (negodata가 참조) | **무해** — 자동으로 DB 폴백, 복구 시 자가 회복 |
|
||||
| PostgreSQL | 모든 데이터의 원본 | 서비스 전체 의존 (기존 DB 운영 정책에 따름) |
|
||||
|
||||
## 3. 처음 설치하고 실행하기
|
||||
|
||||
### 사전 준비
|
||||
|
||||
- PostgreSQL(negosium_db) 접속 정보 (기존 `postgres-init/01~04` 스키마가 적용된 DB)
|
||||
- Docker (운영) 또는 Python 3.12+ (로컬 개발)
|
||||
|
||||
### STEP 1 — DB 스키마 적용 (최초 1회)
|
||||
|
||||
```bash
|
||||
cd schedules/anchoring
|
||||
psql -h <DB호스트> -U <계정> -d negosium_db -f schema.sql
|
||||
```
|
||||
|
||||
- 테이블 1개(`anchoring.rate_adjustments`)·조회용 뷰 2개(`rate_history`, `current_rates`)와 `negotiation.sessions` 컬럼 3개를 추가합니다.
|
||||
- `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다.
|
||||
|
||||
### STEP 2 — 설정 채우기
|
||||
|
||||
```bash
|
||||
cp config.toml.example config.toml
|
||||
# config.toml 열어서 [db] 호스트/계정/비밀번호 채우기
|
||||
```
|
||||
|
||||
환경변수로 덮어쓸 수도 있습니다(우선순위: env > config.toml > 기본값):
|
||||
`DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` / `REDIS_HOST` `REDIS_PORT` `REDIS_DB` `REDIS_PASSWORD` / `LOG_LEVEL`
|
||||
|
||||
### STEP 3-A — 도커로 실행 (운영 권장)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker logs -f anchoring # 기동 로그 확인 (아래 §4)
|
||||
```
|
||||
|
||||
redis 가 함께 뜨고, 로그 로테이션(10MB×5)·재시작 정책까지 자동 설정됩니다.
|
||||
|
||||
### STEP 3-B — 로컬 파이썬으로 실행 (개발용)
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
|
||||
# 또는
|
||||
PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 배치 즉시 1회 실행 후 종료
|
||||
```
|
||||
|
||||
## 4. 정상 동작 확인 체크리스트
|
||||
|
||||
기동 직후 로그에 아래 3줄이 순서대로 보이면 정상입니다:
|
||||
|
||||
```
|
||||
[main] 정적 기본 테이블 로드·검증 완료 (46칸 사다리)
|
||||
[scheduler] 등록 — 매주 토 00:00 Asia/Seoul (격주 게이트는 잡 내부)
|
||||
[main] 스케줄러 상주 시작 — 다음 실행 예정: 2026-07-04 00:00:00+09:00
|
||||
```
|
||||
|
||||
배치가 실제로 도는지 즉시 확인하고 싶으면:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src .venv/bin/python -m anchoring.main --once
|
||||
# 도커: docker exec anchoring python -m anchoring.main --once
|
||||
```
|
||||
|
||||
끝부분에 `종료 {'run_id': ..., 'status': 'done', ...}` 와 `[main] 결과: {...}` 가 나오면 성공입니다(부분 실패면 종료코드 1).
|
||||
(협상 데이터가 없으면 `scanned: 0` — 이것도 정상)
|
||||
|
||||
**첫 운영 실행 전에는 예행 연습을 먼저** 하세요 — 쌓여 있는 협상 전량이 첫 실행에서 한 번에 채점되므로, 무엇이 얼마나 바뀔지 미리 보는 게 안전합니다:
|
||||
|
||||
```bash
|
||||
docker exec anchoring python -m anchoring.main --once --dry-run
|
||||
# DB/Redis 를 전혀 바꾸지 않고 "조정예정 …" 라인과 제외 예정 건수만 로그로 보여줍니다 (status: dry_run)
|
||||
```
|
||||
|
||||
테스트 스위트로 확인하려면:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q # 전부 passed 기대(현재 20개)
|
||||
```
|
||||
|
||||
## 5. 로그 읽는 법
|
||||
|
||||
### 로그 한 줄의 구조
|
||||
|
||||
```
|
||||
2026-07-02 16:45:12+0900 INFO anchoring [batch 20260702-164512] 조정 company=f23c… type=1 bracket=10 n=13 성공=8 10‰→30‰ adj_id=32
|
||||
└──── 시각(항상 KST) ──┘ └레벨┘ └── 회차 태그 ──────┘ └──────────────── 내용 (key=value 형식) ────────────────┘
|
||||
```
|
||||
|
||||
- **시각은 항상 한국시간(+0900)** — 서버 시간대와 무관하게 고정돼 있습니다.
|
||||
- `[batch 20260702-164512]` = **회차 태그**(run_id, 배치 시작 시각). 한 회차의 모든 로그가 같은 태그를 답니다.
|
||||
- `‰`(천분율) 표기: `10‰ = 1%`. `10‰→30‰` 는 "1%에서 3%로 올렸다"는 뜻.
|
||||
|
||||
### 회차 하나의 로그 흐름 (위에서 아래로)
|
||||
|
||||
| 라인 | 의미 |
|
||||
|---|---|
|
||||
| `시작 — ISO 주차 27, force=False` | 배치 깨어남. force=True 는 수동 실행(`--once`) |
|
||||
| `캐시 re-SET n칸` | 조정 이력 있는 칸 전체를 Redis 에 다시 적재(매주, 캐시 자가 회복) |
|
||||
| `격주 게이트 미충족 — 평가 스킵` | 이번 주는 쉬는 주(격주). **정상 동작** |
|
||||
| `제외 확정 마킹 n건` | 가격을 안 써낸 협상들을 채점 대상에서 영구 제외 처리 |
|
||||
| `조정 company=… n=13 성공=8 10‰→30‰ adj_id=32` | **칸 하나의 값이 조정됨** — adj_id 로 DB 행과 대조 가능 |
|
||||
| `회사요약 company=… 평가=1 상승=1 …` | 회사(테넌트)별 이번 회차 집계 |
|
||||
| `종료 {…}` | 회차 전체 요약(스캔 건수, 평가 칸 수, 이월 등) |
|
||||
|
||||
### 자주 쓰는 검색 명령
|
||||
|
||||
```bash
|
||||
docker logs anchoring | grep "batch 20260705" # 특정 회차 전체 보기
|
||||
docker logs anchoring | grep "company=<uuid>" # 특정 회사만 (조정 + 회사요약)
|
||||
docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
|
||||
docker logs anchoring | grep "조정 " # 값이 바뀐 칸만
|
||||
docker logs anchoring | tail -20 # 최근 상태
|
||||
```
|
||||
|
||||
### 레벨별 대응 기준
|
||||
|
||||
| 레벨 | 의미 | 대응 |
|
||||
|---|---|---|
|
||||
| INFO | 정상 동작 기록 | 조치 불필요 |
|
||||
| WARNING | 동작은 하지만 점검 필요 | §7 트러블슈팅에서 해당 메시지 찾기 |
|
||||
| ERROR | 칸 단위 실패(다른 칸엔 영향 없음) | 스택 확인. 실패 칸은 다음 회차 자동 재시도 |
|
||||
|
||||
**핵심 규칙: WARNING 이상이 하나라도 있으면 들여다본다. INFO 뿐이면 건강하다.**
|
||||
|
||||
## 6. 자주 하는 운영 작업
|
||||
|
||||
| 작업 | 명령 |
|
||||
|---|---|
|
||||
| 수동 배치 1회 (격주 게이트 무시) | `docker exec anchoring python -m anchoring.main --once` |
|
||||
| **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 |
|
||||
| 재기동 | `docker compose restart anchoring` |
|
||||
| 서비스 중지/시작 | `docker compose stop` / `docker compose up -d` |
|
||||
| 설정 변경 반영 | config.toml 수정 → `docker compose up -d --build` |
|
||||
| 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` |
|
||||
| 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 |
|
||||
|
||||
## 7. 문제 해결 (트러블슈팅)
|
||||
|
||||
| 증상 (로그 메시지) | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| 기동 실패 + `BaseTableError: 정적 테이블 …` | `resources/anchoring_base.json` 손상/수정됨 | **의도된 안전장치** — git 으로 파일 원복 후 재기동. 이 파일은 절대 수정 금지 |
|
||||
| 기동 실패 + DB 연결 예외 | config.toml/env 의 DB 접속 정보 오류 | 접속 정보 확인, `psql` 로 직접 접속 테스트 |
|
||||
| `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 |
|
||||
| `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 |
|
||||
| `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 |
|
||||
| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service` 의 `last_offered_price` 갱신 경로 점검 요청 |
|
||||
| `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 |
|
||||
| `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 |
|
||||
| 종료 요약이 WARNING (`failed_cells > 0`) | 일부 칸 실패 | 바로 위 ERROR 라인들 확인 |
|
||||
| 토요일 00:00 에 서비스가 꺼져 있었음 | 배치 회차 누락 | 데이터 유실 없음(자동 이월). 재기동 후 `--once` 로 즉시 캐치업 |
|
||||
| 로그가 아무것도 안 나옴 | 컨테이너 죽음 | `docker ps -a` 로 상태 확인 → `docker logs anchoring` 마지막 로그 → 재기동 |
|
||||
|
||||
## 8. DB로 이력 추적하기
|
||||
|
||||
로그는 로테이션되지만 **DB 이력은 영구**입니다. "왜 이 값이 됐는가"는 항상 DB로 답할 수 있습니다.
|
||||
|
||||
```sql
|
||||
-- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에
|
||||
SELECT * FROM anchoring.rate_history
|
||||
WHERE company_id = '<uuid>'
|
||||
ORDER BY adjustment_id;
|
||||
|
||||
-- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%)
|
||||
SELECT * FROM anchoring.current_rates
|
||||
WHERE company_id = '<uuid>';
|
||||
|
||||
-- ② 특정 조정(adj_id)의 근거가 된 협상들
|
||||
SELECT s.session_id, s.status, s.target_anchoring_price, s.last_offered_price, s.bid_price
|
||||
FROM negotiation.sessions s
|
||||
WHERE s.session_id IN (
|
||||
SELECT jsonb_array_elements_text(consumed_session_ids)::uuid
|
||||
FROM anchoring.rate_adjustments WHERE id = <adj_id>
|
||||
);
|
||||
|
||||
-- ③ 특정 협상이 어느 조정에 채점됐나
|
||||
SELECT anchoring_adjustment_id FROM negotiation.sessions WHERE session_id = '<uuid>';
|
||||
-- NULL = 아직 채점 전(다음 회차로 이월) / 0 = 채점 제외 확정 / 숫자 = 해당 조정 id → ② 로
|
||||
```
|
||||
|
||||
로그의 `adj_id=32` ↔ DB 의 `rate_adjustments.id=32` 가 같은 것을 가리킵니다.
|
||||
|
||||
## 9. 절대 하면 안 되는 것
|
||||
|
||||
이 시스템의 신뢰성은 "기록이 불변"이라는 전제 위에 서 있습니다 (상세 근거: `개발용.md` §12).
|
||||
|
||||
- ❌ `anchoring.rate_adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천
|
||||
- ❌ `sessions` 의 `target_anchoring_price` / `anchor_rate_permille` 수동 수정 — 채점 근거가 오염됨
|
||||
- ❌ `resources/anchoring_base.json`(기준표) 수정 — 검증 실패로 기동이 막히며, 값 변경은 정책 재확정 사안
|
||||
- ❌ 상수(조정폭 δ, 경계 60/30, 상·하한, 10건 임계, 배치 주기) 임의 변경 — 전부 정책 고정값
|
||||
- ❌ anchoring 컨테이너를 **2개 이상 동시 실행** — 중복 조정 방지 장치(롤백)가 막아주긴 하지만 설계상 단일 인스턴스가 원칙
|
||||
|
||||
## 10. 정기 점검 체크리스트
|
||||
|
||||
**격주 배치 다음 날(일요일) 5분 점검:**
|
||||
|
||||
```bash
|
||||
docker logs anchoring | grep -E "WARNING|ERROR" | tail # ① 이상 신호 없나
|
||||
docker logs anchoring | grep "종료" | tail -1 # ② status: done 인가
|
||||
docker logs anchoring | grep "다음 실행 예정" # ③ (재기동했다면) 다음 스케줄 정상인가
|
||||
```
|
||||
|
||||
- ① 이 비어 있고 ② 가 `'status': 'done'` 이면 끝.
|
||||
- `carryover_cells`(이월)가 계속 크기만 하고 `evaluated_cells` 가 0인 상태가 몇 달 지속되면 거래량 자체가 적은 것 — 장애가 아니라 정책 검토(희소 칸 과제, `기획용.md` FAQ) 대상입니다.
|
||||
- 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`rate_adjustments` 는 영구 보존 대상).
|
||||
120
schedules/anchoring/docs/워크플로우.md
Normal file
120
schedules/anchoring/docs/워크플로우.md
Normal file
@ -0,0 +1,120 @@
|
||||
# 앵커링 시스템 워크플로우 설명 (비개발자용)
|
||||
|
||||
> **문서 성격**: `개발용.md`의 워크플로우를 비개발자도 이해할 수 있게 풀어 쓴 안내서.
|
||||
> **버전**: v1.2 기준 (2026-07-02) — 정책 배경은 `기획용.md`, 기술 상세는 `개발용.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
**협상마다 "이 가격 이하면 합의한다"는 기준선을 장부에서 찾아 정하고, 협상이 끝날 때마다 결과가 쌓이고, 2주에 한 번 시스템이 그 결과를 채점해서 장부의 숫자를 한 칸씩 조정한다** — 이 순환 구조입니다.
|
||||
|
||||
---
|
||||
|
||||
## 등장하는 것 4가지
|
||||
|
||||
| 이름 | 비유 | 역할 |
|
||||
|---|---|---|
|
||||
| **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 |
|
||||
| **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 |
|
||||
| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
|
||||
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
|
||||
|
||||
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
|
||||
|
||||
---
|
||||
|
||||
## 워크플로우 — 시간 순서대로
|
||||
|
||||
### ① 서버가 켜질 때
|
||||
|
||||
기준표(46개 가격구간 × 시작값 1%)를 메모리에 올리고, 표가 손상됐으면 아예 서버를 켜지 않습니다. 잘못된 가격으로 협상하는 것보다 안 켜지는 게 낫다는 안전장치입니다.
|
||||
|
||||
### ② 견적(협상 건)이 만들어질 때 — "기준선을 정한다"
|
||||
|
||||
재협상 견적이 생성되는 시점에 견적 시스템(negodata)이 이 협상의 칸을 찾습니다. 그 칸의 현재 앵커링 값(깎는 비율)을 빠른 조회판에서 읽고 — 없으면 조정 장부, 그것도 없으면 기준표 순서로 —
|
||||
|
||||
> **기준가(앵커링가) = 목표가 × (1 − 앵커링 값)**, 1원 단위 내림
|
||||
>
|
||||
> 예) 목표가 30,000원, 앵커링 값 3% → 기준가 29,100원 — 협력사가 이 이하를 써내면 그 가격으로 합의
|
||||
>
|
||||
> 이 기준가는 협력사 화면에 **표시되지 않고**, 챗봇이 합의 가능 여부를 판단하는 내부 기준선으로만 동작합니다(정보 비대칭 유지 전략).
|
||||
|
||||
을 계산합니다. 그리고 **이 협상에 쓴 비율과 기준가를 협상 기록에 도장 찍듯 고정(박제)** 합니다. 이후 2주 정산이 지나가서 칸의 값이 바뀌어도, 이미 만들어진 협상의 기준가는 절대 흔들리지 않습니다. 협상이 다음 라운드로 재생성될 때도 옛 값을 물려받지 않고 그 시점의 칸 값으로 새로 계산합니다.
|
||||
|
||||
### ③ 협력사가 가격을 써낼 때 — "가격 흔적"
|
||||
|
||||
협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offered_price`).
|
||||
|
||||
이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다.
|
||||
|
||||
- 가격을 써냈는데 기준가 아래로 합의가 안 됨(결렬·중간 이탈 포함) = **"기준이 시장보다 세다"는 신호** = 실패로 카운트
|
||||
- 가격을 한 번도 안 써내고 끝남(미참여·무응답, 담당자 부재 등) = 기준가는 화면에 안 보이므로 우리 기준과 무관 = 판단 재료에서 제외
|
||||
|
||||
### ④ 협상이 끝나면
|
||||
|
||||
따로 하는 일이 없습니다. 계약서 철(협상 기록)에 결과가 이미 다 남아 있으니까요.
|
||||
|
||||
이게 이번 설계(v1.2)의 특징입니다 — 별도 표본 장부를 만들지 않고, **협상 기록 자체를 나중에 채점 근거로** 씁니다. 기준가·마지막 제시가·투찰가·종료 상태가 전부 확정된 값이라, 언제 채점해도 같은 결과가 나옵니다.
|
||||
|
||||
### ⑤ 격주 토요일 자정 — "정산"
|
||||
|
||||
2주에 한 번 시스템이 깨어나 **아직 채점 안 된 종료 협상들을 전부** 꺼내 채점합니다:
|
||||
|
||||
| 상황 | 채점 |
|
||||
|---|---|
|
||||
| 기준가 이하로 합의 성사 | **성공** |
|
||||
| 기준가보다 높게 합의(예외적) | **실패** |
|
||||
| 가격을 써냈지만 합의 못 함 — 결렬·중간 이탈 포함 | **실패** |
|
||||
| 가격을 한 번도 안 써내고 끝남 | **제외** (셈에서 뺌) |
|
||||
|
||||
칸별로 모아서 **유효 결과가 10건 이상인 칸만** 성공률을 내고, 3단계 규칙으로 **딱 한 계단**만 조정합니다:
|
||||
|
||||
| 성공률 | 판단 | 조치 |
|
||||
|---|---|---|
|
||||
| 60% 이상 | 잘 통하고 있다 | 올림 (유통 ±2%p / 총판 ±1.5%p / 제조 ±1%p) |
|
||||
| 30% ~ 60% | 적당하다 | 유지 |
|
||||
| 30% 미만 | 너무 셌다 | 내림 (같은 폭) |
|
||||
|
||||
값은 어떤 경우에도 1% 아래로 내려가지 않고 20% 위로 올라가지 않습니다.
|
||||
|
||||
정산이 끝나면:
|
||||
|
||||
1. 조정 장부에 한 줄 추가 — "몇 건 중 몇 건 성공, 1% → 3%, 어떤 협상들을 근거로"
|
||||
2. 채점에 쓴 협상들에 **"이 조정에 사용됨" 스탬프**를 찍음 (같은 협상이 두 번 채점되는 일 방지)
|
||||
3. 벽의 가격표(빠른 조회판)를 새 값으로 갱신
|
||||
|
||||
**10건이 안 되는 칸은 스탬프를 안 찍고 그대로 둡니다** — 그게 이월입니다. 다음 정산 때 4주치가 함께 채점되고, 그래도 부족하면 6주, 8주… 로 자연스럽게 기간이 늘어납니다.
|
||||
|
||||
### ⑥ 그리고 다시 ②로
|
||||
|
||||
다음 재협상은 조정된 값으로 시작합니다. 이 순환이 반복되면서 각 칸의 값은 "시장이 받아주는 한계선" 근처에서 자연스럽게 안정됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 이 구조가 주는 안전장치
|
||||
|
||||
- **같은 협상이 두 번 채점될 수 없음** — 스탬프 찍힌 협상은 다음 정산에서 자동으로 빠집니다. 정산이 실수로 두 번 돌아도 결과가 같습니다.
|
||||
- **정산이 한 번 빠져도 문제 없음** — 다음 정산이 4주치를 한 번에 채점하고, 그래도 조정은 한 계단만 하므로 값이 튀지 않습니다.
|
||||
- **진행 중 협상은 절대 안 흔들림** — 기준가는 협상 생성 시점에 고정되므로, 정산이 값을 바꿔도 이미 시작한 협상에는 영향이 없습니다.
|
||||
- **"왜 이 칸이 7%야?"에 항상 답할 수 있음** — 조정 장부에 모든 변경이 근거(어떤 협상들, 성공률)와 함께 영구 보존됩니다. 장부는 수정·삭제가 금지돼 있습니다.
|
||||
- **빠른 조회판이 날아가도 무사** — 어차피 사본이라 조정 장부에서 언제든 다시 만들 수 있습니다. 조회판(Redis)이 아예 꺼져 있어도 협상은 원본 장부를 직접 읽어 계속 동작하고, 조회판에 옛 값이 남아 있더라도 매주 정산 시각에 최신 값으로 전부 다시 붙입니다.
|
||||
- **회사 간 칸막이** — A사의 협상 결과는 A사의 칸에만 반영됩니다. 다른 회사의 값과 기록은 완전히 분리됩니다.
|
||||
- **예행 연습이 가능** — 실제로 아무것도 바꾸지 않고 "이번 정산에서 무엇이 어떻게 바뀔지"만 미리 보는 모드(dry-run)가 있어, 첫 가동처럼 조심스러운 순간에 결과를 눈으로 확인한 뒤 진행할 수 있습니다.
|
||||
- **잘못 찍힌 기준가를 자동 감지** — 정산 때마다 각 협상에 도장 찍힌 기준가가 규칙대로 계산된 값인지 대조해서, 견적 시스템 쪽 계산 실수를 경고로 잡아냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 자주 나올 질문
|
||||
|
||||
**Q. 값이 조정되면 기준표가 바뀌는 건가요?**
|
||||
아닙니다. 기준표는 출발선이고 절대 바뀌지 않습니다. 조정 장부에 이력이 한 줄 쌓여서 "현재 위치"가 바뀌는 것입니다.
|
||||
|
||||
**Q. 결과가 많이 쌓이면 값이 크게 움직이나요?**
|
||||
아닙니다. 13건이 쌓였든 30건이 쌓였든 성공률만 계산하고, 조정은 정산일당 딱 한 계단입니다.
|
||||
|
||||
**Q. 거래가 거의 없는 칸은요?**
|
||||
10건이 찰 때까지 정산일마다 기다립니다(2주 → 4주 → 6주…). 그동안은 시작값(1%) 근처에 머무는데, 거래가 없는 칸이니 사업 영향도 작습니다.
|
||||
|
||||
**Q. 사람이 수동으로 값을 바꿀 수 있나요?**
|
||||
현재 정책에서는 없습니다. 값은 오직 협상 결과 데이터로만 움직이고, 모든 변경은 이력으로 추적 가능합니다.
|
||||
97
schedules/anchoring/docs/인수인계.md
Normal file
97
schedules/anchoring/docs/인수인계.md
Normal file
@ -0,0 +1,97 @@
|
||||
# 앵커링 v1.2 인수인계 명세 (negodata · agent 담당자용)
|
||||
|
||||
> **문서 성격**: 앵커링 시스템 v1.2 도입에 따라 `negodata`·`agent` 폴더에서 적용해야 할 변경 명세.
|
||||
> 앵커링 모듈은 **`schedules/anchoring` 자립 서비스**(독립 컨테이너, 자체 스케줄러)로 개발 완료 후 전달되며, 이 문서는 그 모듈을 각 폴더에 적용하는 방법을 기술한다.
|
||||
> 정책 배경: `기획용.md` / 기술 규범: `개발용.md` (§9.1, §13 참조).
|
||||
|
||||
## 배경 한 줄
|
||||
|
||||
앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.rate_adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다.
|
||||
|
||||
## 전달물 (→ 각 담당자)
|
||||
|
||||
| 전달물 | 내용 |
|
||||
|---|---|
|
||||
| `schedules/anchoring/src/anchoring/` 모듈 | `constants.py`(상수·enum) · `base_table.py`(정적 테이블 로더) · `service.py`(순수 계산 함수) · `redis_client.py` · `reader.py`(rate 조회) — **전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능** |
|
||||
| `schedules/anchoring/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) |
|
||||
| `schedules/anchoring/schema.sql` | `anchoring.rate_adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) |
|
||||
| `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉 — **negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) |
|
||||
| 이 문서 | 적용 위치·변경 전후 명세 |
|
||||
|
||||
---
|
||||
|
||||
## 1. negodata 변경 (견적 생성 측)
|
||||
|
||||
### 1.1 변경 대상
|
||||
|
||||
`negodata/backend/services/quotation_service.py` — `_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
|
||||
|
||||
### 1.2 현재 동작 (변경 전)
|
||||
|
||||
```python
|
||||
# 재생성: 직전 라운드 값 그대로 상속 (재계산 안 함, KTC 방식)
|
||||
if inherited and iid in inherited:
|
||||
tp, ap = inherited[iid]
|
||||
else:
|
||||
tp = self._calc_target_price(...)
|
||||
# 구 방식: 견적설정 고정 비율 + float 연산
|
||||
ap = int(tp * (1 - anchoring)) # anchoring = quotation_settings.anchoring_value
|
||||
```
|
||||
|
||||
### 1.3 변경 후 동작 (MUST)
|
||||
|
||||
**target_price 산정은 그대로 두고, 앵커링가 계산만 교체한다.**
|
||||
|
||||
```python
|
||||
# 세션(상품 × 공급사)마다:
|
||||
# ① 칸 해석
|
||||
# company_id = items.company_id (해당 상품의 소유 회사)
|
||||
# supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
|
||||
# bracket = calc_bracket_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
|
||||
# ② rate 조회 — 전달받은 reader 모듈 사용
|
||||
rate = await get_anchor_rate(db, company_id, supplier_type, bracket) # db = AsyncSession
|
||||
# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰)
|
||||
# supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값)
|
||||
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
|
||||
ap = tp * (1000 - rate) // 1000
|
||||
# ④ 세션 INSERT 에 두 컬럼 모두 박제
|
||||
sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
|
||||
```
|
||||
|
||||
### 1.4 필수 규칙
|
||||
|
||||
1. **재생성(다음 라운드) 상속 폐지**: `inherited` 로 앵커링가를 물려주지 않는다. 다음 라운드 세션도 **생성 시점의 칸 rate 로 재계산**한다. (target_price 상속은 기존 정책대로 유지해도 무방 — 앵커만 재계산)
|
||||
2. **정수 연산 MUST**: `tp * (1000 - rate) // 1000`. 부동소수점 곱셈(`int(tp * (1 - x))`, `round(...)`) 금지 — 1원 단위 내림의 정확성 보장.
|
||||
3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택).
|
||||
4. **`quotations.supplier_type` 기록 유지**: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외).
|
||||
5. **박제 후 수정 금지**: `sessions.target_anchoring_price` / `anchor_rate_permille` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
|
||||
6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.
|
||||
|
||||
### 1.5 적용 전(전환기) 동작
|
||||
|
||||
이 변경이 적용되기 전까지는 지금처럼 구 방식 값이 박제되어도 시스템은 안전하게 동작한다 — 협상 결과 판정은 "박제된 앵커가" 기준이므로 학습 데이터는 유효하게 쌓이고, 이 변경이 적용되는 시점부터 조정된 rate 가 실제 제안가에 반영되기 시작한다. 별도 데이터 마이그레이션은 필요 없다.
|
||||
|
||||
---
|
||||
|
||||
## 2. agent — **변경 없음**
|
||||
|
||||
앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offered_price`).
|
||||
|
||||
---
|
||||
|
||||
## 3. 적용 순서 (권장)
|
||||
|
||||
```
|
||||
① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개)
|
||||
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
|
||||
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
|
||||
③ 전환기 점프 확인 (negodata 적용 직전):
|
||||
SELECT max(anchor_rate_after) FROM anchoring.current_rates;
|
||||
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다
|
||||
("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부
|
||||
또는 이력 리셋을 정책 결정 후 진행.
|
||||
④ negodata 적용 (앵커 산출 교체 — 이 시점부터 조정된 rate 가 실제 기준가에 반영)
|
||||
적용 후 첫 배치 로그에서 "박제 정합 불일치" WARN 이 없는지 확인 — 이식 오류 자동 감지.
|
||||
```
|
||||
|
||||
각 단계는 독립적으로 안전하다(어느 단계까지만 적용돼도 기존 동작이 깨지지 않음). agent 는 변경 대상이 아니다. 문의는 backend 담당(민헌)에게.
|
||||
3
schedules/anchoring/pytest.ini
Normal file
3
schedules/anchoring/pytest.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
10
schedules/anchoring/requirements.txt
Normal file
10
schedules/anchoring/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
# anchoring 자립 모듈 (async — negodata 이식 호환)
|
||||
tzdata>=2024.1 # slim 컨테이너에 IANA 시간대 데이터 보장(zoneinfo Asia/Seoul)
|
||||
SQLAlchemy>=2.0
|
||||
greenlet>=3.0
|
||||
asyncpg>=0.29
|
||||
redis>=5.0
|
||||
APScheduler>=3.10
|
||||
# 테스트
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
72
schedules/anchoring/schema.sql
Normal file
72
schedules/anchoring/schema.sql
Normal file
@ -0,0 +1,72 @@
|
||||
-- ============================================================
|
||||
-- anchoring 모듈 DDL (모듈 소유 — postgres-init 에 두지 않는다)
|
||||
-- 적용: psql -h <host> -U <user> -d negosium_db -f schema.sql
|
||||
-- 신규 DB 구축 순서: postgres-init/01~04 → 이 파일
|
||||
-- 규범: docs/개발용.md §6. IF NOT EXISTS 라 재적용 안전.
|
||||
-- 컨벤션: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC).
|
||||
-- ============================================================
|
||||
\connect negosium_db
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS anchoring;
|
||||
|
||||
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
|
||||
CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
|
||||
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
|
||||
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
|
||||
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
|
||||
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
|
||||
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
|
||||
anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
|
||||
consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 현재 값 조회 최적화: 칸별 최신 조정
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell
|
||||
ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC);
|
||||
|
||||
-- 선행요건 §13 + 소비 마킹. target_anchoring_price 는 기존 컬럼(negodata 가 생성 시 박제).
|
||||
ALTER TABLE negotiation.sessions
|
||||
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제
|
||||
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 가격 입력마다 backend 가 갱신, 종료 후 불변. NULL=가격 흔적 없음(표본 제외)
|
||||
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
|
||||
|
||||
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (부분 인덱스).
|
||||
-- qt_type=1 을 술어에 포함해야 함 — 빼면 배치가 마킹하지 않는 비재협상 세션이
|
||||
-- 영구 잔류해 인덱스가 전체 세션 수에 비례해 성장한다(의도는 이월 풀만 담는 소형 인덱스).
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
|
||||
ON negotiation.sessions (status)
|
||||
WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 조회용 뷰 (파생 — 상태 없음, 진실 원천은 rate_adjustments)
|
||||
-- ============================================================
|
||||
|
||||
-- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로"
|
||||
CREATE OR REPLACE VIEW anchoring.rate_history AS
|
||||
SELECT id AS adjustment_id,
|
||||
company_id,
|
||||
supplier_type, -- 1유통/2제조/3총판
|
||||
price_bracket_index, -- 0..45 자릿수 사다리
|
||||
anchor_rate_before, -- 이전 값(‰)
|
||||
anchor_rate_after, -- 새 값(‰)
|
||||
anchor_rate_after - anchor_rate_before AS delta_permille,
|
||||
nego_count,
|
||||
success_count,
|
||||
round(success_count::numeric / nego_count, 3) AS success_rate,
|
||||
created_at
|
||||
FROM anchoring.rate_adjustments;
|
||||
|
||||
-- 칸별 현재값: 칸의 최신 조정 행. 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
|
||||
CREATE OR REPLACE VIEW anchoring.current_rates AS
|
||||
SELECT DISTINCT ON (company_id, supplier_type, price_bracket_index)
|
||||
company_id,
|
||||
supplier_type,
|
||||
price_bracket_index,
|
||||
anchor_rate_after AS anchor_rate_permille,
|
||||
id AS last_adjustment_id,
|
||||
created_at AS last_adjusted_at
|
||||
FROM anchoring.rate_adjustments
|
||||
ORDER BY company_id, supplier_type, price_bracket_index, id DESC;
|
||||
0
schedules/anchoring/src/anchoring/__init__.py
Normal file
0
schedules/anchoring/src/anchoring/__init__.py
Normal file
58
schedules/anchoring/src/anchoring/base_table.py
Normal file
58
schedules/anchoring/src/anchoring/base_table.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""정적 기본 테이블 — 칸 시작값의 유일한 소스. 규범: §2.
|
||||
|
||||
resources/anchoring_base.json(46행 사다리, 불변)을 서비스 기동 시 메모리에 로드한다.
|
||||
DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증 실패 시 기동 중단(§13-7).
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS
|
||||
|
||||
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
|
||||
|
||||
_rates: list[int] | None = None # bracket_index → 시작값(‰)
|
||||
|
||||
|
||||
class BaseTableError(RuntimeError):
|
||||
"""정적 테이블 로드/검증 실패 — 기동 중단용."""
|
||||
|
||||
|
||||
def _validate(rows: list) -> list[int]:
|
||||
"""행 검증 후 천분율 정수 리스트로 변환. 실패 시 BaseTableError.
|
||||
|
||||
규약(§2.1): 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
|
||||
"""
|
||||
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
|
||||
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}")
|
||||
rates: list[int] = []
|
||||
for i, row in enumerate(rows):
|
||||
idx = row.get("idx")
|
||||
ub = row.get("upper_bound")
|
||||
av = row.get("anchoring_value")
|
||||
if idx != i + 1:
|
||||
raise BaseTableError(f"idx 불연속: 위치 {i} 의 idx={idx} (기대 {i + 1})")
|
||||
if ub != UPPER_BOUNDS[i]:
|
||||
raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={ub} (기대 {UPPER_BOUNDS[i]})")
|
||||
if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20):
|
||||
raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
|
||||
rates.append(int(round(av * 1000)))
|
||||
return rates
|
||||
|
||||
|
||||
def load_base_table() -> None:
|
||||
"""리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등)."""
|
||||
global _rates
|
||||
if _rates is not None:
|
||||
return
|
||||
try:
|
||||
rows = json.loads(_RESOURCE.read_text())
|
||||
except Exception as ex:
|
||||
raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex
|
||||
_rates = _validate(rows)
|
||||
|
||||
|
||||
def get_base_rate_permille(bracket_index: int) -> int:
|
||||
"""구간 인덱스 → 시작 앵커링 값(‰). §2.1"""
|
||||
if _rates is None:
|
||||
load_base_table()
|
||||
return _rates[bracket_index]
|
||||
364
schedules/anchoring/src/anchoring/batch.py
Normal file
364
schedules/anchoring/src/anchoring/batch.py
Normal file
@ -0,0 +1,364 @@
|
||||
"""격주 조정 배치. 규범: §8.
|
||||
|
||||
절차: (매주, 게이트 무관) 캐시 re-SET → 격주 게이트 → 미처리 종료 재협상 세션 스캔
|
||||
→ 파생 판정 → EXCLUDED/칸 불가 마킹 0 → 칸별 [조정 INSERT + 소비 마킹 한 트랜잭션,
|
||||
rowcount ≠ n 이면 전체 롤백(MUST — 유니크 가드 없는 구조에서 이중 조정의 유일한 방어선)]
|
||||
→ 커밋 후 Redis SET → 요약 로그(가격 제시율 0% 면 WARN).
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from anchoring.base_table import get_base_rate_permille
|
||||
from anchoring.constants import (
|
||||
ANCHOR_RATE_MAX,
|
||||
ANCHOR_RATE_MIN,
|
||||
EVAL_WEEK_PARITY,
|
||||
MARK_EXCLUDED,
|
||||
QT_TYPE_RENEGO,
|
||||
SAMPLE_THRESHOLD,
|
||||
SAMPLEABLE_SUPPLIER_TYPES,
|
||||
SESSION_STATUS_DONE,
|
||||
TERMINAL_SESSION_STATUSES,
|
||||
AnchoringSampleType,
|
||||
)
|
||||
from anchoring.db import session_scope
|
||||
from anchoring.log import LOG
|
||||
from anchoring.models import Item, Quotation, RateAdjustment, Session
|
||||
from anchoring.reader import get_latest_adjusted_rate
|
||||
from anchoring.redis_client import consume_failure_counts, ping, set_rate
|
||||
from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
_MARK_CHUNK = 1000
|
||||
|
||||
|
||||
class MarkingConflictError(RuntimeError):
|
||||
"""소비 마킹 rowcount 불일치 — 경합/오설정. 트랜잭션 전체 롤백 트리거."""
|
||||
|
||||
|
||||
def is_evaluation_week(now_kst: datetime) -> bool:
|
||||
"""격주 게이트: ISO 주차 홀짝(기준 패리티 상수 고정). §8"""
|
||||
return now_kst.isocalendar().week % 2 == EVAL_WEEK_PARITY
|
||||
|
||||
|
||||
async def _reconcile_cache() -> int:
|
||||
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET.
|
||||
|
||||
stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7).
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
RateAdjustment.company_id,
|
||||
RateAdjustment.supplier_type,
|
||||
RateAdjustment.price_bracket_index,
|
||||
RateAdjustment.anchor_rate_after,
|
||||
)
|
||||
.distinct(
|
||||
RateAdjustment.company_id,
|
||||
RateAdjustment.supplier_type,
|
||||
RateAdjustment.price_bracket_index,
|
||||
)
|
||||
.order_by(
|
||||
RateAdjustment.company_id,
|
||||
RateAdjustment.supplier_type,
|
||||
RateAdjustment.price_bracket_index,
|
||||
RateAdjustment.id.desc(),
|
||||
)
|
||||
)
|
||||
async with session_scope() as db:
|
||||
rows = (await db.execute(stmt)).all()
|
||||
ok = 0
|
||||
for company_id, stype, bracket, rate in rows:
|
||||
if await set_rate(company_id, stype, bracket, rate):
|
||||
ok += 1
|
||||
return ok
|
||||
|
||||
|
||||
async def _scan_pending(db, company_ids: list | None = None) -> list:
|
||||
"""미처리 종료 재협상 세션 + 칸 해석 소스(supplier_type/company_id) 조인. §8 절차 1
|
||||
|
||||
조인 ON 절에 deleted 필터 — 소프트 삭제된 견적/상품의 세션은 칸 해석이 NULL 이 되어
|
||||
제외 마킹(0)으로 정리된다(철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지).
|
||||
company_ids: 대상 회사 한정(테스트·표적 수동 실행용). None = 전체.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
Session.session_id,
|
||||
Session.status,
|
||||
Session.bid_price,
|
||||
Session.target_price,
|
||||
Session.target_anchoring_price,
|
||||
Session.anchor_rate_permille,
|
||||
Session.last_offered_price,
|
||||
Quotation.supplier_type,
|
||||
Item.company_id,
|
||||
)
|
||||
.join(
|
||||
Quotation,
|
||||
(Quotation.qt_id == Session.quotation_id) & Quotation.deleted.is_(False),
|
||||
isouter=True,
|
||||
)
|
||||
.join(
|
||||
Item,
|
||||
(Item.item_id == Session.item_id) & Item.deleted.is_(False),
|
||||
isouter=True,
|
||||
)
|
||||
.where(
|
||||
Session.anchoring_adjustment_id.is_(None),
|
||||
Session.deleted.is_(False),
|
||||
Session.qt_type == QT_TYPE_RENEGO,
|
||||
Session.status.in_(TERMINAL_SESSION_STATUSES),
|
||||
)
|
||||
)
|
||||
if company_ids:
|
||||
stmt = stmt.where(Item.company_id.in_(company_ids))
|
||||
return (await db.execute(stmt)).all()
|
||||
|
||||
|
||||
async def _mark_sessions(db, session_ids: list, adjustment_id: int) -> int:
|
||||
"""소비/제외 마킹. `IS NULL` 조건으로 이중 마킹 차단. 반환: 실제 마킹 행 수."""
|
||||
marked = 0
|
||||
for i in range(0, len(session_ids), _MARK_CHUNK):
|
||||
chunk = session_ids[i:i + _MARK_CHUNK]
|
||||
res = await db.execute(
|
||||
update(Session)
|
||||
.where(Session.session_id.in_(chunk), Session.anchoring_adjustment_id.is_(None))
|
||||
.values(anchoring_adjustment_id=adjustment_id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
marked += res.rowcount
|
||||
return marked
|
||||
|
||||
|
||||
async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: list,
|
||||
dry_run: bool = False) -> dict | None:
|
||||
"""칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST).
|
||||
|
||||
samples: [(session_id, sample_type_code)] — 유효 표본만, n ≥ 10 보장 후 호출.
|
||||
dry_run: 계산만 하고 INSERT·마킹·캐시 SET 을 전부 생략(예상 결과 dict 반환).
|
||||
반환: 요약용 dict / 마킹 경합 시 예외(트랜잭션 롤백).
|
||||
"""
|
||||
session_ids = [sid for sid, _ in samples]
|
||||
sample_types = [st for _, st in samples]
|
||||
|
||||
async with session_scope() as db:
|
||||
latest = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket)
|
||||
rate_before = latest if latest is not None else get_base_rate_permille(bracket)
|
||||
rate_after = evaluate_pending(rate_before, sample_types, supplier_type)
|
||||
if rate_after is None: # 방어적 재확인(호출측에서 n>=10 보장)
|
||||
return None
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"adjustment_id": None,
|
||||
"before": rate_before,
|
||||
"after": rate_after,
|
||||
"success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
|
||||
"n": len(samples),
|
||||
}
|
||||
|
||||
adjustment = RateAdjustment(
|
||||
company_id=company_id,
|
||||
supplier_type=supplier_type,
|
||||
price_bracket_index=bracket,
|
||||
nego_count=len(samples),
|
||||
success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
|
||||
anchor_rate_before=rate_before,
|
||||
anchor_rate_after=rate_after,
|
||||
consumed_session_ids=[str(sid) for sid in session_ids],
|
||||
)
|
||||
db.add(adjustment)
|
||||
await db.flush() # adjustment.id 확보
|
||||
|
||||
marked = await _mark_sessions(db, session_ids, adjustment.id)
|
||||
if marked != len(session_ids):
|
||||
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
|
||||
raise MarkingConflictError(
|
||||
f"company={company_id} type={supplier_type} bracket={bracket} 마킹 {marked}/{len(session_ids)}"
|
||||
)
|
||||
|
||||
# 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복)
|
||||
await set_rate(company_id, supplier_type, bracket, rate_after)
|
||||
return {
|
||||
"adjustment_id": adjustment.id,
|
||||
"before": rate_before,
|
||||
"after": rate_after,
|
||||
"success": adjustment.success_count,
|
||||
"n": len(samples),
|
||||
}
|
||||
|
||||
|
||||
def _new_company_agg() -> dict:
|
||||
return {"evaluated": 0, "up": 0, "hold": 0, "down": 0, "carryover": 0, "failed": 0, "excluded": 0}
|
||||
|
||||
|
||||
async def run_evaluation_batch(force: bool = False, company_ids: list | None = None,
|
||||
dry_run: bool = False) -> dict:
|
||||
"""배치 1회. force=True 면 격주 게이트만 무시(정책 파라미터는 불변).
|
||||
|
||||
company_ids: 대상 회사 한정 — 테스트가 공유 DB 의 실데이터를 소비하지 않게 하는
|
||||
격리 장치이자, 특정 테넌트만 표적 수동 실행하는 운영 옵션. None = 전체(운영 기본).
|
||||
dry_run: 판정·예상 조정을 로그로만 보고 DB/Redis 를 일절 변경하지 않는다 —
|
||||
첫 운영 실행 전 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북).
|
||||
|
||||
로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), 칸/회사 단위 라인은
|
||||
`company=` `type=` `bracket=` key=value 형식(회사별 grep — `grep company=<uuid>`).
|
||||
"""
|
||||
now = datetime.now(KST)
|
||||
run_id = now.strftime("%Y%m%d-%H%M%S")
|
||||
tag = f"[batch {run_id}]"
|
||||
scope = f", 대상 회사 {len(company_ids)}곳" if company_ids else ""
|
||||
mode = ", DRY-RUN(변경 없음)" if dry_run else ""
|
||||
LOG.info(f"{tag} 시작 — ISO 주차 {now.isocalendar().week}, force={force}{scope}{mode}")
|
||||
|
||||
# 절차 0.5 — 캐시 정합(매주, 게이트 무관). Redis 다운이면 즉시 건너뜀
|
||||
# (셀마다 timeout 을 태우며 수십 분 지연되는 것 방지 — TTL·다음 주 re-SET 이 회복)
|
||||
if dry_run:
|
||||
reconciled = 0
|
||||
elif await ping():
|
||||
reconciled = await _reconcile_cache()
|
||||
LOG.info(f"{tag} 캐시 re-SET {reconciled}칸")
|
||||
else:
|
||||
reconciled = 0
|
||||
LOG.warning(f"{tag} Redis 미가용 — 캐시 re-SET 건너뜀(읽기는 DB 폴백으로 동작)")
|
||||
|
||||
if not force and not is_evaluation_week(now):
|
||||
_log_redis_failures(tag)
|
||||
LOG.info(f"{tag} 격주 게이트 미충족 — 평가 스킵")
|
||||
return {"run_id": run_id, "status": "skipped", "reason": "week_parity", "cache_reconciled": reconciled}
|
||||
|
||||
# 절차 1~2 — 스캔 + 파생 판정
|
||||
async with session_scope() as db:
|
||||
rows = await _scan_pending(db, company_ids)
|
||||
|
||||
excluded_ids: list = []
|
||||
cells: dict[tuple, list] = defaultdict(list)
|
||||
per_company: dict[str, dict] = defaultdict(_new_company_agg)
|
||||
priced = 0
|
||||
snapshot_mismatch = []
|
||||
for r in rows:
|
||||
if r.last_offered_price is not None:
|
||||
priced += 1
|
||||
# 박제 정합 감시: negodata 가 rate 와 anchor 를 함께 박제하기 시작하면(인수인계 적용 후)
|
||||
# 정수식 tp*(1000-rate)//1000 과 박제 anchor 가 일치해야 한다 — 불일치 = 이식 오류 신호.
|
||||
# 전환기(rate 미박제 = NULL)에는 자동 스킵된다.
|
||||
if (r.anchor_rate_permille is not None and r.target_anchoring_price is not None
|
||||
and r.target_price is not None
|
||||
and r.target_price * (1000 - r.anchor_rate_permille) // 1000 != r.target_anchoring_price):
|
||||
snapshot_mismatch.append(r.session_id)
|
||||
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
|
||||
excluded_ids.append(r.session_id) # 칸 구성 불가
|
||||
if r.company_id is not None:
|
||||
per_company[str(r.company_id)]["excluded"] += 1
|
||||
continue
|
||||
sample_type = judge_sample_type(
|
||||
is_done=r.status == SESSION_STATUS_DONE,
|
||||
bid_price=r.bid_price,
|
||||
last_offered_price=r.last_offered_price,
|
||||
anchor_price=r.target_anchoring_price,
|
||||
)
|
||||
if sample_type == AnchoringSampleType.EXCLUDED.value:
|
||||
excluded_ids.append(r.session_id)
|
||||
per_company[str(r.company_id)]["excluded"] += 1
|
||||
continue
|
||||
bracket = calc_bracket_index(r.target_price)
|
||||
cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type))
|
||||
|
||||
if snapshot_mismatch:
|
||||
sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5])
|
||||
LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 "
|
||||
f"(정수식과 박제 anchor 불일치). 예: {sample}")
|
||||
|
||||
# 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
|
||||
if rows and priced == 0:
|
||||
LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) "
|
||||
f"— backend 가격 입력 기록 배선 점검 필요")
|
||||
|
||||
# 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라
|
||||
# 원자성이 불필요하고(중단 시 다음 회차가 이어서 마킹), 첫 실행의 레거시 대량
|
||||
# 마킹이 장시간 단일 트랜잭션(WAL·락)을 만드는 것을 방지한다.
|
||||
if excluded_ids:
|
||||
if dry_run:
|
||||
LOG.info(f"{tag} 제외 확정 마킹(예정) {len(excluded_ids)}건 — DRY-RUN, 미실행")
|
||||
else:
|
||||
marked_total = 0
|
||||
for i in range(0, len(excluded_ids), _MARK_CHUNK):
|
||||
async with session_scope() as db:
|
||||
marked_total += await _mark_sessions(db, excluded_ids[i:i + _MARK_CHUNK], MARK_EXCLUDED)
|
||||
LOG.info(f"{tag} 제외 확정 마킹 {marked_total}건")
|
||||
|
||||
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
|
||||
evaluated = up = hold = down = clamped = failed = 0
|
||||
carryover = 0
|
||||
for (company_id, stype, bracket), samples in cells.items():
|
||||
agg = per_company[str(company_id)]
|
||||
if len(samples) < SAMPLE_THRESHOLD:
|
||||
carryover += 1 # 마킹하지 않음 = 이월(§4.4)
|
||||
agg["carryover"] += 1
|
||||
continue
|
||||
try:
|
||||
result = await _evaluate_cell(company_id, stype, bracket, samples, dry_run=dry_run)
|
||||
except Exception as ex:
|
||||
failed += 1
|
||||
agg["failed"] += 1
|
||||
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} bracket={bracket}: {ex}", exc_info=True)
|
||||
continue
|
||||
if result is None:
|
||||
carryover += 1
|
||||
agg["carryover"] += 1
|
||||
continue
|
||||
evaluated += 1
|
||||
agg["evaluated"] += 1
|
||||
# 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인
|
||||
label = "조정예정" if dry_run else "조정"
|
||||
LOG.info(f"{tag} {label} company={company_id} type={stype} bracket={bracket} "
|
||||
f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}‰ "
|
||||
f"adj_id={result['adjustment_id']}")
|
||||
if result["after"] > result["before"]:
|
||||
up += 1
|
||||
agg["up"] += 1
|
||||
elif result["after"] < result["before"]:
|
||||
down += 1
|
||||
agg["down"] += 1
|
||||
else:
|
||||
hold += 1
|
||||
agg["hold"] += 1
|
||||
if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX):
|
||||
clamped += 1
|
||||
|
||||
# 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로
|
||||
for company, agg in sorted(per_company.items()):
|
||||
LOG.info(f"{tag} 회사요약 company={company} 평가={agg['evaluated']} 상승={agg['up']} "
|
||||
f"유지={agg['hold']} 하락={agg['down']} 이월={agg['carryover']} "
|
||||
f"실패={agg['failed']} 제외={agg['excluded']}")
|
||||
|
||||
_log_redis_failures(tag)
|
||||
|
||||
summary = {
|
||||
"run_id": run_id,
|
||||
"status": ("dry_run" if dry_run else "done") if failed == 0 else "partial",
|
||||
"scanned": len(rows),
|
||||
"priced_rate": (priced / len(rows)) if rows else None,
|
||||
"excluded_marked": len(excluded_ids),
|
||||
"evaluated_cells": evaluated,
|
||||
"up": up, "hold": hold, "down": down, "clamped": clamped,
|
||||
"carryover_cells": carryover,
|
||||
"failed_cells": failed,
|
||||
"companies": len(per_company),
|
||||
"snapshot_mismatch": len(snapshot_mismatch),
|
||||
"cache_reconciled": reconciled,
|
||||
}
|
||||
# 칸 실패가 있으면 요약을 WARNING 으로 승격 — "WARN 이상 알람" 정책에 걸리도록
|
||||
log_fn = LOG.warning if failed else LOG.info
|
||||
log_fn(f"{tag} 종료 {summary}")
|
||||
return summary
|
||||
|
||||
|
||||
def _log_redis_failures(tag: str) -> None:
|
||||
counts = consume_failure_counts()
|
||||
if counts["get"] or counts["set"]:
|
||||
LOG.warning(f"{tag} redis 실패 누계 get={counts['get']} set={counts['set']} "
|
||||
f"— DB 폴백으로 동작함, Redis 상태 점검 필요")
|
||||
66
schedules/anchoring/src/anchoring/config.py
Normal file
66
schedules/anchoring/src/anchoring/config.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""설정 — config.toml + env 오버라이드(env > toml > 기본값).
|
||||
|
||||
자립 모듈: backend config 체계를 쓰지 않는다. 시크릿은 config.toml(.gitignore) 또는 env 로.
|
||||
"""
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.toml"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DBConfig:
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 5432
|
||||
user: str = "postgres"
|
||||
password: str = "postgres"
|
||||
name: str = "negosium_db"
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisConfig:
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 6379
|
||||
db: int = 0
|
||||
password: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
db: DBConfig = field(default_factory=DBConfig)
|
||||
redis: RedisConfig = field(default_factory=RedisConfig)
|
||||
log_level: str = "info"
|
||||
|
||||
|
||||
def _env(name: str, current, cast=str):
|
||||
raw = os.environ.get(name)
|
||||
return cast(raw) if raw is not None else current
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
cfg = Config()
|
||||
if _CONFIG_PATH.exists():
|
||||
data = tomllib.loads(_CONFIG_PATH.read_text())
|
||||
db = data.get("db", {})
|
||||
rd = data.get("redis", {})
|
||||
cfg.db = DBConfig(**{**cfg.db.__dict__, **db})
|
||||
cfg.redis = RedisConfig(**{**cfg.redis.__dict__, **rd})
|
||||
cfg.log_level = data.get("log_level", cfg.log_level)
|
||||
|
||||
cfg.db.host = _env("DB_HOST", cfg.db.host)
|
||||
cfg.db.port = _env("DB_PORT", cfg.db.port, int)
|
||||
cfg.db.user = _env("DB_USER", cfg.db.user)
|
||||
cfg.db.password = _env("DB_PASSWORD", cfg.db.password)
|
||||
cfg.db.name = _env("DB_NAME", cfg.db.name)
|
||||
cfg.redis.host = _env("REDIS_HOST", cfg.redis.host)
|
||||
cfg.redis.port = _env("REDIS_PORT", cfg.redis.port, int)
|
||||
cfg.redis.db = _env("REDIS_DB", cfg.redis.db, int)
|
||||
cfg.redis.password = _env("REDIS_PASSWORD", cfg.redis.password)
|
||||
cfg.log_level = _env("LOG_LEVEL", cfg.log_level)
|
||||
return cfg
|
||||
87
schedules/anchoring/src/anchoring/constants.py
Normal file
87
schedules/anchoring/src/anchoring/constants.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""앵커링 도메인 상수 + 코드값(enum). 규범: docs/개발용.md §3.
|
||||
|
||||
backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값은 프로젝트 컨벤션
|
||||
(SMALLINT 1-based + 앱 enum 매핑)을 따르며 quotations.supplier_type 과 동일 코드다.
|
||||
상수 변경은 정책 재확정 사안 — 코드에서 임의 조정 금지(§12).
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
|
||||
ANCHOR_RATE_MIN = 10 # 하한 1%
|
||||
ANCHOR_RATE_MAX = 200 # 상한 20%
|
||||
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2)
|
||||
|
||||
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
|
||||
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름)
|
||||
DELTA_PERMILLE = {
|
||||
1: 20, # 유통(DISTRIBUTION) ±2%
|
||||
2: 10, # 제조(MANUFACTURE) ±1%
|
||||
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
|
||||
}
|
||||
|
||||
SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 스킵·이월)
|
||||
|
||||
# ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ──
|
||||
# 예: 1,000~1만 은 1,000원 폭(1천 원대·2천 원대…), 1만~10만 은 1만 폭(1만 원대·2만 원대…).
|
||||
# 최하단(0~1,000원)은 한 칸으로 통일. 1억 초과는 마지막 칸으로 클램프.
|
||||
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억)
|
||||
_DECADE_STARTS = (1_000, 10_000, 100_000, 1_000_000, 10_000_000)
|
||||
|
||||
|
||||
def _build_upper_bounds() -> tuple:
|
||||
bounds = [1_000] # idx 0: [0, 1,000) 통일 칸
|
||||
for start in _DECADE_STARTS: # 각 자릿수: 폭 = start (상한의 10%)
|
||||
bounds.extend(start + start * i for i in range(1, 10))
|
||||
return tuple(bounds) # 마지막 = 100,000,000
|
||||
|
||||
|
||||
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
|
||||
BRACKET_COUNT = len(UPPER_BOUNDS) # 46
|
||||
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45
|
||||
|
||||
# ── 배치 ──────────────────────────────────────────────────
|
||||
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
||||
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
||||
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음)
|
||||
|
||||
# ── Redis 캐시 (§7) ──────────────────────────────────────
|
||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET)
|
||||
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
||||
|
||||
|
||||
class SupplierType(Enum):
|
||||
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
|
||||
(negodata SupplierType 과 동일 코드)"""
|
||||
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
|
||||
DISTRIBUTION = 1 # 유통
|
||||
MANUFACTURE = 2 # 제조
|
||||
SOLE_AGENCY = 3 # 총판
|
||||
|
||||
|
||||
class AnchoringSampleType(Enum):
|
||||
"""앵커링 표본 판정 결과(파생값 — DB 에 저장하지 않음, 평가 로직·로그용).
|
||||
|
||||
기준 = "가격 흔적": 협력사가 가격을 한 번이라도 써낸 협상만 표본으로 세고,
|
||||
앵커 이하 합의만 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈)는 전부 실패.
|
||||
"""
|
||||
BID_SUCCESS = 1 # 정상종료 + bid ≤ 박제 앵커
|
||||
BID_FAIL = 2 # 가격 흔적 있으나 성공 아님 (앵커 초과 합의 / 결렬 / 가격 쓰고 이탈·만료)
|
||||
EXCLUDED = 3 # 가격 흔적 없음(미참여·무가격 이탈) / 앵커 박제 없음 / 유형 미지정
|
||||
|
||||
|
||||
SAMPLEABLE_SUPPLIER_TYPES = (
|
||||
SupplierType.DISTRIBUTION.value,
|
||||
SupplierType.MANUFACTURE.value,
|
||||
SupplierType.SOLE_AGENCY.value,
|
||||
)
|
||||
|
||||
# 협상 세션 코드값(backend SessionStatus/QtType 와 동일 매핑, 읽기용으로만 자체 보유)
|
||||
SESSION_STATUS_DONE = 3 # 협상완료
|
||||
SESSION_STATUS_NOT_PARTICIPATED = 4 # 미참여(마감·일괄마감)
|
||||
SESSION_STATUS_REJECTED = 5 # 거부
|
||||
TERMINAL_SESSION_STATUSES = (
|
||||
SESSION_STATUS_DONE,
|
||||
SESSION_STATUS_NOT_PARTICIPATED,
|
||||
SESSION_STATUS_REJECTED,
|
||||
)
|
||||
QT_TYPE_RENEGO = 1 # 재협상(1:1) — 표본 대상
|
||||
41
schedules/anchoring/src/anchoring/db.py
Normal file
41
schedules/anchoring/src/anchoring/db.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""async SQLAlchemy 엔진/세션 (asyncpg). 자립: backend DB 매니저 미사용.
|
||||
|
||||
조정 INSERT + 소비 마킹은 반드시 같은 세션(session_scope 한 블록)에서 실행한다
|
||||
— 한 트랜잭션 원자성이 이중 조정 방어선(§8).
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from anchoring.config import Config
|
||||
|
||||
_engine = None
|
||||
_session_factory: async_sessionmaker | None = None
|
||||
|
||||
|
||||
def init_engine(cfg: Config) -> None:
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
return
|
||||
_engine = create_async_engine(cfg.db.url, pool_size=5, max_overflow=5, pool_pre_ping=True)
|
||||
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_scope():
|
||||
"""정상 종료 시 commit, 예외 시 rollback."""
|
||||
async with _session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
31
schedules/anchoring/src/anchoring/log.py
Normal file
31
schedules/anchoring/src/anchoring/log.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""모듈 로거 — 표준 logging 얇은 래퍼(자립: backend logger 미사용).
|
||||
|
||||
- 타임스탬프는 컨테이너 TZ 와 무관하게 항상 KST(+0900) — 배치 기준 시각과 로그 대조 편의.
|
||||
- apscheduler 로거에도 같은 핸들러를 연결한다(미연결 시 misfire 등 스케줄 이상 로그가
|
||||
포맷 없는 stderr 로 새거나 유실됨).
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
LOG = logging.getLogger("anchoring")
|
||||
|
||||
|
||||
class _KSTFormatter(logging.Formatter):
|
||||
def formatTime(self, record, datefmt=None): # noqa: N802 (logging 시그니처)
|
||||
dt = datetime.fromtimestamp(record.created, KST)
|
||||
return dt.strftime(datefmt or "%Y-%m-%d %H:%M:%S%z")
|
||||
|
||||
|
||||
def configure(level: str = "info") -> None:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(_KSTFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
for name in ("anchoring", "apscheduler"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
logger.propagate = False
|
||||
70
schedules/anchoring/src/anchoring/main.py
Normal file
70
schedules/anchoring/src/anchoring/main.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""엔트리포인트.
|
||||
|
||||
기본: 스케줄러 상주(컨테이너 메인).
|
||||
PYTHONPATH=src python -m anchoring.main
|
||||
수동 1회(격주 게이트 무시 — 미스파이어 캐치업/운영 점검 런북):
|
||||
PYTHONPATH=src python -m anchoring.main --once
|
||||
예행 연습(판정·예상 조정을 로그로만 — DB/Redis 무변경, 첫 운영 실행 전 확인용):
|
||||
PYTHONPATH=src python -m anchoring.main --once --dry-run
|
||||
|
||||
기동 시 정적 테이블 검증 실패 → 예외로 즉시 중단(§13-7 MUST).
|
||||
"""
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from anchoring.base_table import load_base_table
|
||||
from anchoring.batch import run_evaluation_batch
|
||||
from anchoring.config import load_config
|
||||
from anchoring.constants import BRACKET_COUNT
|
||||
from anchoring.db import dispose_engine, init_engine
|
||||
from anchoring.log import LOG, configure
|
||||
from anchoring.redis_client import close_redis, init_redis
|
||||
from anchoring.scheduler import build_scheduler
|
||||
|
||||
|
||||
async def _run(once: bool) -> None:
|
||||
cfg = load_config()
|
||||
configure(cfg.log_level)
|
||||
|
||||
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
|
||||
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({BRACKET_COUNT}칸 사다리)")
|
||||
init_engine(cfg)
|
||||
init_redis(cfg.redis)
|
||||
|
||||
try:
|
||||
if once:
|
||||
dry = "--dry-run" in sys.argv
|
||||
LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry else ''})")
|
||||
result = await run_evaluation_batch(force=True, dry_run=dry)
|
||||
LOG.info(f"[main] 결과: {result}")
|
||||
return result
|
||||
|
||||
scheduler = build_scheduler()
|
||||
scheduler.start()
|
||||
job = scheduler.get_job("anchoring_biweekly_evaluation")
|
||||
LOG.info(f"[main] 스케줄러 상주 시작 — 다음 실행 예정: {job.next_run_time}")
|
||||
|
||||
# SIGTERM(docker stop)/SIGINT 를 받아 정상 종료 — finally(리소스 정리)가 반드시 실행되게 한다
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
await stop.wait()
|
||||
LOG.info("[main] 종료 신호 수신 — 정리 후 종료")
|
||||
scheduler.shutdown(wait=False)
|
||||
return None
|
||||
finally:
|
||||
await close_redis()
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = asyncio.run(_run(once="--once" in sys.argv))
|
||||
# --once 가 부분 실패(partial)로 끝나면 비정상 종료코드 — 런북/cron 에서 감지 가능해야 한다
|
||||
if result is not None and result.get("status") not in ("done", "skipped", "dry_run"):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
schedules/anchoring/src/anchoring/models.py
Normal file
64
schedules/anchoring/src/anchoring/models.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""ORM 모델 — 자립(backend models 미사용).
|
||||
|
||||
- 소유(쓰기): anchoring.rate_adjustments (append-only — UPDATE/DELETE 금지 §5)
|
||||
- sessions 는 anchoring_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12).
|
||||
quotations/items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만).
|
||||
"""
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
BASE = declarative_base()
|
||||
|
||||
|
||||
class RateAdjustment(BASE):
|
||||
__tablename__ = "rate_adjustments"
|
||||
__table_args__ = {"schema": "anchoring"}
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
company_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판
|
||||
price_bracket_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리)
|
||||
nego_count = Column(Integer, nullable=False) # 유효 표본 수 n
|
||||
success_count = Column(Integer, nullable=False)
|
||||
anchor_rate_before = Column(SmallInteger, nullable=False) # ‰
|
||||
anchor_rate_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200]
|
||||
consumed_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
||||
|
||||
|
||||
# ── 읽기 전용/마킹 매핑(집계에 필요한 컬럼만) ─────────────────
|
||||
class Session(BASE):
|
||||
__tablename__ = "sessions"
|
||||
__table_args__ = {"schema": "negotiation"}
|
||||
|
||||
session_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||
quotation_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
item_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
qt_type = Column(SmallInteger, nullable=False) # 1=재협상
|
||||
target_price = Column(BigInteger, nullable=False)
|
||||
target_anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준)
|
||||
anchor_rate_permille = Column(SmallInteger, nullable=True) # 박제 rate
|
||||
last_offered_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외)
|
||||
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼)
|
||||
status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED
|
||||
bid_price = Column(BigInteger, nullable=True)
|
||||
deleted = Column(Boolean, nullable=False)
|
||||
|
||||
|
||||
class Quotation(BASE):
|
||||
__tablename__ = "quotations"
|
||||
__table_args__ = {"schema": "quotation"}
|
||||
|
||||
qt_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||
supplier_type = Column(SmallInteger, nullable=True) # NULL 이면 칸 구성 불가 → 제외
|
||||
deleted = Column(Boolean, nullable=False)
|
||||
|
||||
|
||||
class Item(BASE):
|
||||
__tablename__ = "items"
|
||||
__table_args__ = {"schema": "partner"}
|
||||
|
||||
item_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||
company_id = Column(UUID(as_uuid=True), nullable=True) # 테넌트(갑) — NULL 이면 칸 구성 불가
|
||||
deleted = Column(Boolean, nullable=False)
|
||||
42
schedules/anchoring/src/anchoring/reader.py
Normal file
42
schedules/anchoring/src/anchoring/reader.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1.
|
||||
|
||||
negodata 이식 대상 — 견적/세션 생성 시 이 함수로 칸 rate 를 얻어
|
||||
anchor_price = target_price * (1000 - rate) // 1000 를 정수 연산으로 계산·박제한다.
|
||||
|
||||
순서: Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 시작값 → Redis SET(best effort).
|
||||
supplier_type ∉ {1,2,3} 인 경우 호출하지 말고 get_base_rate_permille(bracket) 을 직접 쓴다(§9.1).
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from anchoring.base_table import get_base_rate_permille
|
||||
from anchoring.models import RateAdjustment
|
||||
from anchoring.redis_client import get_rate, set_rate
|
||||
|
||||
|
||||
async def get_latest_adjusted_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int | None:
|
||||
"""칸의 최신 조정 행 rate_after. 이력 없으면 None."""
|
||||
stmt = (
|
||||
select(RateAdjustment.anchor_rate_after)
|
||||
.where(
|
||||
RateAdjustment.company_id == company_id,
|
||||
RateAdjustment.supplier_type == supplier_type,
|
||||
RateAdjustment.price_bracket_index == bracket_index,
|
||||
)
|
||||
.order_by(RateAdjustment.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_anchor_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int:
|
||||
"""칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET."""
|
||||
cached = await get_rate(company_id, supplier_type, bracket_index)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
rate = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket_index)
|
||||
if rate is None:
|
||||
rate = get_base_rate_permille(bracket_index)
|
||||
await set_rate(company_id, supplier_type, bracket_index, rate, nx=True)
|
||||
return rate
|
||||
101
schedules/anchoring/src/anchoring/redis_client.py
Normal file
101
schedules/anchoring/src/anchoring/redis_client.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Redis 캐시 클라이언트. 규범: §7.
|
||||
|
||||
- 키: anchor:{company_id}:{supplier_type}:{bracket_index} (supplier_type 은 SMALLINT 코드값)
|
||||
- 값: 정수 천분율 문자열, TTL 7일(주 방어선은 배치의 주간 re-SET)
|
||||
- 장애 내성 MUST: 에러 시 GET→None(DB 폴백), SET→로그만. Redis 가 견적/배치를 막으면 안 된다.
|
||||
"""
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from anchoring.config import RedisConfig
|
||||
from anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
|
||||
from anchoring.log import LOG
|
||||
|
||||
_client: aioredis.Redis | None = None
|
||||
|
||||
# 실패 WARN 폭주 억제: 연산별 처음 N 건만 WARN, 이후 무음. 누계는 배치가
|
||||
# consume_failure_counts() 로 회수해 회차 요약에 한 줄로 남긴다.
|
||||
_WARN_LIMIT = 5
|
||||
_fail_counts = {"get": 0, "set": 0}
|
||||
|
||||
|
||||
def _note_failure(op: str, key: str, ex: Exception) -> None:
|
||||
_fail_counts[op] += 1
|
||||
if _fail_counts[op] <= _WARN_LIMIT:
|
||||
suffix = " — 이후 동일 실패는 억제(누계는 배치 요약)" if _fail_counts[op] == _WARN_LIMIT else ""
|
||||
LOG.warning(f"[redis] {op.upper()} 실패({_fail_counts[op]}번째, DB 폴백) key={key}: {ex}{suffix}")
|
||||
|
||||
|
||||
def consume_failure_counts() -> dict:
|
||||
"""실패 누계 회수 + 리셋 — 배치 회차 요약용."""
|
||||
global _fail_counts
|
||||
counts, _fail_counts = _fail_counts, {"get": 0, "set": 0}
|
||||
return counts
|
||||
|
||||
|
||||
def init_redis(cfg: RedisConfig) -> None:
|
||||
global _client
|
||||
if _client is not None:
|
||||
return
|
||||
_client = aioredis.Redis(
|
||||
host=cfg.host, port=cfg.port, db=cfg.db,
|
||||
password=cfg.password or None,
|
||||
socket_timeout=REDIS_SOCKET_TIMEOUT,
|
||||
socket_connect_timeout=REDIS_SOCKET_TIMEOUT,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
def anchor_key(company_id, supplier_type: int, bracket_index: int) -> str:
|
||||
return f"anchor:{company_id}:{supplier_type}:{bracket_index}"
|
||||
|
||||
|
||||
async def ping() -> bool:
|
||||
"""Redis 가용성 확인 — 대량 re-SET 전에 1회 확인해 다운 시 즉시 건너뛴다."""
|
||||
if _client is None:
|
||||
return False
|
||||
try:
|
||||
return bool(await _client.ping())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_rate(company_id, supplier_type: int, bracket_index: int) -> int | None:
|
||||
"""캐시 조회. 미스·에러·클라이언트 미초기화 → None(호출측이 DB 폴백)."""
|
||||
if _client is None:
|
||||
return None
|
||||
try:
|
||||
raw = await _client.get(anchor_key(company_id, supplier_type, bracket_index))
|
||||
if raw is None:
|
||||
return None
|
||||
rate = int(raw)
|
||||
# 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정
|
||||
if not (ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX):
|
||||
LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, bracket_index)} value={raw}")
|
||||
return None
|
||||
return rate
|
||||
except Exception as ex:
|
||||
_note_failure("get", anchor_key(company_id, supplier_type, bracket_index), ex)
|
||||
return None
|
||||
|
||||
|
||||
async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int, nx: bool = False) -> bool:
|
||||
"""캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다.
|
||||
|
||||
nx=True: 키가 없을 때만 적재 — 읽기 경로의 미스 백필용(배치가 방금 쓴 새 값을
|
||||
구값으로 덮어쓰는 write-after-read 경합 방지).
|
||||
"""
|
||||
if _client is None:
|
||||
return False
|
||||
try:
|
||||
await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS, nx=nx)
|
||||
return True
|
||||
except Exception as ex:
|
||||
_note_failure("set", anchor_key(company_id, supplier_type, bracket_index), ex)
|
||||
return False
|
||||
@ -0,0 +1,48 @@
|
||||
[
|
||||
{ "idx": 1, "upper_bound": 1000, "anchoring_value": 0.01 },
|
||||
{ "idx": 2, "upper_bound": 2000, "anchoring_value": 0.01 },
|
||||
{ "idx": 3, "upper_bound": 3000, "anchoring_value": 0.01 },
|
||||
{ "idx": 4, "upper_bound": 4000, "anchoring_value": 0.01 },
|
||||
{ "idx": 5, "upper_bound": 5000, "anchoring_value": 0.01 },
|
||||
{ "idx": 6, "upper_bound": 6000, "anchoring_value": 0.01 },
|
||||
{ "idx": 7, "upper_bound": 7000, "anchoring_value": 0.01 },
|
||||
{ "idx": 8, "upper_bound": 8000, "anchoring_value": 0.01 },
|
||||
{ "idx": 9, "upper_bound": 9000, "anchoring_value": 0.01 },
|
||||
{ "idx": 10, "upper_bound": 10000, "anchoring_value": 0.01 },
|
||||
{ "idx": 11, "upper_bound": 20000, "anchoring_value": 0.01 },
|
||||
{ "idx": 12, "upper_bound": 30000, "anchoring_value": 0.01 },
|
||||
{ "idx": 13, "upper_bound": 40000, "anchoring_value": 0.01 },
|
||||
{ "idx": 14, "upper_bound": 50000, "anchoring_value": 0.01 },
|
||||
{ "idx": 15, "upper_bound": 60000, "anchoring_value": 0.01 },
|
||||
{ "idx": 16, "upper_bound": 70000, "anchoring_value": 0.01 },
|
||||
{ "idx": 17, "upper_bound": 80000, "anchoring_value": 0.01 },
|
||||
{ "idx": 18, "upper_bound": 90000, "anchoring_value": 0.01 },
|
||||
{ "idx": 19, "upper_bound": 100000, "anchoring_value": 0.01 },
|
||||
{ "idx": 20, "upper_bound": 200000, "anchoring_value": 0.01 },
|
||||
{ "idx": 21, "upper_bound": 300000, "anchoring_value": 0.01 },
|
||||
{ "idx": 22, "upper_bound": 400000, "anchoring_value": 0.01 },
|
||||
{ "idx": 23, "upper_bound": 500000, "anchoring_value": 0.01 },
|
||||
{ "idx": 24, "upper_bound": 600000, "anchoring_value": 0.01 },
|
||||
{ "idx": 25, "upper_bound": 700000, "anchoring_value": 0.01 },
|
||||
{ "idx": 26, "upper_bound": 800000, "anchoring_value": 0.01 },
|
||||
{ "idx": 27, "upper_bound": 900000, "anchoring_value": 0.01 },
|
||||
{ "idx": 28, "upper_bound": 1000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 29, "upper_bound": 2000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 30, "upper_bound": 3000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 31, "upper_bound": 4000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 32, "upper_bound": 5000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 33, "upper_bound": 6000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 34, "upper_bound": 7000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 35, "upper_bound": 8000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 36, "upper_bound": 9000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 37, "upper_bound": 10000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 38, "upper_bound": 20000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 39, "upper_bound": 30000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 40, "upper_bound": 40000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 41, "upper_bound": 50000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 42, "upper_bound": 60000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 43, "upper_bound": 70000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 44, "upper_bound": 80000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 45, "upper_bound": 90000000, "anchoring_value": 0.01 },
|
||||
{ "idx": 46, "upper_bound": 100000000, "anchoring_value": 0.01 }
|
||||
]
|
||||
34
schedules/anchoring/src/anchoring/scheduler.py
Normal file
34
schedules/anchoring/src/anchoring/scheduler.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""APScheduler — '언제'(when) 담당. 규범: §8.
|
||||
|
||||
매주 토 00:00 KST 트리거(격주 게이트는 잡 내부 is_evaluation_week). 자립 단일 컨테이너가
|
||||
곧 스케줄러라 중복 실행이 원천 차단된다(추가로 max_instances=1). 잡 예외는 잡 안에서만
|
||||
처리해 스케줄러는 죽지 않는다.
|
||||
"""
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from anchoring.batch import run_evaluation_batch
|
||||
from anchoring.log import LOG
|
||||
|
||||
TIMEZONE = "Asia/Seoul"
|
||||
|
||||
|
||||
async def _job() -> None:
|
||||
try:
|
||||
await run_evaluation_batch(force=False)
|
||||
except Exception as ex: # 어떤 경우에도 스케줄러는 살아있어야 한다
|
||||
LOG.error(f"[scheduler] run_evaluation_batch 예외(무시하고 다음 트리거 대기): {ex}", exc_info=True)
|
||||
|
||||
|
||||
def build_scheduler() -> AsyncIOScheduler:
|
||||
scheduler = AsyncIOScheduler(timezone=TIMEZONE)
|
||||
scheduler.add_job(
|
||||
_job,
|
||||
CronTrigger(day_of_week="sat", hour=0, minute=0, timezone=TIMEZONE),
|
||||
id="anchoring_biweekly_evaluation",
|
||||
coalesce=True, # 밀린 실행이 쌓여도 1번만
|
||||
misfire_grace_time=3600, # 늦게 깨어나도 1시간 내면 실행 (초과 시 --once 런북)
|
||||
max_instances=1,
|
||||
)
|
||||
LOG.info(f"[scheduler] 등록 — 매주 토 00:00 {TIMEZONE} (격주 게이트는 잡 내부)")
|
||||
return scheduler
|
||||
81
schedules/anchoring/src/anchoring/service.py
Normal file
81
schedules/anchoring/src/anchoring/service.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""순수 계산 함수 — DB/Redis 접근 없음. 규범: §4, §10.
|
||||
|
||||
모든 산술은 정수(천분율 ‰). float 금지(§12) — 성공률 비교도 정수 비교로 수행한다.
|
||||
"""
|
||||
from bisect import bisect_right
|
||||
|
||||
from anchoring.base_table import get_base_rate_permille
|
||||
from anchoring.constants import (
|
||||
ANCHOR_RATE_MAX,
|
||||
ANCHOR_RATE_MIN,
|
||||
BRACKET_INDEX_MAX,
|
||||
DELTA_PERMILLE,
|
||||
SAMPLE_THRESHOLD,
|
||||
UPPER_BOUNDS,
|
||||
AnchoringSampleType,
|
||||
)
|
||||
|
||||
|
||||
def calc_bracket_index(target_price: int) -> int:
|
||||
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
|
||||
|
||||
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
|
||||
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
|
||||
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
|
||||
|
||||
|
||||
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
|
||||
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
|
||||
return target_price * (1000 - rate_permille) // 1000
|
||||
|
||||
|
||||
def judge_sample_type(
|
||||
is_done: bool, # sessions.status == DONE(3)
|
||||
bid_price: int | None, # 확정 투찰가(DONE 시)
|
||||
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
|
||||
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커)
|
||||
) -> int:
|
||||
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적.
|
||||
|
||||
가격을 한 번이라도 써낸 협상만 표본: 앵커 이하 합의 = 성공,
|
||||
나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패. 가격 흔적이 없으면 제외.
|
||||
"""
|
||||
if anchor_price is None or last_offered_price is None:
|
||||
return AnchoringSampleType.EXCLUDED.value
|
||||
if is_done and bid_price is not None and bid_price <= anchor_price:
|
||||
return AnchoringSampleType.BID_SUCCESS.value
|
||||
return AnchoringSampleType.BID_FAIL.value
|
||||
|
||||
|
||||
def evaluate_pending(
|
||||
rate_before: int,
|
||||
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
|
||||
supplier_type: int, # SMALLINT 코드 1/2/3
|
||||
) -> int | None:
|
||||
"""누적 전량 평가. §4.4
|
||||
반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월)
|
||||
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
|
||||
"""
|
||||
n = len(sample_types)
|
||||
if n < SAMPLE_THRESHOLD:
|
||||
return None
|
||||
|
||||
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
|
||||
delta = DELTA_PERMILLE[supplier_type]
|
||||
|
||||
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
|
||||
if success * 10 >= n * 6:
|
||||
adjusted = rate_before + delta
|
||||
elif success * 10 < n * 3: # r < 0.30
|
||||
adjusted = rate_before - delta
|
||||
else: # 0.30 ≤ r < 0.60
|
||||
adjusted = rate_before
|
||||
|
||||
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted))
|
||||
|
||||
|
||||
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int:
|
||||
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
|
||||
if latest_adjusted_rate is not None:
|
||||
return latest_adjusted_rate
|
||||
return get_base_rate_permille(bracket_index)
|
||||
139
schedules/anchoring/tests/conftest.py
Normal file
139
schedules/anchoring/tests/conftest.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""통합 테스트 픽스처 — 실제 Postgres 필요(로컬 dev DB), 없으면 자동 스킵.
|
||||
|
||||
컨벤션(backend 와 동일): 전용 행을 시드하고 테스트 후 직접 정리한다.
|
||||
Redis 는 초기화하지 않는다 — 클라이언트 None → get None(DB 폴백)/set no-op 로 무Redis 실행.
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from anchoring import db as adb
|
||||
from anchoring.config import load_config
|
||||
|
||||
CFG = load_config()
|
||||
_SCHEMA_SQL = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||
|
||||
|
||||
def _db_available() -> bool:
|
||||
import asyncpg
|
||||
|
||||
async def _check():
|
||||
conn = await asyncpg.connect(
|
||||
host=CFG.db.host, port=CFG.db.port, user=CFG.db.user,
|
||||
password=CFG.db.password, database=CFG.db.name, timeout=2,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
try:
|
||||
asyncio.run(_check())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
DB_OK = _db_available()
|
||||
requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db) 미가용 — 통합 테스트 스킵")
|
||||
|
||||
|
||||
def _schema_statements() -> list[str]:
|
||||
"""schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
|
||||
lines = [
|
||||
line for line in _SCHEMA_SQL.read_text().splitlines()
|
||||
if not line.startswith("\\") and not line.strip().startswith("--")
|
||||
]
|
||||
return [s.strip() for s in "\n".join(lines).split(";") if s.strip()]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_ready():
|
||||
"""테스트별 엔진(이벤트 루프 수명 일치) + 스키마 멱등 적용."""
|
||||
adb.init_engine(CFG)
|
||||
async with adb.session_scope() as s:
|
||||
for stmt in _schema_statements():
|
||||
await s.execute(text(stmt))
|
||||
yield
|
||||
await adb.dispose_engine()
|
||||
|
||||
|
||||
class Seeder:
|
||||
"""전용 시드 생성 + 정리. 한 인스턴스 = 한 회사(테넌트)."""
|
||||
|
||||
def __init__(self):
|
||||
self.company_id = uuid.uuid4()
|
||||
self.user_id = uuid.uuid4()
|
||||
self.item_id = uuid.uuid4()
|
||||
self.quotation_ids: list = []
|
||||
self._item_created = False
|
||||
|
||||
async def _ensure_item(self, db):
|
||||
if self._item_created:
|
||||
return
|
||||
await db.execute(text(
|
||||
"INSERT INTO partner.items (item_id, company_id, user_id, name) "
|
||||
"VALUES (:iid, :cid, :uid, 'anchoring-it-test')"
|
||||
), {"iid": self.item_id, "cid": self.company_id, "uid": self.user_id})
|
||||
self._item_created = True
|
||||
|
||||
async def seed_session(
|
||||
self, db, *,
|
||||
supplier_type=1, target_price=30_000, anchor_price=29_700, rate=10,
|
||||
status=3, bid_price=None, last_offered_price=..., qt_type=1,
|
||||
):
|
||||
"""종료 재협상 세션 1건 시드. 반환: session_id.
|
||||
|
||||
last_offered_price 기본값은 bid_price(가격 흔적 = 투찰가). None 을 명시하면 가격 흔적 없는 세션.
|
||||
"""
|
||||
if last_offered_price is ...:
|
||||
last_offered_price = bid_price
|
||||
await self._ensure_item(db)
|
||||
qt_id = uuid.uuid4()
|
||||
self.quotation_ids.append(qt_id)
|
||||
await db.execute(text(
|
||||
"INSERT INTO quotation.quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, "
|
||||
" start_time, end_time, supplier_type) "
|
||||
"VALUES (:qid, :uid, :sid, :vid, 'anchoring-it-test', :num, :qtype, 1, 3, now(), now(), :stype)"
|
||||
), {
|
||||
"qid": qt_id, "uid": self.user_id, "sid": uuid.uuid4(), "vid": uuid.uuid4(),
|
||||
"num": f"AT{uuid.uuid4().hex[:12]}", "qtype": qt_type, "stype": supplier_type,
|
||||
})
|
||||
session_id = uuid.uuid4()
|
||||
await db.execute(text(
|
||||
"INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
" target_price, target_anchoring_price, anchor_rate_permille, last_offered_price, "
|
||||
" status, bid_price, end_time) "
|
||||
"VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :rate, :lop, :status, :bid, now())"
|
||||
), {
|
||||
"sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": uuid.uuid4(),
|
||||
"qtype": qt_type, "tp": target_price, "ap": anchor_price, "rate": rate,
|
||||
"lop": last_offered_price, "status": status, "bid": bid_price,
|
||||
})
|
||||
return session_id
|
||||
|
||||
async def cleanup(self, db):
|
||||
await db.execute(text(
|
||||
"DELETE FROM anchoring.rate_adjustments WHERE company_id = :cid"
|
||||
), {"cid": self.company_id})
|
||||
if self.quotation_ids:
|
||||
await db.execute(
|
||||
text("DELETE FROM negotiation.sessions WHERE quotation_id = ANY(:qids)"),
|
||||
{"qids": self.quotation_ids},
|
||||
)
|
||||
await db.execute(
|
||||
text("DELETE FROM quotation.quotations WHERE qt_id = ANY(:qids)"),
|
||||
{"qids": self.quotation_ids},
|
||||
)
|
||||
await db.execute(text("DELETE FROM partner.items WHERE item_id = :iid"), {"iid": self.item_id})
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeder(db_ready):
|
||||
s = Seeder()
|
||||
yield s
|
||||
async with adb.session_scope() as db:
|
||||
await s.cleanup(db)
|
||||
224
schedules/anchoring/tests/test_batch.py
Normal file
224
schedules/anchoring/tests/test_batch.py
Normal file
@ -0,0 +1,224 @@
|
||||
"""배치 통합 테스트 (스펙 §11.5 — 실제 Postgres, Redis 없음(무Redis 폴백 경로)).
|
||||
|
||||
실행: cd schedules/anchoring && PYTHONPATH=src .venv/bin/python -m pytest tests/test_batch.py -q
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from anchoring import db as adb
|
||||
from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch
|
||||
from anchoring.models import RateAdjustment, Session
|
||||
from anchoring.reader import get_anchor_rate
|
||||
from conftest import requires_db
|
||||
|
||||
pytestmark = requires_db
|
||||
|
||||
|
||||
async def _run_batch(*seeders):
|
||||
"""테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다."""
|
||||
return await run_evaluation_batch(force=True, company_ids=[s.company_id for s in seeders])
|
||||
|
||||
# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket 12 ("3만 원대" 칸)
|
||||
BRACKET = 12
|
||||
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
|
||||
FAIL_BID = 29_999 # > anchor → BID_FAIL
|
||||
|
||||
|
||||
async def _adjustments(db, seeder):
|
||||
stmt = (
|
||||
select(RateAdjustment)
|
||||
.where(RateAdjustment.company_id == seeder.company_id)
|
||||
.order_by(RateAdjustment.id)
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().all()
|
||||
|
||||
|
||||
async def _marks(db, session_ids):
|
||||
stmt = select(Session.session_id, Session.anchoring_adjustment_id).where(Session.session_id.in_(session_ids))
|
||||
return dict((await db.execute(stmt)).all())
|
||||
|
||||
|
||||
async def _seed_mixed(db, seeder, success: int, fail: int, **kw):
|
||||
ids = []
|
||||
for _ in range(success):
|
||||
ids.append(await seeder.seed_session(db, bid_price=SUCCESS_BID, **kw))
|
||||
for _ in range(fail):
|
||||
ids.append(await seeder.seed_session(db, bid_price=FAIL_BID, **kw))
|
||||
return ids
|
||||
|
||||
|
||||
# ── §11.5: 13건 전량 평가 + 멱등 (실패 3종 혼합) + 로그 규약 ──
|
||||
async def test_full_cycle_and_idempotency(seeder, caplog):
|
||||
async with adb.session_scope() as db:
|
||||
ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등)
|
||||
for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패
|
||||
ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offered_price=FAIL_BID))
|
||||
# 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오)
|
||||
ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID))
|
||||
# 합계 13건, 성공 8 → r≈0.615 → +20
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="anchoring"):
|
||||
result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id])
|
||||
|
||||
# 로그 규약: run_id 태그 + 회사별 grep 가능한 칸별 조정 라인 + 회사요약 라인
|
||||
assert result["run_id"]
|
||||
tagged = [m for m in caplog.messages if f"[batch {result['run_id']}]" in m]
|
||||
assert any(f"조정 company={seeder.company_id}" in m and "10‰→30‰" in m for m in tagged)
|
||||
assert any(f"회사요약 company={seeder.company_id}" in m and "평가=1" in m for m in tagged)
|
||||
|
||||
async with adb.session_scope() as db:
|
||||
adjustments = await _adjustments(db, seeder)
|
||||
assert len(adjustments) == 1
|
||||
adj = adjustments[0]
|
||||
assert (adj.nego_count, adj.success_count) == (13, 8)
|
||||
assert (adj.anchor_rate_before, adj.anchor_rate_after) == (10, 30)
|
||||
assert sorted(adj.consumed_session_ids) == sorted(str(i) for i in ids)
|
||||
marks = await _marks(db, ids)
|
||||
assert all(v == adj.id for v in marks.values()) # 13건 모두 소비 마킹
|
||||
|
||||
# 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건
|
||||
await _run_batch(seeder)
|
||||
async with adb.session_scope() as db:
|
||||
assert len(await _adjustments(db, seeder)) == 1
|
||||
|
||||
# 조회용 뷰 — rate_history(이전→새 값 리스트업) / current_rates(칸별 현재값)
|
||||
hist = (await db.execute(text(
|
||||
"SELECT anchor_rate_before, anchor_rate_after, delta_permille, success_rate "
|
||||
"FROM anchoring.rate_history WHERE company_id = :c"), {"c": seeder.company_id})).one()
|
||||
assert (hist.anchor_rate_before, hist.anchor_rate_after, hist.delta_permille) == (10, 30, 20)
|
||||
assert float(hist.success_rate) == 0.615
|
||||
cur = (await db.execute(text(
|
||||
"SELECT anchor_rate_permille FROM anchoring.current_rates "
|
||||
"WHERE company_id = :c AND supplier_type = 1 AND price_bracket_index = :b"),
|
||||
{"c": seeder.company_id, "b": BRACKET})).scalar_one()
|
||||
assert cur == 30
|
||||
|
||||
|
||||
# ── §11.5: 이월(7건 스킵 → 누적 13건 단일 평가) ──────────
|
||||
async def test_carryover(seeder):
|
||||
async with adb.session_scope() as db:
|
||||
first = await _seed_mixed(db, seeder, success=5, fail=2) # 7건 < 10
|
||||
|
||||
await _run_batch(seeder)
|
||||
async with adb.session_scope() as db:
|
||||
assert await _adjustments(db, seeder) == []
|
||||
marks = await _marks(db, first)
|
||||
assert all(v is None for v in marks.values()) # 마킹 없음 = 이월
|
||||
|
||||
second = await _seed_mixed(db, seeder, success=3, fail=3) # 누적 13건 (8S/5F)
|
||||
|
||||
await _run_batch(seeder)
|
||||
async with adb.session_scope() as db:
|
||||
adjustments = await _adjustments(db, seeder)
|
||||
assert len(adjustments) == 1
|
||||
assert adjustments[0].nego_count == 13 # 4주치 전량 1회 평가
|
||||
assert adjustments[0].anchor_rate_after == 30
|
||||
marks = await _marks(db, first + second)
|
||||
assert all(v == adjustments[0].id for v in marks.values())
|
||||
|
||||
|
||||
# ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ──
|
||||
async def test_company_isolation_and_reader(seeder):
|
||||
async with adb.session_scope() as db:
|
||||
await _seed_mixed(db, seeder, success=10, fail=0, supplier_type=1) # 유통 → +20
|
||||
await _seed_mixed(db, seeder, success=10, fail=0, supplier_type=2) # 제조 → +10 (δ 스왑 가드)
|
||||
|
||||
await _run_batch(seeder)
|
||||
|
||||
other_company = uuid.uuid4()
|
||||
async with adb.session_scope() as db:
|
||||
adjustments = await _adjustments(db, seeder)
|
||||
by_type = {a.supplier_type: a.anchor_rate_after for a in adjustments}
|
||||
assert by_type == {1: 30, 2: 20}
|
||||
# 조정된 칸은 새 rate, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값
|
||||
assert await get_anchor_rate(db, seeder.company_id, 1, BRACKET) == 30
|
||||
assert await get_anchor_rate(db, other_company, 1, BRACKET) == 10
|
||||
|
||||
|
||||
# ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ──
|
||||
async def test_excluded_and_unsampleable(seeder):
|
||||
async with adb.session_scope() as db:
|
||||
# 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED
|
||||
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None) for _ in range(8)]
|
||||
excluded += [await seeder.seed_session(db, status=4, last_offered_price=None) for _ in range(7)]
|
||||
valid = await _seed_mixed(db, seeder, success=5, fail=0) # 유효 5 < 10
|
||||
untyped = [await seeder.seed_session(db, supplier_type=0, bid_price=SUCCESS_BID)] # 칸 구성 불가
|
||||
untyped.append(await seeder.seed_session(db, supplier_type=None, bid_price=SUCCESS_BID)) # NULL 도 동일(§13-6)
|
||||
|
||||
await _run_batch(seeder)
|
||||
|
||||
async with adb.session_scope() as db:
|
||||
assert await _adjustments(db, seeder) == [] # 유효 5 < 10 → 평가 없음
|
||||
marks = await _marks(db, excluded + untyped)
|
||||
assert all(v == 0 for v in marks.values()) # 제외 확정 마킹(재스캔 방지)
|
||||
marks = await _marks(db, valid)
|
||||
assert all(v is None for v in marks.values()) # 유효 표본은 이월
|
||||
|
||||
|
||||
# ── 개정 1: 마킹 rowcount ≠ n → 조정 INSERT 포함 전체 롤백 ──
|
||||
async def test_marking_conflict_rolls_back(seeder):
|
||||
async with adb.session_scope() as db:
|
||||
ids = await _seed_mixed(db, seeder, success=10, fail=0)
|
||||
# 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다
|
||||
await db.execute(text(
|
||||
"UPDATE negotiation.sessions SET anchoring_adjustment_id = 999999 WHERE session_id = :sid"
|
||||
), {"sid": ids[0]})
|
||||
|
||||
samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도
|
||||
try:
|
||||
await _evaluate_cell(seeder.company_id, 1, BRACKET, samples)
|
||||
raised = False
|
||||
except MarkingConflictError:
|
||||
raised = True
|
||||
assert raised
|
||||
|
||||
async with adb.session_scope() as db:
|
||||
assert await _adjustments(db, seeder) == [] # 롤백 — 이중 조정 없음
|
||||
marks = await _marks(db, ids[1:])
|
||||
assert all(v is None for v in marks.values()) # 나머지 9건 마킹도 롤백
|
||||
|
||||
|
||||
# ── 무증상 고장 감지: 가격 제시 흔적 0% → WARN ────────────
|
||||
async def test_priced_rate_zero_warns(seeder, caplog):
|
||||
async with adb.session_scope() as db:
|
||||
for _ in range(3): # 전부 가격 흔적 없는 종료 → priced_rate 0
|
||||
await seeder.seed_session(db, status=5, last_offered_price=None)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="anchoring"):
|
||||
await run_evaluation_batch(force=True, company_ids=[seeder.company_id])
|
||||
assert any("가격 제시 흔적 0%" in m for m in caplog.messages)
|
||||
|
||||
|
||||
# ── dry-run: 판정·예상 조정만 로그, DB 무변경 ─────────────
|
||||
async def test_dry_run_changes_nothing(seeder, caplog):
|
||||
async with adb.session_scope() as db:
|
||||
ids = await _seed_mixed(db, seeder, success=10, fail=0)
|
||||
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None)]
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="anchoring"):
|
||||
result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
|
||||
|
||||
assert result["status"] == "dry_run" and result["evaluated_cells"] == 1
|
||||
assert any("조정예정" in m and f"company={seeder.company_id}" in m for m in caplog.messages)
|
||||
async with adb.session_scope() as db:
|
||||
assert await _adjustments(db, seeder) == [] # INSERT 없음
|
||||
marks = await _marks(db, ids + excluded)
|
||||
assert all(v is None for v in marks.values()) # 마킹 없음(제외 포함)
|
||||
|
||||
# 이어서 실제 실행하면 그대로 반영된다 (dry-run 이 상태를 소비하지 않았음을 증명)
|
||||
await _run_batch(seeder)
|
||||
async with adb.session_scope() as db:
|
||||
assert len(await _adjustments(db, seeder)) == 1
|
||||
|
||||
|
||||
# ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ────
|
||||
async def test_snapshot_mismatch_warns(seeder, caplog):
|
||||
async with adb.session_scope() as db:
|
||||
# rate 10‰ 기준 정수식 anchor 는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호
|
||||
await seeder.seed_session(db, rate=10, anchor_price=29_000, bid_price=28_000)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="anchoring"):
|
||||
await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
|
||||
assert any("박제 정합 불일치 1건" in m for m in caplog.messages)
|
||||
143
schedules/anchoring/tests/test_core.py
Normal file
143
schedules/anchoring/tests/test_core.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""순수 로직 골든 테스트 (스펙 §11.1~11.4, 외부 의존성 없음).
|
||||
|
||||
실행: cd schedules/anchoring && PYTHONPATH=src python -m pytest tests/test_core.py -q
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table
|
||||
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS, AnchoringSampleType
|
||||
from anchoring.batch import is_evaluation_week
|
||||
from anchoring.service import (
|
||||
calc_anchor_price,
|
||||
calc_bracket_index,
|
||||
evaluate_pending,
|
||||
get_current_rate,
|
||||
judge_sample_type,
|
||||
)
|
||||
|
||||
S = AnchoringSampleType.BID_SUCCESS.value
|
||||
F = AnchoringSampleType.BID_FAIL.value
|
||||
E = AnchoringSampleType.EXCLUDED.value
|
||||
|
||||
|
||||
# ── §11.1 앵커링가 계산 (내림 검증) ──────────────────────
|
||||
def test_calc_anchor_price_floor():
|
||||
assert calc_anchor_price(30_000, 200) == 24_000
|
||||
assert calc_anchor_price(26_706, 10) == 26_438 # 26,438.94 → 내림
|
||||
assert calc_anchor_price(29_999, 15) == 29_549 # 29,549.015 → 내림
|
||||
assert calc_anchor_price(0, 10) == 0
|
||||
|
||||
|
||||
# ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ──
|
||||
def test_bracket_index():
|
||||
assert calc_bracket_index(0) == 0 # [0, 1,000) 통일 칸
|
||||
assert calc_bracket_index(999) == 0
|
||||
assert calc_bracket_index(1_000) == 1 # 경계는 상위 구간
|
||||
assert calc_bracket_index(1_999) == 1 # 1천 원대
|
||||
assert calc_bracket_index(9_999) == 9 # 9천 원대
|
||||
assert calc_bracket_index(10_000) == 10 # 1만 원대 진입
|
||||
assert calc_bracket_index(30_000) == 12 # 3만 원대
|
||||
assert calc_bracket_index(99_999) == 18 # 9만 원대
|
||||
assert calc_bracket_index(150_000) == 19 # 10만 원대
|
||||
assert calc_bracket_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
|
||||
assert calc_bracket_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
|
||||
assert calc_bracket_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
|
||||
|
||||
|
||||
def test_ladder_shape():
|
||||
"""사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억."""
|
||||
assert BRACKET_COUNT == 46
|
||||
assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000
|
||||
assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS))
|
||||
assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계
|
||||
|
||||
|
||||
# ── §2 정적 테이블 로드·검증 ─────────────────────────────
|
||||
def test_base_table_load_and_values():
|
||||
load_base_table()
|
||||
assert get_base_rate_permille(0) == 10
|
||||
assert get_base_rate_permille(45) == 10
|
||||
|
||||
|
||||
def _rows():
|
||||
return [
|
||||
{"idx": i + 1, "upper_bound": ub, "anchoring_value": 0.01}
|
||||
for i, ub in enumerate(UPPER_BOUNDS)
|
||||
]
|
||||
|
||||
|
||||
def test_base_table_validate_ok():
|
||||
rates = _validate(_rows())
|
||||
assert len(rates) == BRACKET_COUNT and set(rates) == {10}
|
||||
|
||||
|
||||
def test_base_table_validate_rejects_bad():
|
||||
with pytest.raises(BaseTableError): # 행 수 부족
|
||||
_validate(_rows()[:-1])
|
||||
rows = _rows()
|
||||
rows[5]["idx"] = 999 # idx 불연속
|
||||
with pytest.raises(BaseTableError):
|
||||
_validate(rows)
|
||||
rows = _rows()
|
||||
rows[-1]["upper_bound"] = 100_002_000 # 사다리 불일치(마지막은 정확히 1억)
|
||||
with pytest.raises(BaseTableError):
|
||||
_validate(rows)
|
||||
rows = _rows()
|
||||
rows[0]["anchoring_value"] = 0.5 # 값 범위(0.01~0.20) 밖
|
||||
with pytest.raises(BaseTableError):
|
||||
_validate(rows)
|
||||
|
||||
|
||||
# ── §11.3 누적 전량 평가 (유통 코드1, δ=20, before=10) ────
|
||||
def _pending(success: int, fail: int) -> list[int]:
|
||||
return [S] * success + [F] * fail
|
||||
|
||||
|
||||
def test_evaluate_pending_distribution():
|
||||
assert evaluate_pending(10, _pending(8, 5), 1) == 30 # 13건 r≈0.615 → +20
|
||||
assert evaluate_pending(10, _pending(7, 6), 1) == 10 # r≈0.538 → 유지
|
||||
assert evaluate_pending(10, _pending(3, 10), 1) == 10 # r≈0.231 → −20, 하한 clamp
|
||||
assert evaluate_pending(10, _pending(6, 4), 1) == 30 # r=0.60 정확히 → 경계 포함 +20
|
||||
assert evaluate_pending(10, _pending(3, 7), 1) == 10 # r=0.30 정확히 → 유지
|
||||
assert evaluate_pending(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월)
|
||||
|
||||
|
||||
def test_evaluate_pending_delta_swap_guard():
|
||||
"""δ 스왑 가드 (MUST): 코드 2=제조=±10, 3=총판=±15."""
|
||||
all_success = [S] * 10
|
||||
assert evaluate_pending(10, all_success, 2) == 20 # 제조 +10
|
||||
assert evaluate_pending(10, all_success, 3) == 25 # 총판 +15
|
||||
all_fail = [F] * 10
|
||||
assert evaluate_pending(100, all_fail, 2) == 90 # 제조 −10
|
||||
assert evaluate_pending(100, all_fail, 3) == 85 # 총판 −15
|
||||
|
||||
|
||||
def test_evaluate_pending_clamp_upper():
|
||||
assert evaluate_pending(200, _pending(9, 1), 1) == 200 # 상한 clamp — 조정 레코드는 호출측이 INSERT
|
||||
|
||||
|
||||
# ── §11.4 파생 판정 ("가격 흔적" 기준) ────────────────────
|
||||
def test_judge_sample_type():
|
||||
# judge_sample_type(is_done, bid_price, last_offered_price, anchor_price)
|
||||
assert judge_sample_type(True, 24_000, 24_000, 24_000) == S # 같아도 성공
|
||||
assert judge_sample_type(True, 24_001, 24_001, 24_000) == F # 앵커 초과 합의(와일드카드 등)
|
||||
assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 결렬(REJECTED)
|
||||
assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)
|
||||
assert judge_sample_type(False, None, None, 24_000) == E # 가격 흔적 없음(미참여·무가격 이탈)
|
||||
assert judge_sample_type(True, 24_000, 24_000, None) == E # anchor 박제 없음 → 제외
|
||||
|
||||
|
||||
# ── §4.5 현재 값 조회 ────────────────────────────────────
|
||||
def test_get_current_rate():
|
||||
assert get_current_rate(70, 0) == 70
|
||||
assert get_current_rate(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
|
||||
|
||||
|
||||
# ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ──────────
|
||||
def test_is_evaluation_week():
|
||||
assert datetime(2026, 7, 4).isocalendar().week == 27 # 홀수 주 토요일
|
||||
assert is_evaluation_week(datetime(2026, 7, 4)) is False
|
||||
assert datetime(2026, 7, 11).isocalendar().week == 28 # 짝수 주 토요일
|
||||
assert is_evaluation_week(datetime(2026, 7, 11)) is True
|
||||
Loading…
Reference in New Issue
Block a user