Merge branch 'refactor/backend'

This commit is contained in:
민헌 2026-07-02 21:41:14 +09:00
commit d0b6e785af
46 changed files with 3793 additions and 203 deletions

View File

@ -30,7 +30,7 @@ class supplier_users(MAIN_BASE):
status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive 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 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) 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")) # 소프트 삭제 여부 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) # 담당자 연락처 manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
priority = Column(String(10), 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) 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")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
@ -90,7 +90,7 @@ class items(MAIN_BASE):
selling_price = Column(BigInteger, nullable=True) selling_price = Column(BigInteger, nullable=True)
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자 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) 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")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
@ -111,7 +111,10 @@ class sessions(MAIN_BASE):
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원) 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 코드) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), 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_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
reject_delivery_type = Column(SmallInteger, 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) 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")) # 소프트 삭제 여부 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_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON) 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) 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")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class quotation_settings(MAIN_BASE): class quotation_settings(MAIN_BASE):
# quotation.quotation_settings (견적 설정). 앵커링값(anchoring_value) 조회용 — agent RL state 입력. # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.target_anchoring_price 박제값).
@staticmethod @staticmethod
def DBType(): def DBType():
return DBType.QUOTATION.value 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) 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) user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율 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")) # 협상 내 협상카드 사용 횟수 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) 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")) # 소프트 삭제 여부 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 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) 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) 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")) # 소프트 삭제 여부 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) # 발급 시각 issued_at = Column(DateTime(timezone=True), nullable=False) # 발급 시각
expired_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) 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")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부

View File

