diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index a331aa7..befef90 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -30,7 +30,7 @@ class supplier_users(MAIN_BASE): status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive role = Column(SmallInteger, nullable=False, server_default=text("1")) # 권한: 1=user, 2=manager created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -53,7 +53,7 @@ class suppliers(MAIN_BASE): manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 priority = Column(String(10), nullable=True) # 우선순위 (고객사별 문자열 값 가능) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -90,7 +90,7 @@ class items(MAIN_BASE): selling_price = Column(BigInteger, nullable=True) category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -111,7 +111,10 @@ class sessions(MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) + target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지 + anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지 + last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적" + anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용 status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 @@ -120,7 +123,7 @@ class sessions(MAIN_BASE): reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원) reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -157,12 +160,12 @@ class quotations(MAIN_BASE): equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부 equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 class quotation_settings(MAIN_BASE): - # quotation.quotation_settings (견적 설정). 앵커링값(anchoring_value) 조회용 — agent RL state 입력. + # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.target_anchoring_price 박제값). @staticmethod def DBType(): return DBType.QUOTATION.value @@ -173,10 +176,10 @@ class quotation_settings(MAIN_BASE): qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 견적 설정 식별자(PK) user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id) target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율 - anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value)) + anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — 구 방식 앵커 비율(현행 앵커 산출에는 미사용 — schedules/anchoring 참조) card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -200,7 +203,7 @@ class chats(MAIN_BASE): card_type = Column(SmallInteger, nullable=True) # 카드 유형: 1=nego_card, 2=wild_card meta = Column(JSONB, nullable=True) # 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -220,5 +223,5 @@ class supplier_user_tokens(MAIN_BASE): issued_at = Column(DateTime(timezone=True), nullable=False) # 발급 시각 expired_at = Column(DateTime(timezone=True), nullable=False) # 만료 시각 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) - updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 diff --git a/backend/common/enums.py b/backend/common/enums.py index 314905c..cd241d2 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -81,10 +81,6 @@ class DBWRType(Enum): DB_WRITE = 2 -# ============================================================ -# 도메인 코드값. 스키마는 SMALLINT 정수 코드(1부터)로 두고, 의미 매핑은 여기 enum 으로 한다. -# (postgres-init/01-schema.sql: "코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다") -# ============================================================ class AccountStatus(Enum): """계정 상태 코드. company.users / supplier.supplier_users 의 status 컬럼.""" @@ -117,9 +113,7 @@ class QtType(Enum): class SessionStatus(Enum): - """협상 세션 진행 상태 코드. negotiation.sessions.status. - ⚠️ 세션을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다. - """ + """협상 세션 진행 상태 코드. negotiation.sessions.status""" CREATED = 1 # 협상생성 IN_PROGRESS = 2 # 협상중 @@ -129,9 +123,7 @@ class SessionStatus(Enum): class QuotationStatus(Enum): - """견적 진행 상태 코드. quotation.quotations.status. - ⚠️ 견적을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다. - """ + """견적 진행 상태 코드. quotation.quotations.status """ CREATED = 1 # 견적생성 IN_PROGRESS = 2 # 견적진행중 @@ -139,18 +131,14 @@ class QuotationStatus(Enum): class ChatSender(Enum): - """채팅 발신자 코드. negotiation.chats.sender. - BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다. - """ + """채팅 발신자 코드. negotiation.chats.sender """ - BOT = 1 # 갑(바이어/agent) — bot 메시지 - USER = 2 # 공급사(을) — user 입력 + BOT = 1 # 공급사(갑) — bot 메시지 + USER = 2 # 협력사(을) — user 입력 class DeliveryType(Enum): - """배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type. - 재견적(CM) 협상의 '배송형태선택' 단계 라벨과 1:1 (SHARED_ENUMS §6, negodata 정의 채택). - """ + """배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type """ SUPPLIER = 1 # 협력사배송 COURIER = 2 # 지정택배배송 diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index c10b2ab..4c8978b 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import chats, items, sessions -from common.enums import ErrorType +from common.enums import ErrorType, SessionStatus from common.logger import LOG @@ -43,6 +43,10 @@ class IChatCRUD(ABC): ) -> ErrorType: pass + @abstractmethod + async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: + pass + class ChatCRUD(IChatCRUD): async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: @@ -121,7 +125,30 @@ class ChatCRUD(IChatCRUD): values["reject_reason"] = reject_reason[:255] if reject_price is not None: values["reject_price"] = reject_price - query = update(sessions).where(sessions.session_id == session_id).values(**values) + # 진행중일 때만 전이 — negodata 일괄마감/중복 전송 경합이 종료된 세션을 되살리지 못하게 가드 + query = ( + update(sessions) + .where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value) + .values(**values) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: + """협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출. + + 진행 중엔 매 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서 + "가격을 한 번이라도 써낸 협상"을 가르는 기준값(NULL=가격 흔적 없음 → 집계 제외). + status 가드: 일괄마감 등으로 이미 종료된 세션의 흔적을 사후에 바꾸지 못하게 한다(판정 결정성 보호). + """ + try: + query = ( + update(sessions) + .where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value) + .values(last_offered_price=price) + ) return await DB_SESSION_MNG.add(cdb, query) except Exception as ex: LOG.e_no_callstack(ex) diff --git a/backend/router/router.py b/backend/router/router.py index fd1e71c..57bd7e1 100644 --- a/backend/router/router.py +++ b/backend/router/router.py @@ -11,7 +11,7 @@ from common.utils.gtime import GTime from config.server_configs import web_server_config import router.v1.auth.account import router.v1.negotiation.session -import router.v1.negotiation.chat +import router.v1.chat.chat API_SERVER_START_TIME = GTime.UTCStr() @@ -58,4 +58,4 @@ async def healthz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) app.include_router(router.v1.negotiation.session.router) -app.include_router(router.v1.negotiation.chat.router) +app.include_router(router.v1.chat.chat.router) diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index bd21a32..687efe2 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -1,3 +1,5 @@ +from pydantic import Field + from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -7,28 +9,28 @@ class AuthProtocol(WebPacketProtocol): class Req_Login(AuthProtocol): - id: str = "" - pw: str = "" + id: str = Field("", description="로그인 ID") + pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시·검증)") class Res_Login(Res_WebPacketProtocol): - su_id: str = "" - name: str = "" # 유저 개인 이름 - supplier_id: str = "" # 소속 공급사(partner.suppliers) - supplier_name: str = "" # 공급사명 - role: int = 0 - access_token: str = "" - refresh_token: str = "" + su_id: str = Field("", description="유저 식별자(uuid)") + name: str = Field("", description="유저 개인 이름") + supplier_id: str = Field("", description="소속 공급사 uuid(partner.suppliers)") + supplier_name: str = Field("", description="공급사명") + role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)") + access_token: str = Field("", description="액세스 토큰(JWT)") + refresh_token: str = Field("", description="리프레시 토큰(JWT)") class Req_CreateAccount(AuthProtocol): - supplier_id: str = "" # 소속 공급사(partner.suppliers.supplier_id) - id: str = "" # 로그인 ID - pw: str = "" - name: str = "" - email: str = "" - contact_number: str = "" - role: int = 1 # 1=user, 2=manager (UserRole) + supplier_id: str = Field("", max_length=36, description="소속 공급사 uuid(partner.suppliers.supplier_id)") + id: str = Field("", max_length=20, description="로그인 ID") + pw: str = Field("", description="비밀번호(평문, 서버에서 bcrypt 해시)") + name: str = Field("", max_length=50, description="이름") + email: str = Field("", max_length=255, description="이메일") + contact_number: str = Field("", max_length=20, description="연락처") + role: int = Field(1, description="권한 코드 1=user, 2=manager (UserRole)") class Res_CreateAccount(Res_WebPacketProtocol): @@ -36,16 +38,16 @@ class Res_CreateAccount(Res_WebPacketProtocol): class Res_RefreshToken(Res_WebPacketProtocol): - access_token: str = "" + access_token: str = Field("", description="재발급된 액세스 토큰(JWT)") class Res_Me(Res_WebPacketProtocol): - su_id: str = "" - id: str = "" - name: str = "" - supplier_id: str = "" - supplier_name: str = "" - role: int = 0 + su_id: str = Field("", description="유저 식별자(uuid)") + id: str = Field("", description="로그인 ID") + name: str = Field("", description="유저 개인 이름") + supplier_id: str = Field("", description="소속 공급사 uuid") + supplier_name: str = Field("", description="공급사명") + role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)") class Res_Logout(Res_WebPacketProtocol): diff --git a/backend/router/v1/negotiation/chat.py b/backend/router/v1/chat/chat.py similarity index 87% rename from backend/router/v1/negotiation/chat.py rename to backend/router/v1/chat/chat.py index bf632d3..d0cad93 100644 --- a/backend/router/v1/negotiation/chat.py +++ b/backend/router/v1/chat/chat.py @@ -4,9 +4,10 @@ from fastapi.security import HTTPAuthorizationCredentials from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security from services.chat_service import ChatService -from .chat_protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend +from .protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend -router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}}) +# URL 은 협상 세션의 하위 리소스라 prefix 는 /v1/negotiation 유지(파일만 chat/ 로 분리). +router = APIRouter(prefix="/v1/negotiation", tags=["Chat"], responses={404: {"description": "Not found"}}) @router.get( diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/chat/protocol.py similarity index 67% rename from backend/router/v1/negotiation/chat_protocol.py rename to backend/router/v1/chat/protocol.py index 5fd30cb..d944fa2 100644 --- a/backend/router/v1/negotiation/chat_protocol.py +++ b/backend/router/v1/chat/protocol.py @@ -9,6 +9,8 @@ summary(요약카드 데이터)는 backend 가 비즈니스 데이터로 조립 from typing import Optional +from pydantic import Field + from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -43,25 +45,25 @@ class ChatMessage(WebPacketProtocol): chat_id: str = "" session_id: str = "" seq: int = 0 - sender: int = 0 # ChatSender 코드 + sender: int = Field(0, description="발신자 코드 (ChatSender: 1=BOT, 2=USER)") script: str = "" - user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price + user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price") step: str = "" - display_step: str = "" # agent client_step - next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type - next_input_type: Optional[list[str]] = None # 다음 입력 선택지 + display_step: str = Field("", description="agent client_step (표시용 단계)") + next_input_mode: Optional[str] = Field(None, description="다음 입력 모드: confirm|yes_no|percent|price|delivery_type") + next_input_type: Optional[list[str]] = Field(None, description="다음 입력 선택지(버튼 라벨)") chat_end: bool = False - indicator_value: Optional[float] = None # 협상 지표(1~99). agent 가 가격협상 턴에 내려주면 표시. - bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator - summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐 + indicator_value: Optional[float] = Field(None, description="협상 지표(1~99). 가격협상 턴에 표시") + bot_chat_type: Optional[str] = Field(None, description="폼 종류: summaryRSP|summaryCM|rejectRSP|rejectCM|indicator") + summary: Optional[ChatSummary] = Field(None, description="summaryRSP/summaryCM 일 때만 채워짐") # 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용) class Res_ChatInit(Res_WebPacketProtocol): session_id: str = "" - session_status: int = 0 # SessionStatus 코드 + session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)") quotation_id: str = "" - quotation_end_time: str = "" # ISO 8601 (마감 시각) + quotation_end_time: str = Field("", description="견적 마감 시각 (ISO 8601, 타이머용)") quotation_memo: str = "" item_id: str = "" item_name: str = "" @@ -84,8 +86,8 @@ class Res_ChatMessages(Res_WebPacketProtocol): # 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열. class Req_ChatSend(WebPacketProtocol): - user_input_type: Optional[str] = None # text|percent|price - user_input: str = "" + user_input_type: Optional[str] = Field(None, description="유저 입력 종류: text|percent|price") + user_input: str = Field("", description="버튼 선택 텍스트 또는 가격/퍼센트 문자열") # append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피) diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 4687b46..af2ad66 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -1,13 +1,15 @@ +from pydantic import Field + from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol # 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다. class ListItem(WebPacketProtocol): session_id: str = "" - session_status: int = 0 # SessionStatus 코드 - qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적) + session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)") + qt_type: int = Field(0, description="견적 종류 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적)") qt_number: str = "" - qt_end_time: str = "" # ISO 8601 (마감 시각) + qt_end_time: str = Field("", description="견적 마감 시각 (ISO 8601)") item_code: str = "" item_name: str = "" model_name: str = "" @@ -22,12 +24,12 @@ class Res_SessionList(Res_WebPacketProtocol): class Res_Participate(Res_WebPacketProtocol): - session_id: str = "" # 참여 성공한 세션 (채팅 진입용) + session_id: str = Field("", description="참여 성공한 세션 uuid (채팅 진입용)") class Req_Reject(WebPacketProtocol): - reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력) + reject_reason: str = Field("", max_length=255, description="거부 사유 (단종/품절 프리셋 라벨 또는 직접 입력)") class Res_Reject(Res_WebPacketProtocol): - session_id: str = "" # 거부 처리된 세션 + session_id: str = Field("", description="거부 처리된 세션 uuid") diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py index 6c1435d..409e047 100644 --- a/backend/services/agent_client.py +++ b/backend/services/agent_client.py @@ -43,7 +43,7 @@ class AgentChatContext: tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id rq_type: str = "재협상" # 재협상 | 재견적 target_price: int = 0 # 갑 목표 매입가(원) - anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산. + anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.target_anchoring_price. item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용. # 핸드오프 #4: agent 의 RL 상태(state) 계산 입력. # partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 4a947c9..cad5a00 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -18,63 +18,25 @@ from fastapi import Depends from sqlalchemy import func, select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import chats, items, quotation_settings, quotations, sessions, suppliers +from common.database.model.models import chats, items, quotations, sessions, suppliers from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.chat_crud import ChatCRUD, IChatCRUD from crud.session_crud import ISessionCRUD, SessionCRUD -from router.v1.negotiation.chat_protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend +from router.v1.chat.protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend from services.agent_client import AgentChatContext, IAgentClient, get_agent_client from services.auth_service import AuthService -# 종료 스텝 → 프론트 폼 종류(bot_chat_type). -# 폼 접미사: RSP=재협상(renegotiation), CM=재견적(requote). qt_type 1=재협상, 2=재견적. -# 협상완료/결과안내 → 요약카드, 협상실패 → 합의불가(거부) 폼. +# 종료 step → 프론트 폼 종류(bot_chat_type). RSP=재협상, CM=재견적. _SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"} _REJECT_STEPS = {"협상실패"} - -def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]: - if not step: - return None - is_reneg = qt_type == 1 # 1=재협상(RSP), 그 외(2)=재견적(CM) - if step in _SUMMARY_STEPS: - return "summaryRSP" if is_reneg else "summaryCM" - if step in _REJECT_STEPS: - return "rejectRSP" if is_reneg else "rejectCM" - return None - - -# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다. +# 가격 허용 범위 배수(목표가 기준). 벗어나면 CHAT_PRICE_OUT_OF_RANGE. PRICE_FLOOR_RATIO = 0.3 PRICE_CEIL_RATIO = 1.7 -def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool: - """직전 봇이 요구한 입력 모드(meta.input_mode)와 이번 유저 입력의 '타입'이 정합한지 검사. - 불일치(예: price 단계인데 버튼/텍스트, yes_no 단계인데 가격/퍼센트 숫자)면 False - → agent 로 넘기지 않고 CHAT_INPUT_MODE_MISMATCH(제자리걸음/오진행 방지). - 직전 봇 메시지/모드가 없으면(제약 없음) True. - - 주의: 버튼 선택형(confirm/yes_no/delivery_type)에서 '선택지 텍스트 일치'까지는 강제하지 않는다. - - 프론트 버튼은 항상 올바른 라벨을 보내고, 거부 폼 등은 자유 텍스트(사유)를 보내기 때문. - - 타입(가격/퍼센트 숫자) 오입력만 막아도 실제 stuck/오진행 케이스는 차단된다. - """ - if not last_meta: - return True - mode = last_meta.get("input_mode") - if not mode: - return True - if mode == "price": - return user_input_type == "price" - if mode == "percent": - return user_input_type == "percent" - if mode in ("confirm", "yes_no", "delivery_type"): - # 버튼 선택형 단계에 가격/퍼센트 '숫자 입력'이 오면 오입력 → 차단. 그 외 텍스트는 허용. - return user_input_type not in ("price", "percent") - return True - class ChatService: def __init__( @@ -89,6 +51,97 @@ class ChatService: self.chat_crud = chat_crud self.agent = agent + # ---- 순수 헬퍼/매퍼 (self 불필요, 상단 집약) ---- + @staticmethod + def _parse_price(text: Optional[str]) -> Optional[int]: + if not text: + return None + digits = "".join(ch for ch in text if ch.isdigit()) + return int(digits) if digits else None + + @staticmethod + def _in_price_range(price: int, target_price: Optional[int]) -> bool: + if not target_price: + return price > 0 + return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO) + + @staticmethod + def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool: + # 직전 봇 input_mode 와 이번 입력 타입 정합 검사(숫자 오입력만 차단). + if not last_meta: + return True + mode = last_meta.get("input_mode") + if not mode: + return True + if mode == "price": + return user_input_type == "price" + if mode == "percent": + return user_input_type == "percent" + if mode in ("confirm", "yes_no", "delivery_type"): + return user_input_type not in ("price", "percent") + return True + + @staticmethod + def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]: + # 종료 step → 폼 종류. qt_type 1=재협상(RSP), 그 외=재견적(CM). + if not step: + return None + is_reneg = qt_type == 1 + if step in _SUMMARY_STEPS: + return "summaryRSP" if is_reneg else "summaryCM" + if step in _REJECT_STEPS: + return "rejectRSP" if is_reneg else "rejectCM" + return None + + @staticmethod + def _build_user_chat(sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats: + return chats( + chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, + sender=ChatSender.USER.value, + target_price=int(price) if price is not None else 0, + meta={"script": user_input, "user_input_type": user_input_type}, + ) + + @staticmethod + def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats: + # bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재. + return chats( + chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, + sender=ChatSender.BOT.value, + target_price=int(sess.target_price or 0), + indicator_value=turn.indicator_value, + meta={ + "script": turn.script, "step": turn.step, "client_step": turn.client_step, + "input_mode": turn.input_mode, "input_options": turn.input_options, + "chat_end": turn.chat_end, "card_id": turn.card_id, + "bot_chat_type": bot_chat_type, "summary": summary, + }, + ) + + @staticmethod + def _chat_to_message(c: chats) -> ChatMessage: + # chats 행 → 응답 ChatMessage (DB 재조회 없이). + meta = c.meta or {} + summary_d = meta.get("summary") + return ChatMessage( + chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender, + script=meta.get("script") or "", + user_input_type=meta.get("user_input_type"), + step=meta.get("step") or "", + display_step=meta.get("client_step") or "", + next_input_mode=meta.get("input_mode"), + next_input_type=meta.get("input_options"), + chat_end=bool(meta.get("chat_end", False)), + indicator_value=float(c.indicator_value) if c.indicator_value is not None else None, + bot_chat_type=meta.get("bot_chat_type"), + summary=ChatSummary(**summary_d) if summary_d else None, + ) + + @staticmethod + def _row_to_message(r) -> ChatMessage: + # DB 행(chats) → 응답 ChatMessage. + return ChatService._chat_to_message(r) + # ---- 공통 전처리 ---------------------------------------------------- async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str): """인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None).""" @@ -235,9 +288,9 @@ class ChatService: return res # 가격 입력이면 범위 검증 - price = _parse_price(user_input) if user_input_type == "price" else None + price = self._parse_price(user_input) if user_input_type == "price" else None if user_input_type == "price": - if price is None or not _in_price_range(price, sess.target_price): + if price is None or not self._in_price_range(price, sess.target_price): res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE) return res @@ -254,7 +307,7 @@ class ChatService: res.result.SetResult(ErrorType.CHAT_IN_PROGRESS) return res # ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지). - if not _input_matches_mode(last_meta, user_input, user_input_type): + if not self._input_matches_mode(last_meta, user_input, user_input_type): LOG.i(f"[chat] 입력-모드 불일치 session_id={sess.session_id} " f"mode={last_meta.get('input_mode') if last_meta else None} input_type={user_input_type} input={user_input!r}") res.result.SetResult(ErrorType.CHAT_INPUT_MODE_MISMATCH) @@ -301,7 +354,7 @@ class ChatService: # 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도. # → agent 가 표현 계약을 책임지면 backend 의 step-이름 결합(_resolve_bot_chat_type)은 폴백으로만 남는다. - bot_chat_type = turn.bot_chat_type or _resolve_bot_chat_type(sess.qt_type, turn.step) + bot_chat_type = turn.bot_chat_type or self._resolve_bot_chat_type(sess.qt_type, turn.step) # 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 1회만 조회한다. need_last_price = bot_chat_type in ("summaryRSP", "summaryCM") or (turn.chat_end and turn.outcome == "success") last_price = await self._last_user_price(sess) if need_last_price else None @@ -313,6 +366,10 @@ class ChatService: # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)] + # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. + # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). + if price is not None: + funcs.append(lambda s: self.chat_crud.update_last_offered_price(s, sess.session_id, price)) new_status = sess.status if turn.chat_end: if turn.outcome == "success": @@ -362,8 +419,7 @@ class ChatService: LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험") rq_type = "재협상" if sess.qt_type == 1 else "재견적" target_price = int(sess.target_price or 0) - # 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식. - # anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지). + # 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변. anchor = await self._resolve_anchor_price(sess, target_price) # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. partner_count = await self._count_partners(sess) @@ -378,25 +434,19 @@ class ChatService: ) async def _resolve_anchor_price(self, sess, target_price: int) -> int: - """견적설정 anchoring_value(비율) → anchor=round(target*(1-value)). 실패 시 target*0.99 폴백.""" + """세션에 박제된 앵커가(target_anchoring_price — negodata 가 생성 시 기록)를 그대로 사용. + + 박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다 + ("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다. + 박제가 없으면(데이터 이상 — 사실상 발생하지 않음) 무할인 폴백 anchor=target + WARN. + 이때 박제하지 않으므로 해당 세션은 앵커링 집계에서 자동 제외(EXCLUDED)된다 — 학습 무오염. + """ if not target_price: return 0 - fallback = int(round(target_price * 0.99)) - - def _q(s): - stmt = ( - select(quotation_settings.anchoring_value) - .join(quotations, quotations.qt_setting_id == quotation_settings.qt_setting_id) - .where(quotations.qt_id == sess.quotation_id, quotation_settings.deleted == False) # noqa: E712 - .limit(1) - ) - return DB_SESSION_MNG.execute(s, stmt) - - err_type, rows = await DB_SESSION_MNG.execute_lambda(quotation_settings.DBType(), DBWRType.DB_READ.value, _q) - if err_type != ErrorType.SUCCESS or not rows or rows[0] is None: - LOG.w(f"[chat] anchoring_value 조회 실패 session_id={sess.session_id} — anchor=target*0.99 폴백") - return fallback - return int(round(target_price * (1.0 - float(rows[0])))) + if sess.target_anchoring_price is not None: + return int(sess.target_anchoring_price) + LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외") + return target_price async def _count_partners(self, sess) -> int: """같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백.""" @@ -413,48 +463,6 @@ class ChatService: return 1 return int(rows[0]) - def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats: - return chats( - chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, - sender=ChatSender.USER.value, - target_price=int(price) if price is not None else 0, - meta={"script": user_input, "user_input_type": user_input_type}, - ) - - def _build_bot_chat(self, sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats: - # bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다. - # indicator_value 는 전용 컬럼(분석/replay용)에도 적재. meta 는 순수 표시용. - return chats( - chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, - sender=ChatSender.BOT.value, - target_price=int(sess.target_price or 0), - indicator_value=turn.indicator_value, - meta={ - "script": turn.script, "step": turn.step, "client_step": turn.client_step, - "input_mode": turn.input_mode, "input_options": turn.input_options, - "chat_end": turn.chat_end, "card_id": turn.card_id, - "bot_chat_type": bot_chat_type, "summary": summary, - }, - ) - - def _chat_to_message(self, c: chats) -> ChatMessage: - """방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이).""" - meta = c.meta or {} - summary_d = meta.get("summary") - return ChatMessage( - chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender, - script=meta.get("script") or "", - user_input_type=meta.get("user_input_type"), - step=meta.get("step") or "", - display_step=meta.get("client_step") or "", - next_input_mode=meta.get("input_mode"), - next_input_type=meta.get("input_options"), - chat_end=bool(meta.get("chat_end", False)), - indicator_value=float(c.indicator_value) if c.indicator_value is not None else None, - bot_chat_type=meta.get("bot_chat_type"), - summary=ChatSummary(**summary_d) if summary_d else None, - ) - async def _last_user_price(self, sess) -> Optional[int]: """세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None.""" def _q(s): @@ -554,21 +562,3 @@ class ChatService: supplier_manager_phone=sup_mgr_phone or "", delivery_type=delivery_label, ).model_dump() - - def _row_to_message(self, r) -> ChatMessage: - """DB 행(chats) → 응답 ChatMessage.""" - return self._chat_to_message(r) - - -# ---- 가격 유틸 ---------------------------------------------------------- -def _parse_price(text: Optional[str]) -> Optional[int]: - if not text: - return None - digits = "".join(ch for ch in text if ch.isdigit()) - return int(digits) if digits else None - - -def _in_price_range(price: int, target_price: Optional[int]) -> bool: - if not target_price: - return price > 0 - return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO) diff --git a/backend/tests/test_anchoring_chat.py b/backend/tests/test_anchoring_chat.py new file mode 100644 index 0000000..a9ab911 --- /dev/null +++ b/backend/tests/test_anchoring_chat.py @@ -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 # 가격 흔적 기록은 정상 동작 diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 3cc5011..853d4fe 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -14,6 +14,7 @@ import pytest_asyncio from sqlalchemy import text from services.agent_client import AgentTurn, IAgentClient, get_agent_client +from services.chat_service import ChatService TEST_LOGIN_ID = "pytest_chat_user" TEST_PW = "pytest1234" @@ -358,3 +359,42 @@ async def test_init_marks_expired_created_as_not_participated(client, chat_seed, assert body["result"]["success"] is True assert body["session_status"] == 4 # 미참여로 정리되어 내려옴 assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨 + + +# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ---------- +def test_parse_price(): + assert ChatService._parse_price("530,000원") == 530000 # 콤마/통화기호 제거 + assert ChatService._parse_price("abc") is None + assert ChatService._parse_price("") is None + assert ChatService._parse_price(None) is None + + +def test_in_price_range(): + assert ChatService._in_price_range(100000, 100000) is True + assert ChatService._in_price_range(29000, 100000) is False # floor(0.3) 미만 + assert ChatService._in_price_range(180000, 100000) is False # ceil(1.7) 초과 + assert ChatService._in_price_range(50000, None) is True # 목표가 없으면 양수면 통과 + assert ChatService._in_price_range(0, None) is False + + +def test_input_matches_mode(): + f = ChatService._input_matches_mode + assert f(None, "x", "text") is True # 직전 메타 없음 → 제약 없음 + assert f({}, "x", "price") is True # input_mode 없음 + assert f({"input_mode": "price"}, "100", "price") is True + assert f({"input_mode": "price"}, "예", "text") is False # price 단계에 텍스트 + assert f({"input_mode": "percent"}, "5", "percent") is True + assert f({"input_mode": "yes_no"}, "예", "text") is True + assert f({"input_mode": "yes_no"}, "100", "price") is False # 버튼 단계에 가격 숫자 + assert f({"input_mode": "delivery_type"}, "픽업", "text") is True + + +def test_resolve_bot_chat_type(): + f = ChatService._resolve_bot_chat_type + assert f(1, "협상완료") == "summaryRSP" # 재협상 + assert f(2, "협상완료") == "summaryCM" # 재견적 + assert f(1, "협상실패") == "rejectRSP" + assert f(2, "협상실패") == "rejectCM" + assert f(2, "결과제출") == "summaryCM" + assert f(1, "가격협상") is None # 일반 step + assert f(1, None) is None diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index 5ea6ba2..8c68603 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -97,6 +97,22 @@ async def _participate(client, token, session_id): return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"}) +async def _reject(client, token, session_id, reason): + return await client.post( + f"/v1/negotiation/sessions/{session_id}/reject", + headers={"Authorization": f"Bearer {token}"}, + json={"reject_reason": reason}, + ) + + +async def _session_reject(db_engine, session_id): + async with db_engine.begin() as conn: + return (await conn.execute( + text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"), + {"sid": session_id}, + )).first() + + async def _session_status(db_engine, session_id): async with db_engine.begin() as conn: return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar() @@ -209,3 +225,44 @@ async def test_participate_session_not_found(client, nego_seed): token = await _login_token(client) r = await _participate(client, token, str(uuid.uuid4())) assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND + + +# ---- 거부 ------------------------------------------------------------------- +async def test_reject_success(client, nego_seed, db_engine): + token = await _login_token(client) + sid = nego_seed["sids"]["B"] # 협상중 → 거부 가능 + r = await _reject(client, token, sid, "단종 상품입니다") + assert r.json()["result"]["success"] is True + assert r.json()["session_id"] == str(sid) + status, reason = await _session_reject(db_engine, sid) + assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장 + + +async def test_reject_empty_reason(client, nego_seed): + token = await _login_token(client) + r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음 + assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA + + +async def test_reject_forbidden_other_supplier(client, nego_seed): + token = await _login_token(client) + r = await _reject(client, token, nego_seed["sids"]["X"], "사유") # 타 공급사 세션 + assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN + + +async def test_reject_not_participable_when_done(client, nego_seed): + token = await _login_token(client) + r = await _reject(client, token, nego_seed["sids"]["C"], "사유") # 협상완료(3) → 거부 불가 + assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE + + +async def test_reject_session_not_found(client, nego_seed): + token = await _login_token(client) + r = await _reject(client, token, str(uuid.uuid4()), "사유") + assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND + + +async def test_reject_requires_auth(client, nego_seed): + sid = nego_seed["sids"]["B"] + r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"}) + assert r.status_code in (401, 403) diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index 6ab5c50..8dd1289 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -295,7 +295,10 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions ( qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷) qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N) target_price BIGINT NOT NULL, -- 목표가(원) - target_anchoring_price BIGINT NULL, -- 앵커링가(원) + target_anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조) + anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제 + last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적" + anchoring_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부) bid_price BIGINT NULL, -- 입찰가(원) bid_at TIMESTAMPTZ NULL, -- 입찰 시각 diff --git a/postgres-init/04-alter.sql b/postgres-init/04-alter.sql index 2afaf64..a2087c5 100644 --- a/postgres-init/04-alter.sql +++ b/postgres-init/04-alter.sql @@ -51,3 +51,12 @@ CREATE TABLE IF NOT EXISTS company.notifications ( CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id); CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL; CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id); + +-- ───────────────────────────────────────────────────────────── +-- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종 +-- (신규 DB 는 01-schema.sql 에 반영됨. anchoring 스키마 자체(rate_adjustments·뷰)는 +-- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.) +ALTER TABLE negotiation.sessions + ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제 + ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적) + ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹 diff --git a/schedules/anchoring/.gitignore b/schedules/anchoring/.gitignore new file mode 100644 index 0000000..dcbd02c --- /dev/null +++ b/schedules/anchoring/.gitignore @@ -0,0 +1,5 @@ +config.toml +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/schedules/anchoring/Dockerfile b/schedules/anchoring/Dockerfile new file mode 100644 index 0000000..7e7f156 --- /dev/null +++ b/schedules/anchoring/Dockerfile @@ -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"] diff --git a/schedules/anchoring/README.md b/schedules/anchoring/README.md new file mode 100644 index 0000000..d8bb94f --- /dev/null +++ b/schedules/anchoring/README.md @@ -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=" # 특정 회사만 (조정·회사요약 라인) +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` 하나뿐. diff --git a/schedules/anchoring/TODO.md b/schedules/anchoring/TODO.md new file mode 100644 index 0000000..6c8cf70 --- /dev/null +++ b/schedules/anchoring/TODO.md @@ -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` 설정을 적용할 것. diff --git a/schedules/anchoring/config.toml.example b/schedules/anchoring/config.toml.example new file mode 100644 index 0000000..a88dfbd --- /dev/null +++ b/schedules/anchoring/config.toml.example @@ -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 = "" diff --git a/schedules/anchoring/docker-compose.yml b/schedules/anchoring/docker-compose.yml new file mode 100644 index 0000000..de276b1 --- /dev/null +++ b/schedules/anchoring/docker-compose.yml @@ -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 diff --git a/schedules/anchoring/docs/개발용.md b/schedules/anchoring/docs/개발용.md new file mode 100644 index 0000000..81d0f47 --- /dev/null +++ b/schedules/anchoring/docs/개발용.md @@ -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=`). +- 라인 구성: 시작(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` 읽기 제거(컬럼은 유지). 구현 완료 | diff --git a/schedules/anchoring/docs/기획용.md b/schedules/anchoring/docs/기획용.md new file mode 100644 index 0000000..bb8715d --- /dev/null +++ b/schedules/anchoring/docs/기획용.md @@ -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 확정)을 눈높이만 달리해 기술한 것입니다. diff --git a/schedules/anchoring/docs/운영및유지보수.md b/schedules/anchoring/docs/운영및유지보수.md new file mode 100644 index 0000000..d326f71 --- /dev/null +++ b/schedules/anchoring/docs/운영및유지보수.md @@ -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 -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=" # 특정 회사만 (조정 + 회사요약) +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 = '' +ORDER BY adjustment_id; + +-- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%) +SELECT * FROM anchoring.current_rates +WHERE company_id = ''; + +-- ② 특정 조정(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 = +); + +-- ③ 특정 협상이 어느 조정에 채점됐나 +SELECT anchoring_adjustment_id FROM negotiation.sessions WHERE session_id = ''; +-- 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` 는 영구 보존 대상). diff --git a/schedules/anchoring/docs/워크플로우.md b/schedules/anchoring/docs/워크플로우.md new file mode 100644 index 0000000..33a97a3 --- /dev/null +++ b/schedules/anchoring/docs/워크플로우.md @@ -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. 사람이 수동으로 값을 바꿀 수 있나요?** +현재 정책에서는 없습니다. 값은 오직 협상 결과 데이터로만 움직이고, 모든 변경은 이력으로 추적 가능합니다. diff --git a/schedules/anchoring/docs/인수인계.md b/schedules/anchoring/docs/인수인계.md new file mode 100644 index 0000000..3869878 --- /dev/null +++ b/schedules/anchoring/docs/인수인계.md @@ -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 담당(민헌)에게. diff --git a/schedules/anchoring/pytest.ini b/schedules/anchoring/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/schedules/anchoring/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/schedules/anchoring/requirements.txt b/schedules/anchoring/requirements.txt new file mode 100644 index 0000000..fb7484f --- /dev/null +++ b/schedules/anchoring/requirements.txt @@ -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 diff --git a/schedules/anchoring/schema.sql b/schedules/anchoring/schema.sql new file mode 100644 index 0000000..af067d9 --- /dev/null +++ b/schedules/anchoring/schema.sql @@ -0,0 +1,72 @@ +-- ============================================================ +-- anchoring 모듈 DDL (모듈 소유 — postgres-init 에 두지 않는다) +-- 적용: psql -h -U -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; diff --git a/schedules/anchoring/src/anchoring/__init__.py b/schedules/anchoring/src/anchoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedules/anchoring/src/anchoring/base_table.py b/schedules/anchoring/src/anchoring/base_table.py new file mode 100644 index 0000000..3b41b32 --- /dev/null +++ b/schedules/anchoring/src/anchoring/base_table.py @@ -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] diff --git a/schedules/anchoring/src/anchoring/batch.py b/schedules/anchoring/src/anchoring/batch.py new file mode 100644 index 0000000..c24b263 --- /dev/null +++ b/schedules/anchoring/src/anchoring/batch.py @@ -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=`). + """ + 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 상태 점검 필요") diff --git a/schedules/anchoring/src/anchoring/config.py b/schedules/anchoring/src/anchoring/config.py new file mode 100644 index 0000000..e8d0801 --- /dev/null +++ b/schedules/anchoring/src/anchoring/config.py @@ -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 diff --git a/schedules/anchoring/src/anchoring/constants.py b/schedules/anchoring/src/anchoring/constants.py new file mode 100644 index 0000000..bf9abb2 --- /dev/null +++ b/schedules/anchoring/src/anchoring/constants.py @@ -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) — 표본 대상 diff --git a/schedules/anchoring/src/anchoring/db.py b/schedules/anchoring/src/anchoring/db.py new file mode 100644 index 0000000..196f015 --- /dev/null +++ b/schedules/anchoring/src/anchoring/db.py @@ -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 diff --git a/schedules/anchoring/src/anchoring/log.py b/schedules/anchoring/src/anchoring/log.py new file mode 100644 index 0000000..1a5fdf4 --- /dev/null +++ b/schedules/anchoring/src/anchoring/log.py @@ -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 diff --git a/schedules/anchoring/src/anchoring/main.py b/schedules/anchoring/src/anchoring/main.py new file mode 100644 index 0000000..275de9c --- /dev/null +++ b/schedules/anchoring/src/anchoring/main.py @@ -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() diff --git a/schedules/anchoring/src/anchoring/models.py b/schedules/anchoring/src/anchoring/models.py new file mode 100644 index 0000000..9695d71 --- /dev/null +++ b/schedules/anchoring/src/anchoring/models.py @@ -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) diff --git a/schedules/anchoring/src/anchoring/reader.py b/schedules/anchoring/src/anchoring/reader.py new file mode 100644 index 0000000..797f9f2 --- /dev/null +++ b/schedules/anchoring/src/anchoring/reader.py @@ -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 diff --git a/schedules/anchoring/src/anchoring/redis_client.py b/schedules/anchoring/src/anchoring/redis_client.py new file mode 100644 index 0000000..fa33b04 --- /dev/null +++ b/schedules/anchoring/src/anchoring/redis_client.py @@ -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 diff --git a/schedules/anchoring/src/anchoring/resources/anchoring_base.json b/schedules/anchoring/src/anchoring/resources/anchoring_base.json new file mode 100644 index 0000000..9ae1e85 --- /dev/null +++ b/schedules/anchoring/src/anchoring/resources/anchoring_base.json @@ -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 } +] diff --git a/schedules/anchoring/src/anchoring/scheduler.py b/schedules/anchoring/src/anchoring/scheduler.py new file mode 100644 index 0000000..d6e495d --- /dev/null +++ b/schedules/anchoring/src/anchoring/scheduler.py @@ -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 diff --git a/schedules/anchoring/src/anchoring/service.py b/schedules/anchoring/src/anchoring/service.py new file mode 100644 index 0000000..b54b5f7 --- /dev/null +++ b/schedules/anchoring/src/anchoring/service.py @@ -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) diff --git a/schedules/anchoring/tests/conftest.py b/schedules/anchoring/tests/conftest.py new file mode 100644 index 0000000..e2d62c2 --- /dev/null +++ b/schedules/anchoring/tests/conftest.py @@ -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) diff --git a/schedules/anchoring/tests/test_batch.py b/schedules/anchoring/tests/test_batch.py new file mode 100644 index 0000000..74d17d0 --- /dev/null +++ b/schedules/anchoring/tests/test_batch.py @@ -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) diff --git a/schedules/anchoring/tests/test_core.py b/schedules/anchoring/tests/test_core.py new file mode 100644 index 0000000..cc03cea --- /dev/null +++ b/schedules/anchoring/tests/test_core.py @@ -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