@ -81,10 +81,6 @@ class DBWRType(Enum):
DB_WRITE = 2 DB_WRITE = 2
# ============================================================
# 도메인 코드값. 스키마는 SMALLINT 정수 코드(1부터)로 두고, 의미 매핑은 여기 enum 으로 한다.
# (postgres-init/01-schema.sql: "코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다")
# ============================================================
class AccountStatus(Enum): class AccountStatus(Enum):
"""계정 상태 코드. company.users / supplier.supplier_users 의 status 컬럼.""" """계정 상태 코드. company.users / supplier.supplier_users 의 status 컬럼."""
@ -117,9 +113,7 @@ class QtType(Enum):
class SessionStatus(Enum): class SessionStatus(Enum):
"""협상 세션 진행 상태 코드. negotiation.sessions.status. """협상 세션 진행 상태 코드. negotiation.sessions.status"""
⚠️ 세션을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
"""
CREATED = 1 # 협상생성 CREATED = 1 # 협상생성
IN_PROGRESS = 2 # 협상중 IN_PROGRESS = 2 # 협상중
@ -129,9 +123,7 @@ class SessionStatus(Enum):
class QuotationStatus(Enum): class QuotationStatus(Enum):
"""견적 진행 상태 코드. quotation.quotations.status. """견적 진행 상태 코드. quotation.quotations.status """
⚠️ 견적을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
"""
CREATED = 1 # 견적생성 CREATED = 1 # 견적생성
IN_PROGRESS = 2 # 견적진행중 IN_PROGRESS = 2 # 견적진행중
@ -139,18 +131,14 @@ class QuotationStatus(Enum):
class ChatSender(Enum): class ChatSender(Enum):
"""채팅 발신자 코드. negotiation.chats.sender. """채팅 발신자 코드. negotiation.chats.sender """
BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다.
"""
BOT = 1 # 갑(바이어/agent) — bot 메시지 BOT = 1 # 공급사(갑) — bot 메시지
USER = 2 # 공급사(을) — user 입력 USER = 2 # 협력사(을) — user 입력
class DeliveryType(Enum): class DeliveryType(Enum):
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type. """배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type """
재견적(CM) 협상의 '배송형태선택' 단계 라벨과 1:1 (SHARED_ENUMS §6, negodata 정의 채택).
"""
SUPPLIER = 1 # 협력사배송 SUPPLIER = 1 # 협력사배송
COURIER = 2 # 지정택배배송 COURIER = 2 # 지정택배배송

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, sessions 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 from common.logger import LOG
@ -43,6 +43,10 @@ class IChatCRUD(ABC):
) -> ErrorType: ) -> ErrorType:
pass pass
@abstractmethod
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
pass
class ChatCRUD(IChatCRUD): class ChatCRUD(IChatCRUD):
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: 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] values["reject_reason"] = reject_reason[:255]
if reject_price is not None: if reject_price is not None:
values["reject_price"] = reject_price 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) return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)

View File

@ -11,7 +11,7 @@ from common.utils.gtime import GTime
from config.server_configs import web_server_config from config.server_configs import web_server_config
import router.v1.auth.account import router.v1.auth.account
import router.v1.negotiation.session import router.v1.negotiation.session
import router.v1.negotiation.chat import router.v1.chat.chat
API_SERVER_START_TIME = GTime.UTCStr() API_SERVER_START_TIME = GTime.UTCStr()
@ -58,4 +58,4 @@ async def healthz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include. # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router) app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.negotiation.session.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)

View File

@ -1,3 +1,5 @@
from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -7,28 +9,28 @@ class AuthProtocol(WebPacketProtocol):
class Req_Login(AuthProtocol): class Req_Login(AuthProtocol):
id: str = "" id: str = Field("", description="로그인 ID")
pw: str = "" pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시·검증)")
class Res_Login(Res_WebPacketProtocol): class Res_Login(Res_WebPacketProtocol):
su_id: str = "" su_id: str = Field("", description="유저 식별자(uuid)")
name: str = "" # 유저 개인 이름 name: str = Field("", description="유저 개인 이름")
supplier_id: str = "" # 소속 공급사(partner.suppliers) supplier_id: str = Field("", description="소속 공급사 uuid(partner.suppliers)")
supplier_name: str = "" # 공급사명 supplier_name: str = Field("", description="공급사명")
role: int = 0 role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
access_token: str = "" access_token: str = Field("", description="액세스 토큰(JWT)")
refresh_token: str = "" refresh_token: str = Field("", description="리프레시 토큰(JWT)")
class Req_CreateAccount(AuthProtocol): class Req_CreateAccount(AuthProtocol):
supplier_id: str = "" # 소속 공급사(partner.suppliers.supplier_id) supplier_id: str = Field("", max_length=36, description="소속 공급사 uuid(partner.suppliers.supplier_id)")
id: str = "" # 로그인 ID id: str = Field("", max_length=20, description="로그인 ID")
pw: str = "" pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시)")
name: str = "" name: str = Field("", max_length=50, description="이름")
email: str = "" email: str = Field("", max_length=255, description="이메일")
contact_number: str = "" contact_number: str = Field("", max_length=20, description="연락처")
role: int = 1 # 1=user, 2=manager (UserRole) role: int = Field(1, description="권한 코드 1=user, 2=manager (UserRole)")
class Res_CreateAccount(Res_WebPacketProtocol): class Res_CreateAccount(Res_WebPacketProtocol):
@ -36,16 +38,16 @@ class Res_CreateAccount(Res_WebPacketProtocol):
class Res_RefreshToken(Res_WebPacketProtocol): class Res_RefreshToken(Res_WebPacketProtocol):
access_token: str = "" access_token: str = Field("", description="재발급된 액세스 토큰(JWT)")
class Res_Me(Res_WebPacketProtocol): class Res_Me(Res_WebPacketProtocol):
su_id: str = "" su_id: str = Field("", description="유저 식별자(uuid)")
id: str = "" id: str = Field("", description="로그인 ID")
name: str = "" name: str = Field("", description="유저 개인 이름")
supplier_id: str = "" supplier_id: str = Field("", description="소속 공급사 uuid")
supplier_name: str = "" supplier_name: str = Field("", description="공급사명")
role: int = 0 role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
class Res_Logout(Res_WebPacketProtocol): class Res_Logout(Res_WebPacketProtocol):

View File

@ -4,9 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
from services.chat_service import ChatService 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( @router.get(

View File

@ -9,6 +9,8 @@ summary(요약카드 데이터)는 backend 가 비즈니스 데이터로 조립
from typing import Optional from typing import Optional
from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -43,25 +45,25 @@ class ChatMessage(WebPacketProtocol):
chat_id: str = "" chat_id: str = ""
session_id: str = "" session_id: str = ""
seq: int = 0 seq: int = 0
sender: int = 0 # ChatSender 코드 sender: int = Field(0, description="발신자 코드 (ChatSender: 1=BOT, 2=USER)")
script: str = "" script: str = ""
user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price")
step: str = "" step: str = ""
display_step: str = "" # agent client_step display_step: str = Field("", description="agent client_step (표시용 단계)")
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type next_input_mode: Optional[str] = Field(None, description="다음 입력 모드: confirm|yes_no|percent|price|delivery_type")
next_input_type: Optional[list[str]] = None # 다음 입력 선택지 next_input_type: Optional[list[str]] = Field(None, description="다음 입력 선택지(버튼 라벨)")
chat_end: bool = False chat_end: bool = False
indicator_value: Optional[float] = None # 협상 지표(1~99). agent 가 가격협상 턴에 내려주면 표시. indicator_value: Optional[float] = Field(None, description="협상 지표(1~99). 가격협상 턴에 표시")
bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator bot_chat_type: Optional[str] = Field(None, description="폼 종류: summaryRSP|summaryCM|rejectRSP|rejectCM|indicator")
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐 summary: Optional[ChatSummary] = Field(None, description="summaryRSP/summaryCM 일 때만 채워짐")
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용) # 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
class Res_ChatInit(Res_WebPacketProtocol): class Res_ChatInit(Res_WebPacketProtocol):
session_id: str = "" session_id: str = ""
session_status: int = 0 # SessionStatus 코드 session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
quotation_id: str = "" quotation_id: str = ""
quotation_end_time: str = "" # ISO 8601 (마감 시각) quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)")
quotation_memo: str = "" quotation_memo: str = ""
item_id: str = "" item_id: str = ""
item_name: str = "" item_name: str = ""
@ -84,8 +86,8 @@ class Res_ChatMessages(Res_WebPacketProtocol):
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열. # 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
class Req_ChatSend(WebPacketProtocol): class Req_ChatSend(WebPacketProtocol):
user_input_type: Optional[str] = None # text|percent|price user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price")
user_input: str = "" user_input: str = Field("", description="버튼 선택 텍스트 또는 가격/퍼센트 문자열")
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피) # append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)

View File

@ -1,13 +1,15 @@
from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
# 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다. # 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다.
class ListItem(WebPacketProtocol): class ListItem(WebPacketProtocol):
session_id: str = "" session_id: str = ""
session_status: int = 0 # SessionStatus 코드 session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적) qt_type: int = Field(0, description="견적 종류 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적)")
qt_number: str = "" qt_number: str = ""
qt_end_time: str = "" # ISO 8601 (마감 시각) qt_end_time: str = Field("", description="견적 마감 시각 (ISO 8601)")
item_code: str = "" item_code: str = ""
item_name: str = "" item_name: str = ""
model_name: str = "" model_name: str = ""
@ -22,12 +24,12 @@ class Res_SessionList(Res_WebPacketProtocol):
class Res_Participate(Res_WebPacketProtocol): class Res_Participate(Res_WebPacketProtocol):
session_id: str = "" # 참여 성공한 세션 (채팅 진입용) session_id: str = Field("", description="참여 성공한 세션 uuid (채팅 진입용)")
class Req_Reject(WebPacketProtocol): class Req_Reject(WebPacketProtocol):
reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력) reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)")
class Res_Reject(Res_WebPacketProtocol): class Res_Reject(Res_WebPacketProtocol):
session_id: str = "" # 거부 처리된 세션 session_id: str = Field("", description="거부 처리된 세션 uuid")

View File

@ -43,7 +43,7 @@ class AgentChatContext:
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적 rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원) 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 가격협상_확인 인하율 산출용. item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력. # 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어 # partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어

View File

@ -18,63 +18,25 @@ from fastapi import Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from common.database.db_session_manager import DB_SESSION_MNG 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.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD 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.agent_client import AgentChatContext, IAgentClient, get_agent_client
from services.auth_service import AuthService from services.auth_service import AuthService
# 종료 스텝 → 프론트 폼 종류(bot_chat_type). # 종료 step → 프론트 폼 종류(bot_chat_type). RSP=재협상, CM=재견적.
# 폼 접미사: RSP=재협상(renegotiation), CM=재견적(requote). qt_type 1=재협상, 2=재견적.
# 협상완료/결과안내 → 요약카드, 협상실패 → 합의불가(거부) 폼.
_SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"} _SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"}
_REJECT_STEPS = {"협상실패"} _REJECT_STEPS = {"협상실패"}
# 가격 허용 범위 배수(목표가 기준). 벗어나면 CHAT_PRICE_OUT_OF_RANGE.
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 로 막는다.
PRICE_FLOOR_RATIO = 0.3 PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7 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: class ChatService:
def __init__( def __init__(
@ -89,6 +51,97 @@ class ChatService:
self.chat_crud = chat_crud self.chat_crud = chat_crud
self.agent = agent 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): async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None).""" """인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
@ -235,9 +288,9 @@ class ChatService:
return res 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 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) res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
return res return res
@ -254,7 +307,7 @@ class ChatService:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS) res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res return res
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지). # ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 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} " 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}") 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) res.result.SetResult(ErrorType.CHAT_INPUT_MODE_MISMATCH)
@ -301,7 +354,7 @@ class ChatService:
# 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도. # 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도.
# → agent 가 표현 계약을 책임지면 backend 의 step-이름 결합(_resolve_bot_chat_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회만 조회한다. # 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 1회만 조회한다.
need_last_price = bot_chat_type in ("summaryRSP", "summaryCM") or (turn.chat_end and turn.outcome == "success") 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 last_price = await self._last_user_price(sess) if need_last_price else None
@ -313,6 +366,10 @@ class ChatService:
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) 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)] 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 new_status = sess.status
if turn.chat_end: if turn.chat_end:
if turn.outcome == "success": 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 위험") LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적" rq_type = "재협상" if sess.qt_type == 1 else "재견적"
target_price = int(sess.target_price or 0) target_price = int(sess.target_price or 0)
# 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식. # 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변.
# anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지).
anchor = await self._resolve_anchor_price(sess, target_price) anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess) partner_count = await self._count_partners(sess)
@ -378,25 +434,19 @@ class ChatService:
) )
async def _resolve_anchor_price(self, sess, target_price: int) -> int: 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: if not target_price:
return 0 return 0
fallback = int(round(target_price * 0.99)) if sess.target_anchoring_price is not None:
return int(sess.target_anchoring_price)
def _q(s): LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
stmt = ( return target_price
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]))))
async def _count_partners(self, sess) -> int: async def _count_partners(self, sess) -> int:
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백.""" """같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
@ -413,48 +463,6 @@ class ChatService:
return 1 return 1
return int(rows[0]) 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]: async def _last_user_price(self, sess) -> Optional[int]:
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None.""" """세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
def _q(s): def _q(s):
@ -554,21 +562,3 @@ class ChatService:
supplier_manager_phone=sup_mgr_phone or "", supplier_manager_phone=sup_mgr_phone or "",
delivery_type=delivery_label, delivery_type=delivery_label,
).model_dump() ).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)

View 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 # 가격 흔적 기록은 정상 동작

View File

@ -14,6 +14,7 @@ import pytest_asyncio
from sqlalchemy import text from sqlalchemy import text
from services.agent_client import AgentTurn, IAgentClient, get_agent_client from services.agent_client import AgentTurn, IAgentClient, get_agent_client
from services.chat_service import ChatService
TEST_LOGIN_ID = "pytest_chat_user" TEST_LOGIN_ID = "pytest_chat_user"
TEST_PW = "pytest1234" 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["result"]["success"] is True
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴 assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨 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

View File

@ -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}"}) 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 def _session_status(db_engine, session_id):
async with db_engine.begin() as conn: 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() 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) token = await _login_token(client)
r = await _participate(client, token, str(uuid.uuid4())) r = await _participate(client, token, str(uuid.uuid4()))
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND 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)

View File

@ -295,7 +295,10 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷) 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) 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_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(거부) status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
bid_price BIGINT NULL, -- 입찰가(원) bid_price BIGINT NULL, -- 입찰가(원)
bid_at TIMESTAMPTZ NULL, -- 입찰 시각 bid_at TIMESTAMPTZ NULL, -- 입찰 시각

View File

@ -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_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_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); 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
View File

@ -0,0 +1,5 @@
config.toml
__pycache__/
*.py[cod]
.pytest_cache/
.venv/

View 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"]

View 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` 하나뿐.

View 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` 설정을 적용할 것.

View 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 = ""

View 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

View 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` 읽기 제거(컬럼은 유지). 구현 완료 |

View 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 확정)을 눈높이만 달리해 기술한 것입니다.

View 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` 는 영구 보존 대상).

View 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. 사람이 수동으로 값을 바꿀 수 있나요?**
현재 정책에서는 없습니다. 값은 오직 협상 결과 데이터로만 움직이고, 모든 변경은 이력으로 추적 가능합니다.

View 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 담당(민헌)에게.

View File

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

View 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

View 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;

View 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]

View 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 상태 점검 필요")

View 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

View 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) — 표본 대상

View 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

View 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

View 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()

View 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)

View 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

View 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

View File

@ -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 }
]

View 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

View 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)

View 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)

View 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)

View 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