Merge branch 'main' into feature/negodata

Helped-By: Claude Opus 4.8뀨 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-07-06 16:58:04 +09:00
commit 3176ec4262
55 changed files with 915 additions and 771 deletions

View File

@ -111,10 +111,10 @@ class sessions(MAIN_BASE):
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원) target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지 anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지 anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지
last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적" last_offer_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적"
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용 used_by_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
@ -165,7 +165,7 @@ class quotations(MAIN_BASE):
class quotation_settings(MAIN_BASE): class quotation_settings(MAIN_BASE):
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.target_anchoring_price 박제값). # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값).
@staticmethod @staticmethod
def DBType(): def DBType():
return DBType.QUOTATION.value return DBType.QUOTATION.value

View File

@ -44,7 +44,7 @@ class IChatCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
pass pass
@ -136,7 +136,7 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
"""협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출. """협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출.
진행 중엔 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서 진행 중엔 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서
@ -147,7 +147,7 @@ class ChatCRUD(IChatCRUD):
query = ( query = (
update(sessions) update(sessions)
.where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value) .where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value)
.values(last_offered_price=price) .values(last_offer_price=price)
) )
return await DB_SESSION_MNG.add(cdb, query) return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex: except Exception as ex:

View File

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

View File

@ -369,7 +369,7 @@ class ChatService:
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
if price is not None: if price is not None:
funcs.append(lambda s: self.chat_crud.update_last_offered_price(s, sess.session_id, price)) funcs.append(lambda s: self.chat_crud.update_last_offer_price(s, sess.session_id, price))
new_status = sess.status new_status = sess.status
if turn.chat_end: if turn.chat_end:
if turn.outcome == "success": if turn.outcome == "success":
@ -419,7 +419,7 @@ class ChatService:
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험") LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적" rq_type = "재협상" if sess.qt_type == 1 else "재견적"
target_price = int(sess.target_price or 0) target_price = int(sess.target_price or 0)
# 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변. # 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
anchor = await self._resolve_anchor_price(sess, target_price) anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess) partner_count = await self._count_partners(sess)
@ -434,7 +434,7 @@ class ChatService:
) )
async def _resolve_anchor_price(self, sess, target_price: int) -> int: async def _resolve_anchor_price(self, sess, target_price: int) -> int:
"""세션에 박제된 앵커가(target_anchoring_price — negodata 가 생성 시 기록)를 그대로 사용. """세션에 박제된 앵커가(anchoring_price — negodata 가 생성 시 기록)를 그대로 사용.
박제값 사용이 정상 경로다: 협상 진행 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다 박제값 사용이 정상 경로다: 협상 진행 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다
("제안 당시 값" 판정의 전제 schedules/anchoring/docs/개발용.md §9.2). backend 앵커를 계산하지 않는다. ("제안 당시 값" 판정의 전제 schedules/anchoring/docs/개발용.md §9.2). backend 앵커를 계산하지 않는다.
@ -443,8 +443,8 @@ class ChatService:
""" """
if not target_price: if not target_price:
return 0 return 0
if sess.target_anchoring_price is not None: if sess.anchoring_price is not None:
return int(sess.target_anchoring_price) return int(sess.anchoring_price)
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외") LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
return target_price return target_price

View File

@ -110,7 +110,7 @@ async def anchor_seed(db_engine):
await conn.execute( await conn.execute(
text("INSERT INTO negotiation.sessions " text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " "(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) " " target_price, anchoring_price, anchoring_value, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, 1, :tp, :ap, :rate, 2, now() + interval '2 hours')"), "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, {"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id,
"qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate}, "qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate},
@ -143,7 +143,7 @@ async def _send(client, token, sid, user_input, user_input_type=None):
async def _anchor_columns(db_engine, session_id): async def _anchor_columns(db_engine, session_id):
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
row = (await conn.execute(text( row = (await conn.execute(text(
"SELECT target_anchoring_price, anchor_rate_permille, last_offered_price, bid_price " "SELECT anchoring_price, anchoring_value, last_offer_price, bid_price "
"FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one() "FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one()
return row return row
@ -158,21 +158,21 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch
r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음) r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음)
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시" assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
row = await _anchor_columns(db_engine, sid) row = await _anchor_columns(db_engine, sid)
assert row.last_offered_price is None assert row.last_offer_price is None
assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달 assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복 r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
assert r.json()["message"]["step"] == "기존가격제시" assert r.json()["message"]["step"] == "기존가격제시"
row = await _anchor_columns(db_engine, sid) row = await _anchor_columns(db_engine, sid)
assert row.last_offered_price == 99_500 # 가격 흔적 기록 assert row.last_offer_price == 99_500 # 가격 흔적 기록
# 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offered_price 로 실패 표본이 된다. # 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offer_price 로 실패 표본이 된다.
r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료 r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료
assert r.json()["session_status"] == 3 # DONE assert r.json()["session_status"] == 3 # DONE
row = await _anchor_columns(db_engine, sid) row = await _anchor_columns(db_engine, sid)
assert row.last_offered_price == 98_000 # 마지막 값으로 갱신 assert row.last_offer_price == 98_000 # 마지막 값으로 갱신
assert row.bid_price == 98_000 assert row.bid_price == 98_000
assert (row.target_anchoring_price, row.anchor_rate_permille) == (ANCHOR, 10) # 박제 불변 assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변
# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ── # ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ──
@ -188,6 +188,6 @@ async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료) r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료)
assert r.json()["session_status"] == 3 assert r.json()["session_status"] == 3
row = await _anchor_columns(db_engine, sid) row = await _anchor_columns(db_engine, sid)
assert row.target_anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외) assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
assert row.anchor_rate_permille is None assert row.anchoring_value is None
assert row.last_offered_price == 97_000 # 가격 흔적 기록은 정상 동작 assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작

View File

@ -8,11 +8,13 @@
# negosium 프론트: http://localhost:3300 # negosium 프론트: http://localhost:3300
# negodata 서버: http://localhost:9400/docs # negodata 서버: http://localhost:9400/docs
# agent 서버: http://localhost:9500/docs # agent 서버: http://localhost:9500/docs
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
# #
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다. # DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
# psql -h <host> -p <port> -U <user> -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema) # psql -h <host> -p <port> -U <user> -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema)
# psql -h <host> -p <port> -U <user> -f postgres-init/02-learning-schema.sql (agent learning 스키마) # psql -h <host> -p <port> -U <user> -f postgres-init/02-learning-schema.sql (agent learning 스키마)
# psql -h <host> -p <port> -U <user> -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users) # psql -h <host> -p <port> -U <user> -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users)
# psql -h <host> -p <port> -U <user> -f postgres-init/05-anchoring-schema.sql (anchoring 스키마: adjustments·뷰 — schedules/anchoring 소유)
services: services:
negosium-backend: negosium-backend:
@ -77,3 +79,38 @@ services:
ports: ports:
- "3300:3300" - "3300:3300"
restart: unless-stopped restart: unless-stopped
# 앵커링 값 자동 조정 배치 (negosium_db 공유, 포트 없음 — 상주 스케줄러).
anchoring:
build: ./schedules/anchoring
container_name: anchoring
environment:
APP_ENV: local # config.{APP_ENV}.toml 선택 (local/dev/prod)
DB_HOST: host.docker.internal # 컨테이너→호스트 DB
REDIS_HOST: anchoring-redis
TZ: Asia/Seoul
volumes:
- ./schedules/anchoring/config.local.toml:/app/config.local.toml:ro # 시크릿은 마운트 — up 전에 파일 필요
depends_on:
- anchoring-redis
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging: # 상주 배치 — 장기 운영 디스크 보호
driver: json-file
options:
max-size: "10m"
max-file: "5"
# 앵커링 조회 캐시 (anchoring 전용)
anchoring-redis:
image: redis:7-alpine
container_name: anchoring-redis
ports:
- "127.0.0.1:6380:6379" # 호스트 로컬만 개방 (무인증 Redis). 6380 = 호스트 redis(6379)와 충돌 회피
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"

View File

@ -7,16 +7,16 @@
조정(격주 배치)·표본 판정은 이식 대상이 아니다(schedules/anchoring 서비스 담당). 조정(격주 배치)·표본 판정은 이식 대상이 아니다(schedules/anchoring 서비스 담당).
상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지). 상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지).
""" """
from common.anchoring.base_table import get_base_rate_permille, load_base_table from common.anchoring.base_table import get_base_anchoring_value, load_base_table
from common.anchoring.constants import SAMPLEABLE_SUPPLIER_TYPES from common.anchoring.constants import SAMPLEABLE_SUPPLIER_TYPES
from common.anchoring.reader import fetch_current_rates from common.anchoring.reader import fetch_current_values
from common.anchoring.service import calc_anchor_price, calc_bracket_index from common.anchoring.service import calc_anchoring_price, calc_price_range_index
__all__ = [ __all__ = [
"SAMPLEABLE_SUPPLIER_TYPES", "SAMPLEABLE_SUPPLIER_TYPES",
"calc_anchor_price", "calc_anchoring_price",
"calc_bracket_index", "calc_price_range_index",
"fetch_current_rates", "fetch_current_values",
"get_base_rate_permille", "get_base_anchoring_value",
"load_base_table", "load_base_table",
] ]

View File

@ -7,11 +7,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증
import json import json
from pathlib import Path from pathlib import Path
from common.anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS from common.anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json" _RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
_rates: list[int] | None = None # bracket_index → 시작값(‰) _rates: list[int] | None = None # price_range_index → 시작값(‰)
class BaseTableError(RuntimeError): class BaseTableError(RuntimeError):
@ -23,8 +23,8 @@ def _validate(rows: list) -> list[int]:
규약: 46 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 0.01~0.20. 규약: 46 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 0.01~0.20.
""" """
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT: if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}") raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}")
rates: list[int] = [] rates: list[int] = []
for i, row in enumerate(rows): for i, row in enumerate(rows):
idx = row.get("idx") idx = row.get("idx")
@ -52,8 +52,8 @@ def load_base_table() -> None:
_rates = _validate(rows) _rates = _validate(rows)
def get_base_rate_permille(bracket_index: int) -> int: def get_base_anchoring_value(price_range_index: int) -> int:
"""구간 인덱스 → 시작 앵커링 값(‰).""" """구간 인덱스 → 시작 앵커링 값(‰)."""
if _rates is None: if _rates is None:
load_base_table() load_base_table()
return _rates[bracket_index] return _rates[price_range_index]

View File

@ -6,8 +6,8 @@
""" """
# ── 앵커링 값(정수 천분율 ‰) ────────────────────────────── # ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
ANCHOR_RATE_MIN = 10 # 하한 1% ANCHORING_VALUE_MIN = 10 # 하한 1%
ANCHOR_RATE_MAX = 200 # 상한 20% ANCHORING_VALUE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지 # 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지
# ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ── # ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ──
@ -25,8 +25,8 @@ def _build_upper_bounds() -> tuple:
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개 UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
BRACKET_COUNT = len(UPPER_BOUNDS) # 46 PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45 PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45
# 칸을 구성할 수 있는 협력사 유형 코드 — common.enums.SupplierType 의 유통(1)/제조(2)/총판(3). # 칸을 구성할 수 있는 협력사 유형 코드 — common.enums.SupplierType 의 유통(1)/제조(2)/총판(3).
# 이 외(NONE=0/NULL)는 칸 해석 불가 → 정적 테이블 시작값 사용(배치 집계에서도 자동 제외). # 이 외(NONE=0/NULL)는 칸 해석 불가 → 정적 테이블 시작값 사용(배치 집계에서도 자동 제외).

View File

@ -2,7 +2,7 @@
원본 reader(schedules/anchoring) Redis 캐시를 먼저 보지만, 이식판은 DB 직조회 문장만 쓴다 원본 reader(schedules/anchoring) Redis 캐시를 먼저 보지만, 이식판은 DB 직조회 문장만 쓴다
(2026-07-03 단순화 결정 조회가 견적 생성 시에만 일어나 캐시가 불필요, Redis 의존 제거). (2026-07-03 단순화 결정 조회가 견적 생성 시에만 일어나 캐시가 불필요, Redis 의존 제거).
칸별 최신 조정 rate 모듈 소유 `anchoring.current_rates` 제공하고, 칸별 최신 조정 rate 모듈 소유 `anchoring.current_values` 제공하고,
조정 이력이 없는 칸은 결과에 없으므로 호출측이 정적 테이블 시작값으로 폴백한다. 조정 이력이 없는 칸은 결과에 없으므로 호출측이 정적 테이블 시작값으로 폴백한다.
견적 생성이 앵커 조회 때문에 실패해서는 된다(인수인계.md §1 규칙 6) anchoring 스키마 견적 생성이 앵커 조회 때문에 실패해서는 된다(인수인계.md §1 규칙 6) anchoring 스키마
@ -11,22 +11,22 @@
from sqlalchemy import column, select, table from sqlalchemy import column, select, table
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, SAMPLEABLE_SUPPLIER_TYPES from common.anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, SAMPLEABLE_SUPPLIER_TYPES
from common.logger import LOG from common.logger import LOG
# 모듈 소유 DDL(schedules/anchoring/schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다. # 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다.
_current_rates = table( _current_values = table(
"current_rates", "current_values",
column("company_id"), column("company_id"),
column("price_bracket_index"), column("price_range_index"),
column("anchor_rate_permille"), column("anchoring_value"),
column("supplier_type"), column("supplier_type"),
schema="anchoring", schema="anchoring",
) )
async def fetch_current_rates(db: AsyncSession, company_ids: list, supplier_type: int) -> dict: async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict:
"""칸별 현재 앵커링 값 일괄 조회. {(company_id, bracket_index): rate‰} 반환. """칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환.
supplier_type 견적 단위로 하나뿐이라 키에 넣지 않는다. supplier_type 견적 단위로 하나뿐이라 키에 넣지 않는다.
조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 dict.""" 조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 dict."""
@ -34,22 +34,22 @@ async def fetch_current_rates(db: AsyncSession, company_ids: list, supplier_type
return {} return {}
try: try:
stmt = select( stmt = select(
_current_rates.c.company_id, _current_values.c.company_id,
_current_rates.c.price_bracket_index, _current_values.c.price_range_index,
_current_rates.c.anchor_rate_permille, _current_values.c.anchoring_value,
).where( ).where(
_current_rates.c.company_id.in_(company_ids), _current_values.c.company_id.in_(company_ids),
_current_rates.c.supplier_type == supplier_type, _current_values.c.supplier_type == supplier_type,
) )
rows = (await db.execute(stmt)).all() rows = (await db.execute(stmt)).all()
except Exception as ex: except Exception as ex:
LOG.w(f"[앵커링] current_rates 조회 실패 — 전량 정적 테이블 폴백: {ex}") LOG.w(f"[앵커링] current_values 조회 실패 — 전량 정적 테이블 폴백: {ex}")
return {} return {}
out = {} out = {}
for company_id, bracket_index, rate in rows: for company_id, price_range_index, rate in rows:
if not ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백 if not ANCHORING_VALUE_MIN <= rate <= ANCHORING_VALUE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백
LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={bracket_index} rate={rate}") LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={price_range_index} rate={rate}")
continue continue
out[(company_id, bracket_index)] = rate out[(company_id, price_range_index)] = rate
return out return out

View File

@ -5,17 +5,17 @@
""" """
from bisect import bisect_right from bisect import bisect_right
from common.anchoring.constants import BRACKET_INDEX_MAX, UPPER_BOUNDS from common.anchoring.constants import PRICE_RANGE_INDEX_MAX, UPPER_BOUNDS
def calc_bracket_index(target_price: int) -> int: def calc_price_range_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). 자릿수 계단식 사다리. """목표가 → 가격구간 인덱스(0-기반). 자릿수 계단식 사다리.
좌폐우개 [이전 ub, ub): 가격이 upper_bound 정확히 같으면 다음 . 좌폐우개 [이전 ub, ub): 가격이 upper_bound 정확히 같으면 다음 .
1 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1""" 1 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX) return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int: def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
"""앵커링가 = 목표가 × (1 A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)""" """앵커링가 = 목표가 × (1 A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)"""
return target_price * (1000 - rate_permille) // 1000 return target_price * (1000 - anchoring_value) // 1000

View File

@ -246,8 +246,8 @@ class sessions(MainTableMixin, MAIN_BASE):
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷 qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
target_price = Column(BigInteger, nullable=False) # 목표가(원) target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준) anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(천분율‰) 박제 — 위와 동일 규칙. 나머지 앵커링 컬럼(last_offered_price 등)은 backend/배치 소유라 매핑 안 함 anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함
status = Column(SmallInteger, nullable=False) # SessionStatus 코드 status = Column(SmallInteger, nullable=False) # SessionStatus 코드
bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각

View File

@ -611,13 +611,13 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, target_anchoring_price). 삭제 제외. """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price). 삭제 제외.
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None). 공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name None).
target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).""" target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
try: try:
query = ( query = (
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name, select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
sessions.target_price, sessions.target_anchoring_price) sessions.target_price, sessions.anchoring_price)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
) )

View File

@ -100,7 +100,7 @@ class SessionData(WebPacketProtocol):
qt_round: int qt_round: int
qt_type: QuotationType qt_type: QuotationType
target_price: int target_price: int
target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율) anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000anchoring_value)//1000
status: SessionStatus status: SessionStatus
bid_price: Optional[int] = None bid_price: Optional[int] = None
bid_at: Optional[datetime] = None bid_at: Optional[datetime] = None
@ -205,7 +205,8 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
selling: Optional[int] = None selling: Optional[int] = None
fee: float = 0.0 fee: float = 0.0
margin: float = 0.0 margin: float = 0.0
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
candidates: list[TargetCandidate] = [] candidates: list[TargetCandidate] = []
chosen_basis: Optional[str] = None chosen_basis: Optional[str] = None
target_price: int = 0 target_price: int = 0
target_anchoring_price: Optional[int] = None anchoring_price: Optional[int] = None

View File

@ -114,7 +114,7 @@ VALUES
INSERT INTO negotiation.sessions INSERT INTO negotiation.sessions
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, (session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type,
target_price, status, bid_price, bid_at, end_time, target_price, status, bid_price, bid_at, end_time,
reject_reason, reject_price, reject_delivery_type, target_anchoring_price, email_sent_at, reject_reason, reject_price, reject_delivery_type, anchoring_price, email_sent_at,
created_at, updated_at, deleted) created_at, updated_at, deleted)
VALUES VALUES
-- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000 -- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000

View File

@ -7,10 +7,10 @@ from fastapi import Depends
from common.anchoring import ( from common.anchoring import (
SAMPLEABLE_SUPPLIER_TYPES, SAMPLEABLE_SUPPLIER_TYPES,
calc_anchor_price, calc_anchoring_price,
calc_bracket_index, calc_price_range_index,
fetch_current_rates, fetch_current_values,
get_base_rate_permille, get_base_anchoring_value,
) )
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
@ -186,7 +186,8 @@ class QuotationService:
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands] res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
res.chosen_basis = None if is_inherited else chosen_basis res.chosen_basis = None if is_inherited else chosen_basis
res.target_price = sess.target_price res.target_price = sess.target_price
res.target_anchoring_price = sess.target_anchoring_price res.anchoring_price = sess.anchoring_price
res.anchoring_value = (sess.anchoring_value or 0) / 1000 # 세션 ‰ → 비율(main 프론트 '목표가×(1값)' 표시용)
return res return res
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
@ -415,8 +416,8 @@ class QuotationService:
rates = rates if _err == ErrorType.SUCCESS else {} rates = rates if _err == ErrorType.SUCCESS else {}
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
# 앵커링가는 quotation_settings.anchoring_value 를 더 이상 쓰지 않는다(앵커링 v1.2) — # 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) —
# 칸(회사×협력사유형×가격구간)별 조정 rate 로 계산한다. 아래 세션 생성부 ②. # 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
@ -489,32 +490,32 @@ class QuotationService:
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res return res
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) rate 조회 후 # ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는 # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는
# 정적 테이블 시작값 폴백 — rate 조회 때문에 견적 생성이 실패하지 않는다(규칙 6). # 정적 테이블 시작값 폴백 — 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
_err, item_companies = await DB_SESSION_MNG.execute_lambda( _err, item_companies = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), quotations.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_item_companies(s, item_ids), lambda s: self.quotation_crud.get_item_companies(s, item_ids),
) )
item_companies = item_companies if _err == ErrorType.SUCCESS else {} item_companies = item_companies if _err == ErrorType.SUCCESS else {}
rate_map = {} value_map = {}
if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies: if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies:
rate_map = await DB_SESSION_MNG.execute_lambda( value_map = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), quotations.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: fetch_current_rates(s, list(set(item_companies.values())), supplier_type), lambda s: fetch_current_values(s, list(set(item_companies.values())), supplier_type),
) )
session_objs = [] session_objs = []
for iid in item_ids: for iid in item_ids:
tp = target_prices[iid] tp = target_prices[iid]
bracket = calc_bracket_index(tp) price_range = calc_price_range_index(tp)
company = item_companies.get(iid) company = item_companies.get(iid)
rate = rate_map.get((company, bracket)) if company is not None else None value = value_map.get((company, price_range)) if company is not None else None
if rate is None: if value is None:
rate = get_base_rate_permille(bracket) value = get_base_anchoring_value(price_range)
ap = calc_anchor_price(tp, rate) # 목표가×(1000rate)//1000 — float 곱셈 금지(1원 내림 정확성) ap = calc_anchoring_price(tp, value) # 목표가×(1000value)//1000 — float 곱셈 금지(1원 내림 정확성)
for sid in supplier_ids: for sid in supplier_ids:
session_objs.append( session_objs.append(
sessions( sessions(
@ -526,8 +527,8 @@ class QuotationService:
qt_round=quotation.round, qt_round=quotation.round,
qt_type=quotation.type, qt_type=quotation.type,
target_price=tp, target_price=tp,
target_anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값) anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
anchor_rate_permille=rate, anchoring_value=value,
status=SessionStatus.CREATED.value, status=SessionStatus.CREATED.value,
end_time=quotation.end_time, end_time=quotation.end_time,
) )
@ -645,7 +646,7 @@ class QuotationService:
mid_action = original.mid_action or PriceGateAction.AWARD.value mid_action = original.mid_action or PriceGateAction.AWARD.value
over_action = original.over_action or PriceGateAction.AWARD.value over_action = original.over_action or PriceGateAction.AWARD.value
target = next((r.target_price for r in rows if r.target_price is not None), None) target = next((r.target_price for r in rows if r.target_price is not None), None)
anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None) anchor = next((r.anchoring_price for r in rows if r.anchoring_price is not None), None)
# 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격). # 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
if winner is not None: if winner is not None:
@ -797,7 +798,7 @@ class QuotationService:
qt_round=r.qt_round, qt_round=r.qt_round,
qt_type=r.qt_type, qt_type=r.qt_type,
target_price=r.target_price, target_price=r.target_price,
target_anchoring_price=r.target_anchoring_price, anchoring_price=r.anchoring_price,
status=r.status, status=r.status,
bid_price=r.bid_price, bid_price=r.bid_price,
bid_at=r.bid_at, bid_at=r.bid_at,

View File

@ -89,7 +89,7 @@ async def test_anchor_only_opens_within_target(clean):
status=QuotationStatus.IN_PROGRESS.value, status=QuotationStatus.IN_PROGRESS.value,
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value) mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120, await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120,
target_price=200, target_anchoring_price=80) target_price=200, anchoring_price=80)
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt) outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
@ -161,14 +161,14 @@ async def _seed_quotation(
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None, async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None,
target_price=0, target_anchoring_price=None): target_price=0, anchoring_price=None):
"""세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다.""" """세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다."""
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute( await conn.execute(
text( text(
"INSERT INTO sessions " "INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, target_anchoring_price, status, bid_price, end_time) VALUES " " target_price, anchoring_price, status, bid_price, end_time) VALUES "
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, " "(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
" :target_price, :anchor, :status, :bid_price, :end_time)" " :target_price, :anchor, :status, :bid_price, :end_time)"
), ),
@ -176,7 +176,7 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1, "supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
"qt_type": QuotationType.REQUOTE.value, "status": status, "qt_type": QuotationType.REQUOTE.value, "status": status,
"target_price": target_price, "anchor": target_anchoring_price, "target_price": target_price, "anchor": anchoring_price,
"bid_price": bid_price, "end_time": PAST, "bid_price": bid_price, "end_time": PAST,
}, },
) )

View File

@ -1,29 +1,29 @@
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) rate 로 앵커가를 박제하는지 검증. """앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
이식 명세: schedules/anchoring/docs/인수인계.md §1. 이식 명세: schedules/anchoring/docs/인수인계.md §1.
- 앵커가 = 목표가 × (1000 rate) // 1000 (정수 연산), anchor_rate_permille 동시 박제 - 앵커가 = 목표가 × (1000 anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 정적 테이블 시작값(10) 폴백, - 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 정적 테이블 시작값(10) 폴백,
견적 생성은 실패하지 않는다(규칙 6) 견적 생성은 실패하지 않는다(규칙 6)
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 rate 재계산(규칙 1 상속 폐지) - 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 재계산(규칙 1 상속 폐지)
""" """
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import text from sqlalchemy import text
from common.anchoring import calc_bracket_index from common.anchoring import calc_price_range_index
from common.enums import QuotationType from common.enums import QuotationType
from crud.quotation_crud import QuotationCRUD from crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation_service import QuotationService from services.quotation_service import QuotationService
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게 FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
BASE_RATE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01 BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01
async def test_create_without_anchoring_schema_falls_back_to_base_rate(db_engine, company_id): async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engine, company_id):
"""검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성. """검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성.
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990(시작값), rate=10 박제.""" 기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990(시작값), anchoring_value=10 박제."""
await _drop_anchoring(db_engine) await _drop_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000) item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
@ -32,10 +32,10 @@ async def test_create_without_anchoring_schema_falls_back_to_base_rate(db_engine
assert res.result.success is True assert res.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200 tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
rows = await _session_anchor_rows(db_engine, res.qt_id) rows = await _session_anchor_rows(db_engine, res.qt_id)
assert rows == {item: (tp, tp * (1000 - BASE_RATE) // 1000, BASE_RATE)} assert rows == {item: (tp, tp * (1000 - BASE_VALUE) // 1000, BASE_VALUE)}
async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id): async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id):
"""검증: 한 상품의 칸에만 조정 이력(50‰)을 넣고 상품 2개(다른 가격구간)로 견적 생성. """검증: 한 상품의 칸에만 조정 이력(50‰)을 넣고 상품 2개(다른 가격구간)로 견적 생성.
기대결과: 이력 상품은 50, 무이력 상품은 시작값 10 각각 박제( 단위 조회).""" 기대결과: 이력 상품은 50, 무이력 상품은 시작값 10 각각 박제( 단위 조회)."""
await _reset_anchoring(db_engine) await _reset_anchoring(db_engine)
@ -43,29 +43,29 @@ async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id):
item_miss = await _seed_item(db_engine, company_id, internet_lowest=5_000) # tp 4,610 — 다른 구간 item_miss = await _seed_item(db_engine, company_id, internet_lowest=5_000) # tp 4,610 — 다른 구간
tp_hit = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) tp_hit = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp_hit), rate_after=50) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp_hit), value_after=50)
res = await _create(item_ids=[item_hit, item_miss], supplier_type=1) res = await _create(item_ids=[item_hit, item_miss], supplier_type=1)
assert res.result.success is True assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id) rows = await _session_anchor_rows(db_engine, res.qt_id)
assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50) assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50)
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_RATE) assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE)
async def test_supplier_type_unset_uses_base_rate(db_engine, company_id): async def test_supplier_type_unset_uses_base_value(db_engine, company_id):
"""검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가. """검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가.
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10 박제.""" 기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10 박제."""
await _reset_anchoring(db_engine) await _reset_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000) item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
res = await _create(item_ids=[item], supplier_type=None) res = await _create(item_ids=[item], supplier_type=None)
assert res.result.success is True assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id) rows = await _session_anchor_rows(db_engine, res.qt_id)
assert rows == {item: (tp, tp * 990 // 1000, BASE_RATE)} assert rows == {item: (tp, tp * 990 // 1000, BASE_VALUE)}
async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id): async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id):
@ -79,28 +79,28 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa
assert res1.result.success is True assert res1.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
rows1 = await _session_anchor_rows(db_engine, res1.qt_id) rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
assert rows1 == {item: (tp, tp * 990 // 1000, BASE_RATE)} # 1라운드는 시작값 assert rows1 == {item: (tp, tp * 990 // 1000, BASE_VALUE)} # 1라운드는 시작값
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier]) res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
assert res2.result.success is True assert res2.result.success is True
rows2 = await _session_anchor_rows(db_engine, res2.qt_id) rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 rate assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
def test_bracket_index_golden_vectors(): def test_price_range_index_golden_vectors():
"""검증: 이식된 calc_bracket_index 경계 골든 벡터(자릿수 사다리, 좌폐우개). """검증: 이식된 calc_price_range_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
배치의 박제 정합 감시는 rate앵커가 자기일관만 보므로 브래킷 이식 오류를 잡는다 배치의 박제 정합 감시는 앵커가 자기일관만 보므로 브래킷 이식 오류를 잡는다
벡터가 원본(schedules/anchoring) 어긋나면 이식 오류다( 변경 금지).""" 벡터가 원본(schedules/anchoring) 어긋나면 이식 오류다( 변경 금지)."""
assert calc_bracket_index(0) == 0 # 최하단 통일 칸 [0, 1,000) assert calc_price_range_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
assert calc_bracket_index(999) == 0 assert calc_price_range_index(999) == 0
assert calc_bracket_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개) assert calc_price_range_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
assert calc_bracket_index(9_999) == 9 assert calc_price_range_index(9_999) == 9
assert calc_bracket_index(10_000) == 10 # 자릿수 전환 경계 assert calc_price_range_index(10_000) == 10 # 자릿수 전환 경계
assert calc_bracket_index(99_999_999) == 45 assert calc_price_range_index(99_999_999) == 45
assert calc_bracket_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프 assert calc_price_range_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
assert calc_bracket_index(10**12) == 45 assert calc_price_range_index(10**12) == 45
# ===== 헬퍼 ===== # ===== 헬퍼 =====
@ -140,16 +140,16 @@ async def _seed_item(engine, company_id, *, internet_lowest):
async def _session_anchor_rows(engine, qt_id): async def _session_anchor_rows(engine, qt_id):
"""생성된 견적의 item_id -> (target_price, target_anchoring_price, anchor_rate_permille).""" """생성된 견적의 item_id -> (target_price, anchoring_price, anchoring_value)."""
async with engine.begin() as conn: async with engine.begin() as conn:
rows = (await conn.execute( rows = (await conn.execute(
text("SELECT item_id, target_price, target_anchoring_price, anchor_rate_permille " text("SELECT item_id, target_price, anchoring_price, anchoring_value "
"FROM sessions WHERE quotation_id = :qt"), "FROM sessions WHERE quotation_id = :qt"),
{"qt": qt_id}, {"qt": qt_id},
)).all() )).all()
out = {} out = {}
for item_id, tp, ap, rate in rows: for item_id, tp, ap, value in rows:
assert out.setdefault(item_id, (tp, ap, rate)) == (tp, ap, rate) # 같은 상품 세션끼리 동일 박제 assert out.setdefault(item_id, (tp, ap, value)) == (tp, ap, value) # 같은 상품 세션끼리 동일 박제
return out return out
@ -158,29 +158,29 @@ async def _drop_anchoring(engine):
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE")) await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
# 모듈 소유 DDL(schedules/anchoring/schema.sql)에서 조회 경로에 필요한 부분 발췌. # 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)에서 조회 경로에 필요한 부분 발췌.
# negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다. # negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다.
_ANCHORING_DDL = ( _ANCHORING_DDL = (
"CREATE SCHEMA IF NOT EXISTS anchoring", "CREATE SCHEMA IF NOT EXISTS anchoring",
"""CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments ( """CREATE TABLE IF NOT EXISTS anchoring.adjustments (
id BIGSERIAL PRIMARY KEY, adjustment_id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, company_id uuid NOT NULL,
supplier_type SMALLINT NOT NULL, supplier_type SMALLINT NOT NULL,
price_bracket_index INTEGER NOT NULL, price_range_index INTEGER NOT NULL,
nego_count INTEGER NOT NULL, sample_count INTEGER NOT NULL,
success_count INTEGER NOT NULL, success_count INTEGER NOT NULL,
anchor_rate_before SMALLINT NOT NULL, anchoring_value_before SMALLINT NOT NULL,
anchor_rate_after SMALLINT NOT NULL, anchoring_value_after SMALLINT NOT NULL,
consumed_session_ids JSONB NOT NULL, used_session_ids JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)""", )""",
"""CREATE OR REPLACE VIEW anchoring.current_rates AS """CREATE OR REPLACE VIEW anchoring.current_values AS
SELECT DISTINCT ON (company_id, supplier_type, price_bracket_index) SELECT DISTINCT ON (company_id, supplier_type, price_range_index)
company_id, supplier_type, price_bracket_index, company_id, supplier_type, price_range_index,
anchor_rate_after AS anchor_rate_permille, anchoring_value_after AS anchoring_value,
id AS last_adjustment_id, created_at AS last_adjusted_at adjustment_id AS last_adjustment_id, created_at AS last_adjusted_at
FROM anchoring.rate_adjustments FROM anchoring.adjustments
ORDER BY company_id, supplier_type, price_bracket_index, id DESC""", ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC""",
) )
@ -192,15 +192,15 @@ async def _reset_anchoring(engine):
await conn.execute(text(ddl)) await conn.execute(text(ddl))
async def _seed_adjustment(engine, company_id, *, supplier_type, bracket, rate_after): async def _seed_adjustment(engine, company_id, *, supplier_type, price_range, value_after):
"""칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현).""" """칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현)."""
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute( await conn.execute(
text( text(
"INSERT INTO anchoring.rate_adjustments " "INSERT INTO anchoring.adjustments "
"(company_id, supplier_type, price_bracket_index, nego_count, success_count, " "(company_id, supplier_type, price_range_index, sample_count, success_count, "
" anchor_rate_before, anchor_rate_after, consumed_session_ids) " " anchoring_value_before, anchoring_value_after, used_session_ids) "
"VALUES (:cid, :stype, :bracket, 10, 8, 10, :after, '[]'::jsonb)" "VALUES (:cid, :stype, :price_range, 10, 8, 10, :after, '[]'::jsonb)"
), ),
{"cid": uuid.UUID(company_id), "stype": supplier_type, "bracket": bracket, "after": rate_after}, {"cid": uuid.UUID(company_id), "stype": supplier_type, "price_range": price_range, "after": value_after},
) )

View File

@ -317,21 +317,21 @@ export * from './resSupplierListMsg';
export * from './resSupplierMsg'; export * from './resSupplierMsg';
export * from './resSupplierSupplier'; export * from './resSupplierSupplier';
export * from './resTargetBreakdown'; export * from './resTargetBreakdown';
export * from './resTargetBreakdownAnchoringPrice';
export * from './resTargetBreakdownChosenBasis'; export * from './resTargetBreakdownChosenBasis';
export * from './resTargetBreakdownInternetLowest'; export * from './resTargetBreakdownInternetLowest';
export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMdPrice';
export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownMsg';
export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownPurchase';
export * from './resTargetBreakdownSelling'; export * from './resTargetBreakdownSelling';
export * from './resTargetBreakdownTargetAnchoringPrice';
export * from './sessionData'; export * from './sessionData';
export * from './sessionDataAnchoringPrice';
export * from './sessionDataBidAt'; export * from './sessionDataBidAt';
export * from './sessionDataBidPrice'; export * from './sessionDataBidPrice';
export * from './sessionDataEmailSentAt'; export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice'; export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason'; export * from './sessionDataRejectReason';
export * from './sessionDataTargetAnchoringPrice';
export * from './sessionStatus'; export * from './sessionStatus';
export * from './supplierData'; export * from './supplierData';
export * from './supplierDataCode'; export * from './supplierDataCode';

View File

@ -12,7 +12,7 @@ import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase';
import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling';
import type { TargetCandidate } from './targetCandidate'; import type { TargetCandidate } from './targetCandidate';
import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis';
import type { ResTargetBreakdownTargetAnchoringPrice } from './resTargetBreakdownTargetAnchoringPrice'; import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice';
export interface ResTargetBreakdown { export interface ResTargetBreakdown {
result?: ErrorInfo; result?: ErrorInfo;
@ -25,8 +25,9 @@ export interface ResTargetBreakdown {
selling?: ResTargetBreakdownSelling; selling?: ResTargetBreakdownSelling;
fee?: number; fee?: number;
margin?: number; margin?: number;
anchoring_value?: number;
candidates?: TargetCandidate[]; candidates?: TargetCandidate[];
chosen_basis?: ResTargetBreakdownChosenBasis; chosen_basis?: ResTargetBreakdownChosenBasis;
target_price?: number; target_price?: number;
target_anchoring_price?: ResTargetBreakdownTargetAnchoringPrice; anchoring_price?: ResTargetBreakdownAnchoringPrice;
} }

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export type ResTargetBreakdownTargetAnchoringPrice = number | null; export type ResTargetBreakdownAnchoringPrice = number | null;

View File

@ -5,7 +5,7 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { QuotationType } from './quotationType'; import type { QuotationType } from './quotationType';
import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice'; import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice';
import type { SessionStatus } from './sessionStatus'; import type { SessionStatus } from './sessionStatus';
import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidPrice } from './sessionDataBidPrice';
import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataBidAt } from './sessionDataBidAt';
@ -23,7 +23,7 @@ export interface SessionData {
qt_round: number; qt_round: number;
qt_type: QuotationType; qt_type: QuotationType;
target_price: number; target_price: number;
target_anchoring_price?: SessionDataTargetAnchoringPrice; anchoring_price?: SessionDataAnchoringPrice;
status: SessionStatus; status: SessionStatus;
bid_price?: SessionDataBidPrice; bid_price?: SessionDataBidPrice;
bid_at?: SessionDataBidAt; bid_at?: SessionDataBidAt;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export type SessionDataTargetAnchoringPrice = number | null; export type SessionDataAnchoringPrice = number | null;

View File

@ -104,7 +104,7 @@ export function DrawerHeaderCards({
<span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span> <span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span>
)} )}
</InfoField> </InfoField>
<InfoField label="앵커링가" value={won(repSession?.target_anchoring_price)} valueClassName="font-sans" /> <InfoField label="앵커링가" value={won(repSession?.anchoring_price)} valueClassName="font-sans" />
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1"> <InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
<QuotationStatusBadge status={quotation.status} /> <QuotationStatusBadge status={quotation.status} />
</InfoField> </InfoField>

View File

@ -181,12 +181,12 @@ export function SessionsStatusTab({
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill> <StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell> </TableCell>
<TableCell className="p-3 text-right text-muted-foreground"> <TableCell className="p-3 text-right text-muted-foreground">
{sess.target_anchoring_price > 0 ? ( {sess.anchoring_price > 0 ? (
<> <>
{sess.target_anchoring_price.toLocaleString()} {sess.anchoring_price.toLocaleString()}
{sess.target_price > 0 && ( {sess.target_price > 0 && (
<span className="ml-0.5 text-[10px] opacity-70"> <span className="ml-0.5 text-[10px] opacity-70">
({((1 - sess.target_anchoring_price / sess.target_price) * 100).toFixed(1)}%) ({((1 - sess.anchoring_price / sess.target_price) * 100).toFixed(1)}%)
</span> </span>
)} )}
</> </>

View File

@ -122,15 +122,16 @@ export function TargetPriceModal({
})} })}
</div> </div>
{/* 앵커링 (negodata: 앵커링가는 칸 rate v1.2로 산정·박제, 세팅 고정율 폐지) */} {/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1"> <div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]"></Typography> <Typography as="p" variant="small" className="font-bold text-foreground text-[11px]"></Typography>
{category && ( {category && (
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {category}</Typography> <Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {category}</Typography>
)} )}
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {supplierTypeLabel}</Typography> <Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {supplierTypeLabel}</Typography>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground"> : {bd.anchoring_value}</Typography>
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground"> <Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
: {won(bd.target_anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= × (1 )</span> : {won(bd.anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= ×(1{bd.anchoring_value})</span>
</Typography> </Typography>
</div> </div>
</> </>

View File

@ -242,7 +242,7 @@ export type SessionView = {
item_name: string; item_name: string;
status: number; status: number;
target_price: number; target_price: number;
target_anchoring_price: number; anchoring_price: number;
bid_price: number | null; bid_price: number | null;
bid_at: string; bid_at: string;
reject_reason: string | null; reject_reason: string | null;
@ -379,7 +379,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
item_name: product?.name || '-', item_name: product?.name || '-',
status: sd.status, status: sd.status,
target_price: sd.target_price ?? 0, target_price: sd.target_price ?? 0,
target_anchoring_price: sd.target_anchoring_price ?? 0, anchoring_price: sd.anchoring_price ?? 0,
bid_price: sd.bid_price ?? null, bid_price: sd.bid_price ?? null,
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-', bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
reject_reason: sd.reject_reason ?? null, reject_reason: sd.reject_reason ?? null,

View File

@ -298,10 +298,10 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷) qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N) qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
target_price BIGINT NOT NULL, -- 목표가(원) target_price BIGINT NOT NULL, -- 목표가(원)
target_anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조) anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제 anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적" last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
anchoring_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부) status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
bid_price BIGINT NULL, -- 입찰가(원) bid_price BIGINT NULL, -- 입찰가(원)
bid_at TIMESTAMPTZ NULL, -- 입찰 시각 bid_at TIMESTAMPTZ NULL, -- 입찰 시각

View File

@ -14,7 +14,7 @@ ALTER TABLE partner.items
-- 세션: 앵커링가 -- 세션: 앵커링가
ALTER TABLE negotiation.sessions ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT; ADD COLUMN IF NOT EXISTS anchoring_price BIGINT;
-- 견적: MD 제시가 + 협력사(공급채널) 유형 -- 견적: MD 제시가 + 협력사(공급채널) 유형
ALTER TABLE quotation.quotations ALTER TABLE quotation.quotations
@ -54,12 +54,12 @@ CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notification
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종 -- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종
-- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(rate_adjustments·뷰)는 -- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(adjustments·뷰)는
-- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.) -- 05-anchoring-schema.sql 로 적용 — 여기엔 두지 않는다.)
ALTER TABLE negotiation.sessions ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제 ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적) ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적)
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹 ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- [2026-07-02] 마감 close_reason 개편 — 견적 마감사유 + 가격정책 3구간 -- [2026-07-02] 마감 close_reason 개편 — 견적 마감사유 + 가격정책 3구간

View File

@ -0,0 +1,69 @@
-- ============================================================
-- anchoring : 앵커링 값 자동 조정 배치 (schedules/anchoring) 자산
-- ============================================================
-- negosium_db 안의 8번째 schema. schedules/anchoring 서비스가 소유한다(backend 는 소비만).
-- 01-schema*.sql 과 동일 컨벤션: FK 미사용(앱 레이어 무결성), TIMESTAMPTZ(UTC), 코드값 SMALLINT.
-- 규범 문서: schedules/anchoring/docs/개발용.md §6.
--
-- sessions 의 앵커링 컬럼(anchoring_price·anchoring_value·last_offer_price·used_by_adjustment_id)은
-- 여기 두지 않는다 — 신규 DB 는 01-schema*.sql, 기존 DB 는 04-alter*.sql 소관.
-- 유일한 DDL 원본 — 구 schedules/anchoring/schema.sql 은 여기로 이관 후 삭제(2026-07-06).
-- 구 이름(rate_adjustments 등)의 기존 DB 는 schedules/anchoring/migrations/20260706_rename_anchoring.sql 적용.
\connect negosium_db
CREATE SCHEMA IF NOT EXISTS anchoring;
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지, updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.adjustments (
adjustment_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_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_adjustments_cell
ON anchoring.adjustments (company_id, supplier_type, price_range_index, adjustment_id DESC);
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (부분 인덱스).
-- qt_type=1 을 술어에 포함해야 함 — 빼면 배치가 마킹하지 않는 비재협상 세션이
-- 영구 잔류해 인덱스가 전체 세션 수에 비례해 성장한다(의도는 이월 풀만 담는 소형 인덱스).
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (status)
WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
-- ── 조회용 뷰 (파생 — 상태 없음, 진실 원천은 adjustments) ──────────
-- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로"
CREATE OR REPLACE VIEW anchoring.value_history AS
SELECT adjustment_id,
company_id,
supplier_type, -- 1유통/2제조/3총판
price_range_index, -- 0..45 자릿수 사다리
anchoring_value_before, -- 이전 값(‰)
anchoring_value_after, -- 새 값(‰)
anchoring_value_after - anchoring_value_before AS value_change,
sample_count,
success_count,
round(success_count::numeric / sample_count, 3) AS success_rate,
created_at
FROM anchoring.adjustments;
-- 칸별 현재값: 칸의 최신 조정 행. 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
CREATE OR REPLACE VIEW anchoring.current_values AS
SELECT DISTINCT ON (company_id, supplier_type, price_range_index)
company_id,
supplier_type,
price_range_index,
anchoring_value_after AS anchoring_value,
adjustment_id AS last_adjustment_id,
created_at AS last_adjusted_at
FROM anchoring.adjustments
ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC;

View File

@ -0,0 +1,11 @@
# 시크릿 — 이미지 레이어에 절대 넣지 않는다(런타임 마운트/env 주입)
config.toml
config.*.toml
__pycache__/
*.pyc
.pytest_cache/
.venv/
tests/
docs/
*.md

View File

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

View File

@ -5,8 +5,8 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# config.{env}.toml 은 이미지에 넣지 않는다(시크릿이 이미지 레이어에 남음) — 런타임 마운트 또는 env 로 주입
COPY src ./src COPY src ./src
COPY config.toml* ./
ENV PYTHONPATH=/app/src \ ENV PYTHONPATH=/app/src \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1

View File

@ -6,48 +6,48 @@
> 규범 문서: **`docs/개발용.md`** (정책: `docs/기획용.md`, 흐름 해설: `docs/워크플로우.md`, 타 팀 적용: `docs/인수인계.md`) > 규범 문서: **`docs/개발용.md`** (정책: `docs/기획용.md`, 흐름 해설: `docs/워크플로우.md`, 타 팀 적용: `docs/인수인계.md`)
> **처음 오신 분 / 운영 담당자****`docs/운영및유지보수.md`** 부터 보세요 (설치·실행·로그 읽기·트러블슈팅). > **처음 오신 분 / 운영 담당자****`docs/운영및유지보수.md`** 부터 보세요 (설치·실행·로그 읽기·트러블슈팅).
> 과제 이력·백로그 → **`TODO.md`** (주요 과제는 전부 종결)
## 경계 ## 경계
| 구분 | 대상 | | 구분 | 대상 |
|---|---| |---|---|
| 소유(쓰기) | `anchoring.rate_adjustments`(append-only 조정 이력), `sessions.anchoring_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 | | 소유(쓰기) | `anchoring.adjustments`(append-only 조정 이력), `sessions.used_by_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 |
| 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` | | 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` |
| 소비자 | negodata 가 `reader.get_anchor_rate` 이식 + 이 Redis 를 참조해 세션 생성 시 앵커가 박제 (인수인계) | | 소비자 | negodata 가 reader 이식판으로 세션 생성 시 앵커가 박제 — **적용 완료(2026-07-04), 이식판은 Redis 미사용**(`current_values` 뷰 직조회, 인수인계 §1) |
## 구조 ## 구조
``` ```
schema.sql # 모듈 소유 DDL (rate_adjustments + sessions 3컬럼) — psql 수동 적용
src/anchoring/ src/anchoring/
constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의) constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의)
resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스 resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스
base_table.py # 로드+검증(실패 시 기동 중단) base_table.py # 로드+검증(실패 시 기동 중단)
service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상 service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상
reader.py # 현재 rate 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상 reader.py # 현재 anchoring_value 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백 redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백
batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백) batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백)
scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝) scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝)
main.py # 엔트리 (상주 / --once) main.py # 엔트리 (상주 / --once)
tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵) tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵)
migrations/ # 기존 DB 이름 개편용 rename 마이그레이션 (DDL 자체는 postgres-init/05)
``` ```
## 실행 ## 실행
```bash ```bash
# 0) DDL 적용 (신규 DB: postgres-init/01~04 이후) # 0) DDL 적용 — 루트 postgres-init 로 이관됨 (신규 DB: 01~04 이후 05)
psql -h 127.0.0.1 -U postgres -d negosium_db -f schema.sql psql -h 127.0.0.1 -U postgres -d negosium_db -f ../../postgres-init/05-anchoring-schema.sql
# 로컬(가상환경) # 로컬(가상환경)
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.toml.example config.toml # DB/Redis 채우기 (env 로 대체 가능) cp config.toml.example config.local.toml # DB/Redis 채우기 (APP_ENV 로 local/dev/prod 선택, env 로 대체 가능)
PYTHONPATH=src .venv/bin/python -m anchoring.main --once --dry-run # 예행 연습(DB/Redis 무변경) 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 --once # 수동 1회(격주 게이트 무시)
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주 PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
# 도커(자립 compose: redis 동봉) # 도커 — 루트 docker-compose.yml 에 통합됨(anchoring + anchoring-redis). config.local.toml 은
docker compose up -d --build # 이미지에 안 들어가고 마운트되므로 up 전에 파일이 먼저 있어야 한다 (레포 루트에서 실행)
(cd ../.. && docker compose up -d --build anchoring anchoring-redis)
# 테스트 # 테스트
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q PYTHONPATH=src .venv/bin/python -m pytest tests/ -q
@ -62,9 +62,9 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
``` ```
- 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과 - 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id``anchoring.rate_adjustments` 행과 교차 확인한다. `회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id``anchoring.adjustments` 행과 교차 확인한다.
- 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다. - 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다.
- 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당. - 로그 로테이션은 루트 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
## 운영 런북 ## 운영 런북
@ -72,6 +72,6 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변). 재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변).
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고 - **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`. TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
- **가격 제시율 0% WARN**: backend 의 `last_offered_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검. - **가격 제시율 0% WARN**: backend 의 `last_offer_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
- **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청. - **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청.
- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `anchoring_adjustment_id` 하나뿐. - 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `used_by_adjustment_id` 하나뿐.

View File

@ -1,50 +0,0 @@
# TODO — 앵커링 모듈 후속 과제
> 남은 과제는 **정책 재확정 + 스펙(v1.3) 개정 사안**이다. 현재 규범(`docs/개발용.md` §12)은
> 정적 테이블 변경·파라미터 조정을 봉인하고 있으므로, 착수 전 정책 확정 → 문서 개정 → 구현 순서를 지킨다.
> **운영 데이터(조정 이력)가 쌓이기 전에 확정하는 것이 가장 저렴하다** — negodata 적용 전이 적기.
---
## ~~1. 정적 기본 테이블의 가격구간을 계단식으로 재설계~~ ✅ 완료 (2026-07-02)
**자릿수 계단식 사다리(46칸)로 확정·구현 완료.** 폭 = 구간 상한의 10%(선행 자릿수 밴드):
[0, 1,000) 통일 1칸 + 자릿수(1천~1억, 5개)당 9칸 — "1천 원대·2천 원대 … 9천만 원대".
1억 초과는 마지막 인덱스(45) 클램프. 사다리 단일 소스 = `constants.UPPER_BOUNDS`,
산식 = `bisect_right`. 조정 이력 0건 시점에 적용해 마이그레이션 없음.
상세: `docs/개발용.md` §2·§4.1.
## ~~2. `anchoring_records` — 회사별 앵커링 값 전체 조회 테이블~~ ✅ 뷰로 종결 (2026-07-02)
**신규 테이블 없이 조회용 뷰 2개로 해결.** 요구(회사별 값 업데이트 리스트업 + 이전 값 판별)는
`rate_adjustments` 한 행에 before→after 가 박제되어 있어 이미 충족 — 테이블 추가는 사본만 만든다고
판단해 기각하고, 조회를 제품화하는 뷰를 추가했다:
- `anchoring.rate_history` — 회사별 값 변경 이력(이전→새 값, 변화폭, 성공률, 시각)
- `anchoring.current_rates` — 칸별 현재값(없는 칸 = 시작값 10‰)
상세: `docs/개발용.md` §6.3, 사용법: `docs/운영및유지보수.md` §8.
추후 대시보드에서 "전체 칸 나열(무조정 칸 포함)·페이징" 요구가 생기면 그때 스냅샷 테이블로 승격을 재검토한다.
---
## 백로그 (저우선 — 리뷰에서 식별, 착수 조건 명시)
- [ ] **전환기 점프 정책 결정 (negodata 적용 직전 필수)**: backend 배포~negodata 적용 사이에
학습된 rate 가 적용 순간 한 번에 반영된다("한 계단" 원칙의 1회 예외).
적용 직전 `SELECT max(anchor_rate_after) FROM anchoring.current_rates` 로 폭 확인 후
점프 감수 vs 이력 아카이브·리셋을 결정할 것 — 절차는 `docs/인수인계.md` 적용 순서 ③.
- [ ] **percent 입력 모드 대비**: `chat_service.send``user_input_type == "price"` 만 가격으로
파싱한다. agent 에 percent 스크립트가 도입되면 percent 턴이 가격 흔적 없이 지나가
학습에서 조용히 빠진다(현재 agent 스크립트에 percent 없음 — 잠복). 도입 시
percent→price 변환(`target*(100-pct)//100`) 후 동일 경로로 태울 것.
- [ ] **스캔 스트리밍**: 배치 스캔이 pending 전량을 메모리에 올린다. 레거시 수백만 행
규모 DB 에 첫 적용할 때는 keyset 페이지네이션으로 전환 검토(제외 마킹은 이미 청크
커밋이라 트랜잭션 장기화 없음).
- [ ] **Redis 통합 테스트**: 자동 스위트는 무Redis(폴백 경로)로 돈다. CI 에 redis 컨테이너가
생기면 §11.5 의 re-SET 회복·TTL·오염 값 방어(get_rate 범위 검증) 케이스를 자동화.
- [ ] **config 오류 메시지**: config.toml 의 오타 키가 TypeError 로 죽는다 — 파일/섹션명을
알려주는 검증 메시지로 개선.
- [ ] **운영 Redis 인증**: compose 는 127.0.0.1 바인딩으로 방어했지만, 운영 네트워크에서
negodata 가 원격 접속하는 구성이면 `requirepass` + `REDIS_PASSWORD` 설정을 적용할 것.

View File

@ -1,5 +1,9 @@
# anchoring 모듈 설정 — config.toml 로 복사 후 채운다 (config.toml 은 gitignore). # anchoring 모듈 설정 템플릿 — 실행 환경에 맞게 복사 후 채운다 (전부 gitignore):
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 이 파일 > 코드 기본값. # cp config.toml.example config.local.toml # APP_ENV=local (기본)
# cp config.toml.example config.dev.toml # APP_ENV=dev
# cp config.toml.example config.prod.toml # APP_ENV=prod
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 해당 파일 > 코드 기본값.
# local 은 파일이 없어도 기본값으로 동작, dev/prod 는 파일이 없으면 기동 중단.
log_level = "info" log_level = "info"

View File

@ -1,30 +0,0 @@
# anchoring 자립 서비스 — 루트 compose 와 독립(다른 서버를 건드리지 않음).
# DB 는 기존 외부 PostgreSQL(host.docker.internal), Redis 는 여기 동봉.
# negodata(견적 생성 측)는 이 redis 인스턴스를 REDIS_HOST 로 바라본다(docs/인수인계.md).
# 로그: stdout(json-file) — 로테이션 필수(장기 운영 디스크 보호). 로그 시각은 코드가 KST 로 고정.
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
anchoring-redis:
image: redis:7-alpine
container_name: anchoring-redis
ports:
- "127.0.0.1:6379:6379" # 호스트 로컬만 — 무인증 Redis 를 외부에 열지 않는다(앵커 값 오염 방지)
restart: unless-stopped
logging: *default-logging
anchoring:
build: .
container_name: anchoring
environment:
DB_HOST: host.docker.internal
REDIS_HOST: anchoring-redis
TZ: Asia/Seoul
depends_on:
- anchoring-redis
restart: unless-stopped
logging: *default-logging

View File

@ -34,11 +34,11 @@
| 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 | | 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`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)** 로 클램프 | | 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 |
| 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 | | 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 |
| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`target_anchoring_price`, `anchor_rate_permille`, `last_offered_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 | | 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`anchoring_price`, `anchoring_value`, `last_offer_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offered_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 | | 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offer_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
| 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 | | 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 |
| 조정 이력 저장 | **append-only 조정 이력** `anchoring.rate_adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 | | 조정 이력 저장 | **append-only 조정 이력** `anchoring.adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 |
| 소비 경계 | `sessions.anchoring_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** | | 소비 경계 | `sessions.used_by_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** |
| 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 | | 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 |
| 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 다음 주기 자연 이월 | | 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 다음 주기 자연 이월 |
| 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 | | 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 |
@ -83,8 +83,8 @@
|---|---| |---|---|
| 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) | | 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) |
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) | | 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) |
| 내부 인덱스 변환 | `bracket_index = idx 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `bracket_index` 사용 | | 내부 인덱스 변환 | `price_range_index = idx 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `price_range_index` 사용 |
| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `bracket_index` 45). **1억 초과도 예외 없이 마지막 인덱스** | | 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `price_range_index` 45). **1억 초과도 예외 없이 마지막 인덱스** |
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(round(anchoring_value * 1000))`. 현재 전 구간 10‰ | | 시작값 | 칸의 시작 앵커링 값 = 해당 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) | | 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
@ -100,13 +100,13 @@
```python ```python
# src/anchoring/constants.py # src/anchoring/constants.py
ANCHOR_RATE_MIN = 10 # 하한 1% ANCHORING_VALUE_MIN = 10 # 하한 1%
ANCHOR_RATE_MAX = 200 # 상한 20% ANCHORING_VALUE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드 # 시작값은 상수가 아니라 정적 테이블(§2)에서 로드
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드 # 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름) # ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
DELTA_PERMILLE = { ADJUSTMENT_STEP = {
1: 20, # 유통(DISTRIBUTION) ±2% 1: 20, # 유통(DISTRIBUTION) ±2%
2: 10, # 제조(MANUFACTURE) ±1% 2: 10, # 제조(MANUFACTURE) ±1%
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5% 3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
@ -117,13 +117,13 @@ SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면
# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드) # 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스 PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개 UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개
BRACKET_COUNT = 46 PRICE_RANGE_COUNT = 46
BRACKET_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한 PRICE_RANGE_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에 EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유) # 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값
CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조 CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백 REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
@ -133,7 +133,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
```python ```python
class SupplierType(Enum): class SupplierType(Enum):
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
(negodata SupplierType 과 동일 코드)""" (negodata SupplierType 과 동일 코드)"""
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외) NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
DISTRIBUTION = 1 # 유통 DISTRIBUTION = 1 # 유통
@ -157,13 +157,13 @@ class AnchoringSampleType(Enum):
### 4.1 칸(cell) 식별 ### 4.1 칸(cell) 식별
칸 = **`(company_id, supplier_type, bracket_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다. 칸 = **`(company_id, supplier_type, price_range_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
``` ```
bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45) price_range_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
``` ```
- `bracket_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45. - `price_range_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
- 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨). - 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨).
- 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**. - 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**.
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다. - 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
@ -172,7 +172,7 @@ bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
### 4.2 앵커링가 계산 ### 4.2 앵커링가 계산
``` ```
anchor_price = target_price × (1000 anchor_rate_permille) // 1000 anchor_price = target_price × (1000 anchoring_value) // 1000
``` ```
- `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다. - `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다.
@ -189,29 +189,29 @@ anchor_price = target_price × (1000 anchor_rate_permille) // 1000
| 컬럼 | 의미 | 기록 시점 | | 컬럼 | 의미 | 기록 시점 |
|---|---|---| |---|---|---|
| `sessions.target_anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) | | `sessions.anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) |
| `sessions.anchor_rate_permille` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 | | `sessions.anchoring_value` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 |
| `sessions.last_offered_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 | | `sessions.last_offer_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 |
| `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 | | `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 |
판정 대상: `qt_type = 1(재협상)` AND `status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED}` AND `deleted = false`. 판정 대상: `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_SUCCESS` | `status=DONE` AND `bid_price ≤ anchoring_price` | O | O |
| `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X | | `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X |
| `EXCLUDED` | `last_offered_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — | | `EXCLUDED` | `last_offer_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — |
- "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다. - "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
- **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다. - **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offered_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다. - 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offer_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.
### 4.4 평가 산식 (누적 전량 평가) ### 4.4 평가 산식 (누적 전량 평가)
배치 시점에 칸별로 수행한다. 배치 시점에 칸별로 수행한다.
``` ```
pending = 해당 칸의 미처리(anchoring_adjustment_id IS NULL) 유효 표본 전부 pending = 해당 칸의 미처리(used_by_adjustment_id IS NULL) 유효 표본 전부
n = |pending| n = |pending|
n < 10 평가하지 않음. 마킹도 하지 않음 다음 주기로 이월 (자동으로 4주, 6주, 치가 ) n < 10 평가하지 않음. 마킹도 하지 않음 다음 주기로 이월 (자동으로 4주, 6주, 치가 )
@ -221,16 +221,16 @@ n ≥ 10 → r = (pending 중 BID_SUCCESS 건수) / n
delta = ┤ 0 if 0.30 ≤ r < 0.60 delta = ┤ 0 if 0.30 ≤ r < 0.60
└ −δ(p) if r < 0.30 └ −δ(p) if r < 0.30
anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200) anchoring_value_after = clamp(anchoring_value_before + delta, 10, 200)
→ 한 트랜잭션으로: → 한 트랜잭션으로:
① anchoring.rate_adjustments INSERT (n, success, before/after, consumed_session_ids 박제) ① anchoring.adjustments INSERT (n, success, before/after, used_session_ids 박제)
② 소비 세션 UPDATE sessions SET anchoring_adjustment_id = <조정 id> ② 소비 세션 UPDATE sessions SET used_by_adjustment_id = <조정 id>
WHERE session_id IN (...) AND anchoring_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST) WHERE session_id IN (...) AND used_by_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
``` ```
- **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다. - **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
- delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST). - delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST).
- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `anchoring_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다. - "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `used_by_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다.
- 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장). - 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).
### 4.5 현재 앵커링 값 조회 ### 4.5 현재 앵커링 값 조회
@ -238,12 +238,12 @@ anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200)
값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다. 값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다.
``` ```
rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after rate = (칸의 최신 anchoring.adjustments 행).anchoring_value_after
없으면 → 정적 테이블 시작값 (§2.1) 없으면 → 정적 테이블 시작값 (§2.1)
``` ```
- 재현성: 조정 행에 박제된 `consumed_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST). - 재현성: 조정 행에 박제된 `used_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST).
- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `consumed_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능). - 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `used_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능).
- 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다. - 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.
--- ---
@ -256,22 +256,22 @@ rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after
[견적/세션 생성 — negodata, 인수인계 §9.1] [견적/세션 생성 — negodata, 인수인계 §9.1]
│ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000rate)//1000 (정수) │ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000rate)//1000 (정수)
│ → 세션 INSERT 에 target_anchoring_price + anchor_rate_permille 박제 (재생성 상속 폐지) │ → 세션 INSERT 에 anchoring_price + anchoring_value 박제 (재생성 상속 폐지)
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존] [협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
│ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계) │ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
│ 가격 입력 턴마다 last_offered_price 갱신 (가격 흔적) │ 가격 입력 턴마다 last_offer_price 갱신 (가격 흔적)
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼) negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
│ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3) │ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
│ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션) │ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
anchoring.rate_adjustments ────────── 진실 원천 (INSERT only, consumed_session_ids·값 변화 박제) anchoring.adjustments ────────── 진실 원천 (INSERT only, used_session_ids·값 변화 박제)
│ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합) │ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7일 Redis anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TTL 7일
│ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블) │ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
@ -279,7 +279,7 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7
``` ```
- 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**. - 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**.
- 배치가 `sessions`에 쓰는 것은 `anchoring_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT. - 배치가 `sessions`에 쓰는 것은 `used_by_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT.
- 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외). - 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
- 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요. - 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
- Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8). - Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).
@ -288,20 +288,20 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7
## 6. DB 스키마 ## 6. DB 스키마
프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 **모듈 소유**`schema.sql` 한 파일(스키마+테이블+sessions ALTER+인덱스, psql 수동 적용). `postgres-init` 에는 anchoring 파일을 두지 않는다. 프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 `postgres-init/05-anchoring-schema.sql` 한 파일(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관). sessions 앵커링 컬럼은 `01-schema*.sql`·`04-alter*.sql` 소관. 소유 서비스는 여전히 이 모듈이다.
**네이밍 결정** — 기존 코드베이스 용어와 통일: **네이밍 결정** — 기존 코드베이스 용어와 통일:
| 개념 | 명칭 | 이유 | | 개념 | 명칭 | 이유 |
|---|---|---| |---|---|---|
| 조정 이력 테이블 | **`anchoring.rate_adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 | | 조정 이력 테이블 | **`anchoring.adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 |
| 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 | | 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 |
| 가격구간 | **`price_bracket_index`** | 가격구간임을 명시 (코드 내부 변수는 `bracket_index`) | | 가격구간 | **`price_range_index`** | 가격구간임을 명시 (코드 내부 변수는 `price_range_index`) |
| 표본 수 | **`nego_count`** | "협상 결과 n건" — 정책 문서 용어 | | 표본 수 | **`sample_count`** | "협상 결과 n건" — 정책 문서 용어 |
| 값 변화 | **`anchor_rate_before` / `anchor_rate_after`** | `sessions.anchor_rate_permille`와 계열 통일 (‰) | | 값 변화 | **`anchoring_value_before` / `anchoring_value_after`** | `sessions.anchoring_value`와 계열 통일 (‰) |
| 소비 창 | **`consumed_session_ids`** | "이 조정이 소비한 세션"임을 명시 | | 소비 창 | **`used_session_ids`** | "이 조정이 소비한 세션"임을 명시 |
| 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) | | 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) |
| 소비 마킹 | **`sessions.anchoring_adjustment_id`** | 조정 테이블명과 정합 | | 소비 마킹 | **`sessions.used_by_adjustment_id`** | 조정 테이블명과 정합 |
### 6.1 조정 이력 (신설 — 유일한 새 테이블) ### 6.1 조정 이력 (신설 — 유일한 새 테이블)
@ -309,56 +309,56 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7
CREATE SCHEMA IF NOT EXISTS anchoring; CREATE SCHEMA IF NOT EXISTS anchoring;
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략. -- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments ( CREATE TABLE IF NOT EXISTS anchoring.adjustments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래) company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15) supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장) price_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장) sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수 success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값) anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장 anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사) used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- 현재 값 조회 최적화: 칸별 최신 조정 -- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell CREATE INDEX IF NOT EXISTS idx_adjustments_cell
ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC); ON anchoring.adjustments (company_id, supplier_type, price_range_index, id DESC);
``` ```
### 6.2 sessions 확장 (기존 테이블 ALTER) ### 6.2 sessions 확장 (기존 테이블 ALTER)
```sql ```sql
ALTER TABLE negotiation.sessions ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제 ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 rate(‰) 박제
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변 ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST — -- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST —
-- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장) -- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장)
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (status) ON negotiation.sessions (status)
WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1; WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
``` ```
### 6.3 조회용 뷰 (파생 — 상태 없음) ### 6.3 조회용 뷰 (파생 — 상태 없음)
회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 rate_adjustments 그대로): 회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 adjustments 그대로):
```sql ```sql
anchoring.rate_history -- 값 변경 이력 리스트업: 이전 값(anchor_rate_before)→새 값 + delta_permille·success_rate·created_at anchoring.value_history -- 값 변경 이력 리스트업: 이전 값(anchoring_value_before)→새 값 + value_change·success_rate·created_at
anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰) anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
``` ```
- 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다. - 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 rate_adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다. - "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.
주의사항: 주의사항:
- `sessions.target_anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님). - `sessions.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 스키마 자체(테이블·뷰)는 모듈 파일만이 소유. - 신규 DB 구축 시 적용 순서: `postgres-init/01~05` (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 매핑 포함). - backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).
--- ---
@ -366,7 +366,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
| 항목 | 규약 | | 항목 | 규약 |
|---|---| |---|---|
| 키 | `anchor:{company_id}:{supplier_type}:{bracket_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` | | 키 | `anchor:{company_id}:{supplier_type}:{price_range_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` |
| 값 | 정수 천분율 문자열. 예: `"30"` | | 값 | 정수 천분율 문자열. 예: `"30"` |
| TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) | | TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 | | 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 |
@ -377,16 +377,16 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST). - 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
- ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 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.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, **negodata 도 쓰지 않는다**(2026-07-04 적용된 이식판 reader 는 `current_values` 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 `config.{APP_ENV}.toml`(local/dev/prod, 기본 local) + `REDIS_HOST/PORT/PASSWORD` env 오버라이드.
- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. 모듈 compose 는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그). - 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. compose(루트 docker-compose.yml)는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다.
--- ---
## 8. 배치 잡 명세 ## 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·수동 실행 실패 감지). - **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·config.{APP_ENV}.toml 보유(APP_ENV 로 local/dev/prod 선택, compose 는 루트 docker-compose.yml 에 통합), 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 모드). 플래그는 argparse 로 검증한다 — `--dry-run` 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 **기동 전 즉시 에러(종료코드 2)**: 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). config.{env}.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. dev/prod 는 파일 부재 시 기동 즉시 중단(local 만 기본값 허용).
- **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST). - **스케줄**: 매주 토 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** 경합을 차단한다 유니크 가드가 없는 구조에서 이중 조정(+) 막는 유일한 방어선이므로 SHOULD 아니라 MUST . - **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND used_by_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 경합을 차단한다 유니크 가드가 없는 구조에서 이중 조정(+) 막는 유일한 방어선이므로 SHOULD 아니라 MUST .
- **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.) - **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
``` ```
@ -397,31 +397,32 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
(SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7) (SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조): 1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조):
sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false) 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 WHERE s.used_by_adjustment_id IS NULL AND s.deleted = false
AND s.qt_type = 1 AND s.status IN (3, 4, 5) AND s.qt_type = 1 AND s.status IN (3, 4, 5)
2. 세션별 파생 판정(§4.3): 2. 세션별 파생 판정(§4.3):
- EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석) - EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
anchoring_adjustment_id = 0 일괄 마킹 (재스캔 방지) used_by_adjustment_id = 0 일괄 마킹 (재스캔 방지)
- 유효 표본 → 칸별 그룹 적재 - 유효 표본 → 칸별 그룹 적재
- 박제 정합 감시: rate 가 박제된 세션에 대해 tp×(1000anchor_rate_permille)//1000 과 - 박제 정합 감시: rate 가 박제된 세션에 대해 calc_anchoring_price(tp, anchoring_value)(§4.2 정수식)와
박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch 박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
— negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는 — negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다) 검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음): 3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값) anchoring_value_before = 최신 조정 anchoring_value_after (없으면 정적 테이블 시작값)
anchor_rate_after = evaluate_pending(...) # §4.4 / §10 anchoring_value_after = evaluate_samples(...) # §4.4 / §10
① anchoring.rate_adjustments INSERT (consumed_session_ids 박제) ① anchoring.adjustments INSERT (used_session_ids 박제)
② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST) ② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchor_rate_after (best effort, TTL 7일) 4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchoring_value_after (best effort, TTL 7일)
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / 상·하한 도달 / 이월 칸 수 / 제외 마킹 건수 5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / clamp 포화(조정치가 상·하한 밖으로 나가 잘린 칸 —
+ 가격 제시율(종료 재협상 세션 중 last_offered_price 보유 비율) — 0% 면 WARN 경계값에서의 단순 '유지'는 세지 않음) / 이월 칸 수 / 제외 마킹 건수
+ 가격 제시율(종료 재협상 세션 중 last_offer_price 보유 비율) — 0% 면 WARN
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지) (backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
``` ```
**로그 규약** (운영 추적): **로그 규약** (운영 추적):
- 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**. - 출력 = stdout(컨테이너 json-file 드라이버, 루트 compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= bracket=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`). - 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= price_range=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`).
- 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약. - 라인 구성: 시작(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. - 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화). - 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
@ -442,19 +443,22 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
### 9.1 견적/세션 생성 — negodata (인수인계 대상) ### 9.1 견적/세션 생성 — negodata (인수인계 대상)
현재 negodata `_build_quotation`은 세션 생성 시 `target_anchoring_price`를 구 방식으로 채운다 > ✅ 2026-07-04 적용 완료(인수인계.md §1 참조). 적용된 이식판은 아래 2번의 Redis GET/SET 없이
> `current_values` 뷰 직조회 → 정적 테이블 폴백으로 동작한다(단순화 결정).
구(舊) negodata `_build_quotation`은 세션 생성 시 `anchoring_price`를 구 방식으로 채웠다
(신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속). (신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속).
새 앵커링 모듈 전달 후 아래로 교체된다: 새 앵커링 모듈 전달 후 아래로 교체된다:
``` ```
세션(상품 × 공급사) 생성 시마다: 세션(상품 × 공급사) 생성 시마다:
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type 1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
bracket_index = calc_bracket_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식 price_range_index = calc_price_range_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식
2. rate 조회 (모듈의 reader 이식): 2. rate 조회 (모듈의 reader 이식):
supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12) 그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12)
3. anchor_price = target_price × (1000 rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기) 3. anchor_price = target_price × (1000 rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기)
4. 세션 INSERT 에 target_anchoring_price = anchor_price, anchor_rate_permille = rate 포함 (박제) 4. 세션 INSERT 에 anchoring_price = anchor_price, anchoring_value = rate 포함 (박제)
``` ```
- **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지. - **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
@ -466,7 +470,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
`backend/services/chat_service.py::_resolve_anchor_price``quotation_settings.anchoring_value` 읽기 **삭제**. `_agent_context`가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다. `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 를 그대로 사용. 1. 박제값 사용 (MUST): sessions.anchoring_price 를 그대로 사용.
→ negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로. → negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
→ 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제) → 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그. 2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
@ -480,7 +484,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
**가격 흔적 기록** (MUST): **가격 흔적 기록** (MUST):
- agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다. - agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offered_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3). - backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offer_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
- 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST). - 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).
--- ---
@ -492,46 +496,48 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
from bisect import bisect_right from bisect import bisect_right
from anchoring.constants import ( from anchoring.constants import (
ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE, ANCHORING_VALUE_MIN, ANCHORING_VALUE_MAX, ADJUSTMENT_STEP,
SAMPLE_THRESHOLD, UPPER_BOUNDS, BRACKET_INDEX_MAX, SAMPLE_THRESHOLD, UPPER_BOUNDS, PRICE_RANGE_INDEX_MAX,
AnchoringSampleType, AnchoringSampleType,
) )
from anchoring.base_table import get_base_rate_permille # 정적 테이블 조회 (§2) from anchoring.base_table import get_base_anchoring_value # 정적 테이블 조회 (§2)
def calc_bracket_index(target_price: int) -> int: def calc_price_range_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리. """목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프. 좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
정적 테이블 idx = 반환값 + 1""" 정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX) return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int: def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
"""앵커링가 = 목표가 × (1 A), 1원 단위 내림. §4.2 (정수 연산만)""" """앵커링가 = 목표가 × (1 A), 1원 단위 내림. §4.2 (정수 연산만)"""
return target_price * (1000 - rate_permille) // 1000 return target_price * (1000 - anchoring_value) // 1000
def judge_sample_type( def judge_sample_type(
is_done: bool, # sessions.status == DONE(3) is_done: bool, # sessions.status == DONE(3)
bid_price: int | None, # 확정 투찰가(DONE 시) bid_price: int | None, # 확정 투찰가(DONE 시)
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음 last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커) anchor_price: int | None, # sessions.anchoring_price (박제 앵커)
) -> int: ) -> int:
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적.""" """배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
if anchor_price is None or last_offered_price is None: if anchor_price is None or last_offer_price is None:
return AnchoringSampleType.EXCLUDED.value return AnchoringSampleType.EXCLUDED.value
if is_done and bid_price is not None and bid_price <= anchor_price: if is_done and bid_price is not None and bid_price <= anchor_price:
return AnchoringSampleType.BID_SUCCESS.value return AnchoringSampleType.BID_SUCCESS.value
return AnchoringSampleType.BID_FAIL.value return AnchoringSampleType.BID_FAIL.value
def evaluate_pending( def evaluate_samples(
rate_before: int, value_before: int,
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드 sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
supplier_type: int, # SMALLINT 코드 1/2/3 supplier_type: int, # SMALLINT 코드 1/2/3
) -> int | None: ) -> tuple[int, bool] | None:
"""누적 전량 평가. §4.4 """누적 전량 평가. §4.4
반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월) 반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월)
clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와
구분되는 실제 포화 신호(운영 지표용).
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다. 호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
""" """
n = len(sample_types) n = len(sample_types)
@ -539,24 +545,25 @@ def evaluate_pending(
return None return None
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value) success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
delta = DELTA_PERMILLE[supplier_type] delta = ADJUSTMENT_STEP[supplier_type]
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피) # r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
if success * 10 >= n * 6: if success * 10 >= n * 6:
adjusted = rate_before + delta adjusted = value_before + delta
elif success * 10 < n * 3: # r < 0.30 elif success * 10 < n * 3: # r < 0.30
adjusted = rate_before - delta adjusted = value_before - delta
else: # 0.30 ≤ r < 0.60 else: # 0.30 ≤ r < 0.60
adjusted = rate_before adjusted = value_before
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted)) value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted))
return value_after, value_after != adjusted
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int: def get_current_value(latest_adjusted_rate: int | None, price_range_index: int) -> int:
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값.""" """현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
if latest_adjusted_rate is not None: if latest_adjusted_rate is not None:
return latest_adjusted_rate return latest_adjusted_rate
return get_base_rate_permille(bracket_index) # int(round(anchoring_value * 1000)) return get_base_anchoring_value(price_range_index) # int(round(anchoring_value * 1000))
``` ```
```sql ```sql
@ -564,12 +571,12 @@ def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> in
-- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어 -- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어
-- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지. -- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지.
SELECT s.session_id, s.status, s.bid_price, s.target_price, SELECT s.session_id, s.status, s.bid_price, s.target_price,
s.target_anchoring_price, s.last_offered_price, s.anchoring_price, s.last_offer_price,
q.supplier_type, i.company_id q.supplier_type, i.company_id
FROM negotiation.sessions s FROM negotiation.sessions s
LEFT JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false 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 LEFT JOIN partner.items i ON i.item_id = s.item_id AND i.deleted = false
WHERE s.anchoring_adjustment_id IS NULL WHERE s.used_by_adjustment_id IS NULL
AND s.deleted = false AND s.deleted = false
AND s.qt_type = 1 AND s.qt_type = 1
AND s.status IN (3, 4, 5) AND s.status IN (3, 4, 5)
@ -577,9 +584,9 @@ WHERE s.anchoring_adjustment_id IS NULL
```sql ```sql
-- 현재 값 조회 (캐시 미스 시) -- 현재 값 조회 (캐시 미스 시)
SELECT anchor_rate_after SELECT anchoring_value_after
FROM anchoring.rate_adjustments FROM anchoring.adjustments
WHERE company_id = :c AND supplier_type = :p AND price_bracket_index = :b WHERE company_id = :c AND supplier_type = :p AND price_range_index = :b
ORDER BY id DESC ORDER BY id DESC
LIMIT 1 LIMIT 1
``` ```
@ -601,7 +608,7 @@ LIMIT 1
### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함) ### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
| target_price | bracket_index | 정적 테이블 idx | 칸 | | target_price | price_range_index | 정적 테이블 idx | 칸 |
|---|---|---|---| |---|---|---|---|
| 0 | 0 | 1 | [0, 1,000) 통일 칸 | | 0 | 0 | 1 | [0, 1,000) 통일 칸 |
| 999 | 0 | 1 | [0, 1,000) | | 999 | 0 | 1 | [0, 1,000) |
@ -616,9 +623,9 @@ LIMIT 1
정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000. 정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000.
### 11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10) ### 11.3 누적 전량 평가 (유통 코드1, δ=20, value_before=10)
| pending 구성 | n | r | 판정 | anchor_rate_after | | pending 구성 | n | r | 판정 | anchoring_value_after |
|---|---|---|---|---| |---|---|---|---|---|
| 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** | | 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** |
| 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** | | 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** |
@ -627,20 +634,20 @@ LIMIT 1
| 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** | | 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** |
| 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None | | 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None |
**δ 스왑 가드 (MUST)**: `evaluate_pending(10, [성공10/10], supplier_type=2) == 20` (제조 +10), `supplier_type=3 → 25` (총판 +15). **δ 스왑 가드 (MUST)**: `evaluate_samples(10, [성공10/10], supplier_type=2) == (20, False)` (제조 +10), `supplier_type=3 → (25, False)` (총판 +15).
clamp·격리 케이스: clamp·격리 케이스:
| 시나리오 | 기대 | | 시나리오 | 기대 |
|---|---| |---|---|
| rate_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 | | value_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 |
| A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 | | A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 |
| 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 | | 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 |
| EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 마킹 0 처리 | | EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 마킹 0 처리 |
### 11.4 파생 판정 ### 11.4 파생 판정
| status | bid_price | last_offered_price | anchor_price | 기대 | | status | bid_price | last_offer_price | anchor_price | 기대 |
|---|---|---|---|---| |---|---|---|---|---|
| DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) | | DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) |
| DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) | | DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) |
@ -653,7 +660,7 @@ clamp·격리 케이스:
| 시나리오 | 기대 | | 시나리오 | 기대 |
|---|---| |---|---|
| 유효 13건 시드 → 배치 | 조정 1행(n=13, consumed_session_ids 13개 박제, 10→30) + 13건 모두 `anchoring_adjustment_id`=조정 id | | 유효 13건 시드 → 배치 | 조정 1행(n=13, used_session_ids 13개 박제, 10→30) + 13건 모두 `used_by_adjustment_id`=조정 id |
| 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 마킹 멱등) | | 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 마킹 멱등) |
| 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 | | 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 |
| 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 | | 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 |
@ -670,8 +677,8 @@ clamp·격리 케이스:
| 시나리오 | 기대 | | 시나리오 | 기대 |
|---|---| |---|---|
| 재협상 채팅 → 가격 입력 턴 | `last_offered_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) | | 재협상 채팅 → 가격 입력 턴 | `last_offer_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) |
| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offered_price` 보존 → 배치에서 BID_FAIL 표본 | | 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offer_price` 보존 → 배치에서 BID_FAIL 표본 |
| 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) | | 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) |
| 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 | | 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 |
| Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) | | Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) |
@ -685,7 +692,7 @@ clamp·격리 케이스:
- **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현). - **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
- **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안. - **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
- **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계. - **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
- **sessions 판정 입력 컬럼(`target_anchoring_price`, `anchor_rate_permille`)의 사후 수정, `last_offered_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `anchoring_adjustment_id` 단 하나. - **sessions 판정 입력 컬럼(`anchoring_price`, `anchoring_value`)의 사후 수정, `last_offer_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `used_by_adjustment_id` 단 하나.
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정. - **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
- **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐. - **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
- **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립. - **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립.
@ -700,13 +707,13 @@ clamp·격리 케이스:
| # | 항목 | 담당 | 상태 | | # | 항목 | 담당 | 상태 |
|---|---|---|---| |---|---|---|---|
| 1 | DDL — `anchoring.rate_adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) | | 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `postgres-init/05-anchoring-schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) |
| 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체**`_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 | | 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체**`_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 |
| 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 | | 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 |
| 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) | | 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) |
| 5 | company_id 식별 | — | `partner.items.company_id` (세션→item 조인, 기존 `_agent_context` 해석 방식과 동일) | | 5 | company_id 식별 | — | `partner.items.company_id` (세션→item 조인, 기존 `_agent_context` 해석 방식과 동일) |
| 6 | `quotations.supplier_type` 기록 | [인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) | | 6 | `quotations.supplier_type` 기록 | [인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) |
| 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 | | 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 |
| 8 | Redis 인프라 | [우리 — 모듈] | 모듈 docker-compose 에 redis 동봉, negodata 가 같은 인스턴스 참조. backend 는 Redis 무의존 | | 8 | Redis 인프라 | [우리 — 모듈] | 루트 docker-compose 에 redis 동봉(모듈 전용 캐시). negodata 는 무Redis(뷰 직조회)·backend 도 무의존 |
| 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 | | 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 |
| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offered_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 | | 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offer_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 |

View File

@ -32,7 +32,7 @@
``` ```
[anchoring 컨테이너] ──── 격주 배치 실행 (APScheduler 내장) [anchoring 컨테이너] ──── 격주 배치 실행 (APScheduler 내장)
│ 읽기: negotiation.sessions / quotation.quotations / partner.items │ 읽기: negotiation.sessions / quotation.quotations / partner.items
│ 쓰기: anchoring.rate_adjustments (조정 이력) + sessions.anchoring_adjustment_id (채점 마킹) │ 쓰기: anchoring.adjustments (조정 이력) + sessions.used_by_adjustment_id (채점 마킹)
[PostgreSQL (외부, negosium_db)] [anchoring-redis 컨테이너] [PostgreSQL (외부, negosium_db)] [anchoring-redis 컨테이너]
진실 원천 — 영구 이력 조회 캐시(사본) — 없어져도 복구됨 진실 원천 — 영구 이력 조회 캐시(사본) — 없어져도 복구됨
@ -54,27 +54,35 @@
### STEP 1 — DB 스키마 적용 (최초 1회) ### STEP 1 — DB 스키마 적용 (최초 1회)
```bash ```bash
cd schedules/anchoring psql -h <DB호스트> -U <계정> -d negosium_db -f postgres-init/05-anchoring-schema.sql # 레포 루트에서
psql -h <DB호스트> -U <계정> -d negosium_db -f schema.sql
``` ```
- 테이블 1개(`anchoring.rate_adjustments`)·조회용 뷰 2개(`rate_history`, `current_rates`)와 `negotiation.sessions` 컬럼 3개를 추가합니다. - 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)·인덱스를 추가합니다.
(`negotiation.sessions` 앵커링 컬럼은 `postgres-init/01-schema*.sql`(신규)·`04-alter*.sql`(기존) 소관)
- `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다. - `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다.
### STEP 2 — 설정 채우기 ### STEP 2 — 설정 채우기
```bash ```bash
cp config.toml.example config.toml # 실행 환경(APP_ENV, 기본 local)에 맞는 파일을 만든다 — local/dev/prod
# config.toml 열어서 [db] 호스트/계정/비밀번호 채우기 cp config.toml.example config.local.toml
# config.local.toml 열어서 [db] 호스트/계정/비밀번호 채우기 (dev/prod 는 config.dev.toml/config.prod.toml)
``` ```
환경변수로 덮어쓸 수도 있습니다(우선순위: env > config.toml > 기본값): - 환경 선택은 `APP_ENV` 환경변수(기본 `local``config.local.toml`). **dev/prod 는 파일이 없으면 기동이 즉시 중단**됩니다(오타·미배치 상태로 로컬 기본값에 붙는 사고 방지). local 은 파일 없이도 코드 기본값으로 뜹니다.
환경변수로 덮어쓸 수도 있습니다(우선순위: env > config.{APP_ENV}.toml > 기본값):
`DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` / `REDIS_HOST` `REDIS_PORT` `REDIS_DB` `REDIS_PASSWORD` / `LOG_LEVEL` `DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` / `REDIS_HOST` `REDIS_PORT` `REDIS_DB` `REDIS_PASSWORD` / `LOG_LEVEL`
> config.{env}.toml 은 **이미지에 들어가지 않습니다**(시크릿이 이미지 레이어에 남는 것을 방지 — .dockerignore 로도 차단).
> 도커 실행 시 compose 가 읽기 전용 마운트하므로, **`docker compose up` 전에 해당 환경 파일이 반드시 존재해야 합니다**
> (없이 up 하면 docker 가 같은 이름의 디렉터리를 만들어 기동에 실패합니다). 루트 compose 는 APP_ENV=local + config.local.toml 마운트.
### STEP 3-A — 도커로 실행 (운영 권장) ### STEP 3-A — 도커로 실행 (운영 권장)
```bash ```bash
docker compose up -d --build # 레포 루트에서 (anchoring 서비스는 루트 docker-compose.yml 에 통합됨)
docker compose up -d --build anchoring anchoring-redis
docker logs -f anchoring # 기동 로그 확인 (아래 §4) docker logs -f anchoring # 기동 로그 확인 (아래 §4)
``` ```
@ -127,7 +135,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/ -q # 전부 passed 기대(
### 로그 한 줄의 구조 ### 로그 한 줄의 구조
``` ```
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 2026-07-02 16:45:12+0900 INFO anchoring [batch 20260702-164512] 조정 company=f23c… type=1 price_range=10 n=13 성공=8 10‰→30‰ adj_id=32
└──── 시각(항상 KST) ──┘ └레벨┘ └── 회차 태그 ──────┘ └──────────────── 내용 (key=value 형식) ────────────────┘ └──── 시각(항상 KST) ──┘ └레벨┘ └── 회차 태그 ──────┘ └──────────────── 내용 (key=value 형식) ────────────────┘
``` ```
@ -174,8 +182,8 @@ docker logs anchoring | tail -20 # 최근 상태
| 수동 배치 1회 (격주 게이트 무시) | `docker exec anchoring python -m anchoring.main --once` | | 수동 배치 1회 (격주 게이트 무시) | `docker exec anchoring python -m anchoring.main --once` |
| **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 | | **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 |
| 재기동 | `docker compose restart anchoring` | | 재기동 | `docker compose restart anchoring` |
| 서비스 중지/시작 | `docker compose stop` / `docker compose up -d` | | 서비스 중지/시작 | `docker compose stop anchoring anchoring-redis` / `docker compose up -d anchoring anchoring-redis` (레포 루트에서) |
| 설정 변경 반영 | config.toml 수정 → `docker compose up -d --build` | | 설정 변경 반영 | config.{env}.toml 수정 → `docker compose restart anchoring` (파일은 마운트라 리빌드 불필요) |
| 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` | | 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` |
| 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 | | 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 |
@ -184,11 +192,13 @@ docker logs anchoring | tail -20 # 최근 상태
| 증상 (로그 메시지) | 원인 | 조치 | | 증상 (로그 메시지) | 원인 | 조치 |
|---|---|---| |---|---|---|
| 기동 실패 + `BaseTableError: 정적 테이블 …` | `resources/anchoring_base.json` 손상/수정됨 | **의도된 안전장치** — git 으로 파일 원복 후 재기동. 이 파일은 절대 수정 금지 | | 기동 실패 + `BaseTableError: 정적 테이블 …` | `resources/anchoring_base.json` 손상/수정됨 | **의도된 안전장치** — git 으로 파일 원복 후 재기동. 이 파일은 절대 수정 금지 |
| 기동 실패 + DB 연결 예외 | config.toml/env 의 DB 접속 정보 오류 | 접속 정보 확인, `psql` 로 직접 접속 테스트 | | 기동 실패 + DB 연결 예외 | config.{env}.toml/env 의 DB 접속 정보 오류(dev/prod 의 CHANGE_ME 미기입 포함) | 접속 정보 확인, `psql` 로 직접 접속 테스트 |
| 기동 실패 + `설정 파일이 없습니다 (APP_ENV=…)` | dev/prod 인데 config.{env}.toml 미배치 | `cp config.toml.example config.{env}.toml` 채우고 재기동 |
| 기동 실패 + `config.local.toml` 이 **디렉터리**로 생겨 있음 | 파일 없이 `docker compose up` — 마운트 대상이 없어 docker 가 디렉터리를 만듦 | `docker compose down` → 디렉터리 삭제 → `cp config.toml.example config.local.toml` 채우고 재기동 |
| `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 | | `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 |
| `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 | | `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 |
| `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 | | `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 |
| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service``last_offered_price` 갱신 경로 점검 요청 | | `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service``last_offer_price` 갱신 경로 점검 요청 |
| `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 | | `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 |
| `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 | | `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 |
| 종료 요약이 WARNING (`failed_cells > 0`) | 일부 칸 실패 | 바로 위 ERROR 라인들 확인 | | 종료 요약이 WARNING (`failed_cells > 0`) | 일부 칸 실패 | 바로 위 ERROR 라인들 확인 |
@ -201,35 +211,35 @@ docker logs anchoring | tail -20 # 최근 상태
```sql ```sql
-- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에 -- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에
SELECT * FROM anchoring.rate_history SELECT * FROM anchoring.value_history
WHERE company_id = '<uuid>' WHERE company_id = '<uuid>'
ORDER BY adjustment_id; ORDER BY adjustment_id;
-- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%) -- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%)
SELECT * FROM anchoring.current_rates SELECT * FROM anchoring.current_values
WHERE company_id = '<uuid>'; WHERE company_id = '<uuid>';
-- ② 특정 조정(adj_id)의 근거가 된 협상들 -- ② 특정 조정(adj_id)의 근거가 된 협상들
SELECT s.session_id, s.status, s.target_anchoring_price, s.last_offered_price, s.bid_price SELECT s.session_id, s.status, s.anchoring_price, s.last_offer_price, s.bid_price
FROM negotiation.sessions s FROM negotiation.sessions s
WHERE s.session_id IN ( WHERE s.session_id IN (
SELECT jsonb_array_elements_text(consumed_session_ids)::uuid SELECT jsonb_array_elements_text(used_session_ids)::uuid
FROM anchoring.rate_adjustments WHERE id = <adj_id> FROM anchoring.adjustments WHERE id = <adj_id>
); );
-- ③ 특정 협상이 어느 조정에 채점됐나 -- ③ 특정 협상이 어느 조정에 채점됐나
SELECT anchoring_adjustment_id FROM negotiation.sessions WHERE session_id = '<uuid>'; SELECT used_by_adjustment_id FROM negotiation.sessions WHERE session_id = '<uuid>';
-- NULL = 아직 채점 전(다음 회차로 이월) / 0 = 채점 제외 확정 / 숫자 = 해당 조정 id → ② 로 -- NULL = 아직 채점 전(다음 회차로 이월) / 0 = 채점 제외 확정 / 숫자 = 해당 조정 id → ② 로
``` ```
로그의 `adj_id=32` ↔ DB 의 `rate_adjustments.id=32` 가 같은 것을 가리킵니다. 로그의 `adj_id=32` ↔ DB 의 `adjustments.id=32` 가 같은 것을 가리킵니다.
## 9. 절대 하면 안 되는 것 ## 9. 절대 하면 안 되는 것
이 시스템의 신뢰성은 "기록이 불변"이라는 전제 위에 서 있습니다 (상세 근거: `개발용.md` §12). 이 시스템의 신뢰성은 "기록이 불변"이라는 전제 위에 서 있습니다 (상세 근거: `개발용.md` §12).
- ❌ `anchoring.rate_adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천 - ❌ `anchoring.adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천
- ❌ `sessions``target_anchoring_price` / `anchor_rate_permille` 수동 수정 — 채점 근거가 오염됨 - ❌ `sessions``anchoring_price` / `anchoring_value` 수동 수정 — 채점 근거가 오염됨
- ❌ `resources/anchoring_base.json`(기준표) 수정 — 검증 실패로 기동이 막히며, 값 변경은 정책 재확정 사안 - ❌ `resources/anchoring_base.json`(기준표) 수정 — 검증 실패로 기동이 막히며, 값 변경은 정책 재확정 사안
- ❌ 상수(조정폭 δ, 경계 60/30, 상·하한, 10건 임계, 배치 주기) 임의 변경 — 전부 정책 고정값 - ❌ 상수(조정폭 δ, 경계 60/30, 상·하한, 10건 임계, 배치 주기) 임의 변경 — 전부 정책 고정값
- ❌ anchoring 컨테이너를 **2개 이상 동시 실행** — 중복 조정 방지 장치(롤백)가 막아주긴 하지만 설계상 단일 인스턴스가 원칙 - ❌ anchoring 컨테이너를 **2개 이상 동시 실행** — 중복 조정 방지 장치(롤백)가 막아주긴 하지만 설계상 단일 인스턴스가 원칙
@ -246,4 +256,4 @@ docker logs anchoring | grep "다음 실행 예정" # ③ (재기
- ① 이 비어 있고 ② 가 `'status': 'done'` 이면 끝. - ① 이 비어 있고 ② 가 `'status': 'done'` 이면 끝.
- `carryover_cells`(이월)가 계속 크기만 하고 `evaluated_cells` 가 0인 상태가 몇 달 지속되면 거래량 자체가 적은 것 — 장애가 아니라 정책 검토(희소 칸 과제, `기획용.md` FAQ) 대상입니다. - `carryover_cells`(이월)가 계속 크기만 하고 `evaluated_cells` 가 0인 상태가 몇 달 지속되면 거래량 자체가 적은 것 — 장애가 아니라 정책 검토(희소 칸 과제, `기획용.md` FAQ) 대상입니다.
- 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`rate_adjustments` 는 영구 보존 대상). - 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`adjustments` 는 영구 보존 대상).

View File

@ -17,7 +17,7 @@
|---|---|---| |---|---|---|
| **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 | | **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 |
| **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 | | **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 |
| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 | | **조정 장부** (`anchoring.adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 | | **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다. 여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
@ -44,7 +44,7 @@
### ③ 협력사가 가격을 써낼 때 — "가격 흔적" ### ③ 협력사가 가격을 써낼 때 — "가격 흔적"
협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offered_price`). 협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offer_price`).
이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다. 이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다.

View File

@ -6,7 +6,7 @@
## 배경 한 줄 ## 배경 한 줄
앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.rate_adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다. 앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다.
## 전달물 (→ 각 담당자) ## 전달물 (→ 각 담당자)
@ -14,14 +14,21 @@
|---|---| |---|---|
| `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/` 모듈 | `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/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) |
| `schedules/anchoring/schema.sql` | `anchoring.rate_adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) | | `postgres-init/05-anchoring-schema.sql` | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 01/04 소관) |
| `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉**negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) | | 루트 `docker-compose.yml` | anchoring 서비스 + redis 동봉(모듈 전용 캐시) — **negodata 는 Redis 를 쓰지 않는다**(`current_values` 뷰 직조회) |
| 이 문서 | 적용 위치·변경 전후 명세 | | 이 문서 | 적용 위치·변경 전후 명세 |
--- ---
## 1. negodata 변경 (견적 생성 측) ## 1. negodata 변경 (견적 생성 측)
> ✅ **적용 완료 (2026-07-04)** — negodata 담당자 승인 하에 backend 담당(민헌)이 이 절을 직접 적용했다.
>
> - 이식 위치: `negodata/backend/common/anchoring/` (constants·base_table·service 는 읽기 경로 발췌, reader 는 이식판) + `_build_quotation` 앵커 산출 교체 + 재생성 앵커 상속 폐지 + `sessions.anchoring_value` 모델 매핑
> - **이식판 reader 는 Redis 캐시를 쓰지 않는다**: `anchoring.current_values` 뷰 단일 쿼리 → 실패·무이력 시 정적 테이블 폴백. (2026-07-03 단순화 결정 — 조회가 견적 생성 시 1회뿐이라 캐시 불필요. 모듈 쪽 Redis 제거는 후속 백로그로 진행)
> - 검증: negodata 테스트 스위트 50종 통과 — 앵커링 신설 5종(스키마 부재 폴백·칸별 조정 반영·유형 미지정 폴백·재생성 앵커 재계산·`calc_price_range_index` 경계 골든 벡터) 포함
> - 전환기 점프 확인(§3-③): 적용 시점 `adjustments` 0건 → 점프 없음(시작값 10‰ = 구 기본 `anchoring_value` 0.01 과 동일)
### 1.1 변경 대상 ### 1.1 변경 대상
`negodata/backend/services/quotation_service.py``_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근). `negodata/backend/services/quotation_service.py``_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
@ -47,15 +54,15 @@ else:
# ① 칸 해석 # ① 칸 해석
# company_id = items.company_id (해당 상품의 소유 회사) # company_id = items.company_id (해당 상품의 소유 회사)
# supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3) # supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
# bracket = calc_bracket_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식 # price_range = calc_price_range_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
# ② rate 조회 — 전달받은 reader 모듈 사용 # ② rate 조회 — 전달받은 reader 모듈 사용
rate = await get_anchor_rate(db, company_id, supplier_type, bracket) # db = AsyncSession value = await get_current_anchoring_value(db, company_id, supplier_type, price_range) # db = AsyncSession
# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰) # 내부 동작: Redis GET → miss 시 anchoring.adjustments 최신 행 → 없으면 정적 테이블(10‰)
# supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값) # supplier_type ∉ {1,2,3} 이면 get_base_anchoring_value(price_range) 사용 (정적 테이블 시작값)
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가) # ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
ap = tp * (1000 - rate) // 1000 ap = tp * (1000 - rate) // 1000
# ④ 세션 INSERT 에 두 컬럼 모두 박제 # ④ 세션 INSERT 에 두 컬럼 모두 박제
sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...) sessions(..., anchoring_price=ap, anchoring_value=rate, ...)
``` ```
### 1.4 필수 규칙 ### 1.4 필수 규칙
@ -64,7 +71,7 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
2. **정수 연산 MUST**: `tp * (1000 - rate) // 1000`. 부동소수점 곱셈(`int(tp * (1 - x))`, `round(...)`) 금지 — 1원 단위 내림의 정확성 보장. 2. **정수 연산 MUST**: `tp * (1000 - rate) // 1000`. 부동소수점 곱셈(`int(tp * (1 - x))`, `round(...)`) 금지 — 1원 단위 내림의 정확성 보장.
3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택). 3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택).
4. **`quotations.supplier_type` 기록 유지**: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외). 4. **`quotations.supplier_type` 기록 유지**: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외).
5. **박제 후 수정 금지**: `sessions.target_anchoring_price` / `anchor_rate_permille` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다. 5. **박제 후 수정 금지**: `sessions.anchoring_price` / `anchoring_value` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다. 6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.
### 1.5 적용 전(전환기) 동작 ### 1.5 적용 전(전환기) 동작
@ -75,18 +82,18 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
## 2. agent — **변경 없음** ## 2. agent — **변경 없음**
앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offered_price`). 앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offer_price`).
--- ---
## 3. 적용 순서 (권장) ## 3. 적용 순서 (권장)
``` ```
① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개) ① DB 스키마 적용 (postgres-init/05-anchoring-schema.sql — adjustments·뷰·인덱스, sessions 컬럼은 01/04)
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작) ② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작) + backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
③ 전환기 점프 확인 (negodata 적용 직전): ③ 전환기 점프 확인 (negodata 적용 직전):
SELECT max(anchor_rate_after) FROM anchoring.current_rates; SELECT max(anchoring_value) FROM anchoring.current_values;
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다 — ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다
("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부 ("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부
또는 이력 리셋을 정책 결정 후 진행. 또는 이력 리셋을 정책 결정 후 진행.

View File

@ -0,0 +1,112 @@
-- ============================================================
-- 앵커링 이름 전면 개편 마이그레이션 (2026-07-06)
-- rate_adjustments → adjustments (+컬럼 6종)
-- sessions 앵커링 컬럼 4종, 뷰 2종(rate_history/current_rates → value_history/current_values)
-- 대상: 구 이름 스키마가 이미 적용된 기존 DB (신규 DB 는 postgres-init/01~05 적용으로 충분)
-- 멱등: 각 rename 은 구 이름 존재 시에만 수행 — 재실행·부분 적용 상태에서도 안전
-- 적용: psql -h <host> -U <user> -d negosium_db -f migrations/20260706_rename_anchoring.sql
-- ============================================================
\connect negosium_db
BEGIN;
-- 1) 구 이름 뷰 제거 (신 이름 뷰는 마지막에 재생성 — 정의는 postgres-init/05-anchoring-schema.sql 과 동일)
DROP VIEW IF EXISTS anchoring.rate_history;
DROP VIEW IF EXISTS anchoring.current_rates;
-- 2) 테이블·시퀀스·인덱스 rename
DO $$
BEGIN
IF EXISTS (SELECT FROM information_schema.tables
WHERE table_schema = 'anchoring' AND table_name = 'rate_adjustments') THEN
ALTER TABLE anchoring.rate_adjustments RENAME TO adjustments;
END IF;
IF EXISTS (SELECT FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'anchoring' AND c.relname = 'rate_adjustments_id_seq') THEN
ALTER SEQUENCE anchoring.rate_adjustments_id_seq RENAME TO adjustments_adjustment_id_seq;
END IF;
IF EXISTS (SELECT FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'anchoring' AND c.relname = 'idx_rate_adjustments_cell') THEN
ALTER INDEX anchoring.idx_rate_adjustments_cell RENAME TO idx_adjustments_cell;
END IF;
-- 테이블 rename 은 PK 제약 이름을 바꾸지 않는다 — 함께 정리
IF EXISTS (SELECT FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace
WHERE n.nspname = 'anchoring' AND con.conname = 'rate_adjustments_pkey') THEN
ALTER TABLE anchoring.adjustments RENAME CONSTRAINT rate_adjustments_pkey TO adjustments_pkey;
END IF;
END $$;
-- 3) adjustments 컬럼 rename
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT * FROM (VALUES
('id', 'adjustment_id'),
('price_bracket_index', 'price_range_index'),
('nego_count', 'sample_count'),
('anchor_rate_before', 'anchoring_value_before'),
('anchor_rate_after', 'anchoring_value_after'),
('consumed_session_ids', 'used_session_ids')
) AS m(old_name, new_name)
LOOP
IF EXISTS (SELECT FROM information_schema.columns
WHERE table_schema = 'anchoring' AND table_name = 'adjustments'
AND column_name = r.old_name) THEN
EXECUTE format('ALTER TABLE anchoring.adjustments RENAME COLUMN %I TO %I',
r.old_name, r.new_name);
END IF;
END LOOP;
END $$;
-- 4) negotiation.sessions 앵커링 컬럼 rename
-- (부분 인덱스 idx_sessions_anchoring_pending 술어는 컬럼 rename 을 자동 추종 — 재생성 불필요)
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT * FROM (VALUES
('target_anchoring_price', 'anchoring_price'),
('anchor_rate_permille', 'anchoring_value'),
('last_offered_price', 'last_offer_price'),
('anchoring_adjustment_id','used_by_adjustment_id')
) AS m(old_name, new_name)
LOOP
IF EXISTS (SELECT FROM information_schema.columns
WHERE table_schema = 'negotiation' AND table_name = 'sessions'
AND column_name = r.old_name) THEN
EXECUTE format('ALTER TABLE negotiation.sessions RENAME COLUMN %I TO %I',
r.old_name, r.new_name);
END IF;
END LOOP;
END $$;
-- 5) 신 이름 뷰 재생성 (postgres-init/05-anchoring-schema.sql §조회용 뷰와 동일 정의)
CREATE OR REPLACE VIEW anchoring.value_history AS
SELECT adjustment_id,
company_id,
supplier_type,
price_range_index,
anchoring_value_before,
anchoring_value_after,
anchoring_value_after - anchoring_value_before AS value_change,
sample_count,
success_count,
round(success_count::numeric / sample_count, 3) AS success_rate,
created_at
FROM anchoring.adjustments;
CREATE OR REPLACE VIEW anchoring.current_values AS
SELECT DISTINCT ON (company_id, supplier_type, price_range_index)
company_id,
supplier_type,
price_range_index,
anchoring_value_after AS anchoring_value,
adjustment_id AS last_adjustment_id,
created_at AS last_adjusted_at
FROM anchoring.adjustments
ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC;
COMMIT;

View File

@ -1,72 +0,0 @@
-- ============================================================
-- anchoring 모듈 DDL (모듈 소유 — postgres-init 에 두지 않는다)
-- 적용: psql -h <host> -U <user> -d negosium_db -f schema.sql
-- 신규 DB 구축 순서: postgres-init/01~04 → 이 파일
-- 규범: docs/개발용.md §6. IF NOT EXISTS 라 재적용 안전.
-- 컨벤션: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC).
-- ============================================================
\connect negosium_db
CREATE SCHEMA IF NOT EXISTS anchoring;
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell
ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC);
-- 선행요건 §13 + 소비 마킹. target_anchoring_price 는 기존 컬럼(negodata 가 생성 시 박제).
ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 가격 입력마다 backend 가 갱신, 종료 후 불변. NULL=가격 흔적 없음(표본 제외)
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (부분 인덱스).
-- qt_type=1 을 술어에 포함해야 함 — 빼면 배치가 마킹하지 않는 비재협상 세션이
-- 영구 잔류해 인덱스가 전체 세션 수에 비례해 성장한다(의도는 이월 풀만 담는 소형 인덱스).
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (status)
WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
-- ============================================================
-- 조회용 뷰 (파생 — 상태 없음, 진실 원천은 rate_adjustments)
-- ============================================================
-- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로"
CREATE OR REPLACE VIEW anchoring.rate_history AS
SELECT id AS adjustment_id,
company_id,
supplier_type, -- 1유통/2제조/3총판
price_bracket_index, -- 0..45 자릿수 사다리
anchor_rate_before, -- 이전 값(‰)
anchor_rate_after, -- 새 값(‰)
anchor_rate_after - anchor_rate_before AS delta_permille,
nego_count,
success_count,
round(success_count::numeric / nego_count, 3) AS success_rate,
created_at
FROM anchoring.rate_adjustments;
-- 칸별 현재값: 칸의 최신 조정 행. 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
CREATE OR REPLACE VIEW anchoring.current_rates AS
SELECT DISTINCT ON (company_id, supplier_type, price_bracket_index)
company_id,
supplier_type,
price_bracket_index,
anchor_rate_after AS anchor_rate_permille,
id AS last_adjustment_id,
created_at AS last_adjusted_at
FROM anchoring.rate_adjustments
ORDER BY company_id, supplier_type, price_bracket_index, id DESC;

View File

@ -6,11 +6,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증
import json import json
from pathlib import Path from pathlib import Path
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json" _RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
_rates: list[int] | None = None # bracket_index → 시작값(‰) _values: list[int] | None = None # price_range_index → 시작값(‰)
class BaseTableError(RuntimeError): class BaseTableError(RuntimeError):
@ -22,9 +22,9 @@ def _validate(rows: list) -> list[int]:
규약(§2.1): 46 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 0.01~0.20. 규약(§2.1): 46 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 0.01~0.20.
""" """
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT: if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}") raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}")
rates: list[int] = [] values: list[int] = []
for i, row in enumerate(rows): for i, row in enumerate(rows):
idx = row.get("idx") idx = row.get("idx")
ub = row.get("upper_bound") ub = row.get("upper_bound")
@ -35,24 +35,24 @@ def _validate(rows: list) -> list[int]:
raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={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): 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}") raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
rates.append(int(round(av * 1000))) values.append(int(round(av * 1000)))
return rates return values
def load_base_table() -> None: def load_base_table() -> None:
"""리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등).""" """리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등)."""
global _rates global _values
if _rates is not None: if _values is not None:
return return
try: try:
rows = json.loads(_RESOURCE.read_text()) rows = json.loads(_RESOURCE.read_text())
except Exception as ex: except Exception as ex:
raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex
_rates = _validate(rows) _values = _validate(rows)
def get_base_rate_permille(bracket_index: int) -> int: def get_base_anchoring_value(price_range_index: int) -> int:
"""구간 인덱스 → 시작 앵커링 값(‰). §2.1""" """구간 인덱스 → 시작 앵커링 값(‰). §2.1"""
if _rates is None: if _values is None:
load_base_table() load_base_table()
return _rates[bracket_index] return _values[price_range_index]

View File

@ -11,10 +11,8 @@ from zoneinfo import ZoneInfo
from sqlalchemy import select, update from sqlalchemy import select, update
from anchoring.base_table import get_base_rate_permille from anchoring.base_table import get_base_anchoring_value
from anchoring.constants import ( from anchoring.constants import (
ANCHOR_RATE_MAX,
ANCHOR_RATE_MIN,
EVAL_WEEK_PARITY, EVAL_WEEK_PARITY,
MARK_EXCLUDED, MARK_EXCLUDED,
QT_TYPE_RENEGO, QT_TYPE_RENEGO,
@ -26,10 +24,10 @@ from anchoring.constants import (
) )
from anchoring.db import session_scope from anchoring.db import session_scope
from anchoring.log import LOG from anchoring.log import LOG
from anchoring.models import Item, Quotation, RateAdjustment, Session from anchoring.models import Item, Quotation, Adjustment, Session
from anchoring.reader import get_latest_adjusted_rate from anchoring.reader import get_latest_adjusted_value
from anchoring.redis_client import consume_failure_counts, ping, set_rate from anchoring.redis_client import consume_failure_counts, ping, set_value
from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type from anchoring.service import calc_anchoring_price, calc_price_range_index, evaluate_samples, judge_sample_type
KST = ZoneInfo("Asia/Seoul") KST = ZoneInfo("Asia/Seoul")
_MARK_CHUNK = 1000 _MARK_CHUNK = 1000
@ -45,34 +43,34 @@ def is_evaluation_week(now_kst: datetime) -> bool:
async def _reconcile_cache() -> int: async def _reconcile_cache() -> int:
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET. """절차 0.5 — 조정 이력 보유 칸 전체의 최신 anchoring_value 를 Redis 일괄 re-SET.
stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7). stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7).
""" """
stmt = ( stmt = (
select( select(
RateAdjustment.company_id, Adjustment.company_id,
RateAdjustment.supplier_type, Adjustment.supplier_type,
RateAdjustment.price_bracket_index, Adjustment.price_range_index,
RateAdjustment.anchor_rate_after, Adjustment.anchoring_value_after,
) )
.distinct( .distinct(
RateAdjustment.company_id, Adjustment.company_id,
RateAdjustment.supplier_type, Adjustment.supplier_type,
RateAdjustment.price_bracket_index, Adjustment.price_range_index,
) )
.order_by( .order_by(
RateAdjustment.company_id, Adjustment.company_id,
RateAdjustment.supplier_type, Adjustment.supplier_type,
RateAdjustment.price_bracket_index, Adjustment.price_range_index,
RateAdjustment.id.desc(), Adjustment.adjustment_id.desc(),
) )
) )
async with session_scope() as db: async with session_scope() as db:
rows = (await db.execute(stmt)).all() rows = (await db.execute(stmt)).all()
ok = 0 ok = 0
for company_id, stype, bracket, rate in rows: for company_id, stype, price_range, value in rows:
if await set_rate(company_id, stype, bracket, rate): if await set_value(company_id, stype, price_range, value):
ok += 1 ok += 1
return ok return ok
@ -90,9 +88,9 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
Session.status, Session.status,
Session.bid_price, Session.bid_price,
Session.target_price, Session.target_price,
Session.target_anchoring_price, Session.anchoring_price,
Session.anchor_rate_permille, Session.anchoring_value,
Session.last_offered_price, Session.last_offer_price,
Quotation.supplier_type, Quotation.supplier_type,
Item.company_id, Item.company_id,
) )
@ -107,7 +105,7 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
isouter=True, isouter=True,
) )
.where( .where(
Session.anchoring_adjustment_id.is_(None), Session.used_by_adjustment_id.is_(None),
Session.deleted.is_(False), Session.deleted.is_(False),
Session.qt_type == QT_TYPE_RENEGO, Session.qt_type == QT_TYPE_RENEGO,
Session.status.in_(TERMINAL_SESSION_STATUSES), Session.status.in_(TERMINAL_SESSION_STATUSES),
@ -125,15 +123,15 @@ async def _mark_sessions(db, session_ids: list, adjustment_id: int) -> int:
chunk = session_ids[i:i + _MARK_CHUNK] chunk = session_ids[i:i + _MARK_CHUNK]
res = await db.execute( res = await db.execute(
update(Session) update(Session)
.where(Session.session_id.in_(chunk), Session.anchoring_adjustment_id.is_(None)) .where(Session.session_id.in_(chunk), Session.used_by_adjustment_id.is_(None))
.values(anchoring_adjustment_id=adjustment_id) .values(used_by_adjustment_id=adjustment_id)
.execution_options(synchronize_session=False) .execution_options(synchronize_session=False)
) )
marked += res.rowcount marked += res.rowcount
return marked return marked
async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: list, async def _evaluate_cell(company_id, supplier_type: int, price_range: int, samples: list,
dry_run: bool = False) -> dict | None: dry_run: bool = False) -> dict | None:
"""칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST). """칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST).
@ -145,47 +143,50 @@ async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples:
sample_types = [st for _, st in samples] sample_types = [st for _, st in samples]
async with session_scope() as db: async with session_scope() as db:
latest = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket) latest = await get_latest_adjusted_value(db, company_id, supplier_type, price_range)
rate_before = latest if latest is not None else get_base_rate_permille(bracket) value_before = latest if latest is not None else get_base_anchoring_value(price_range)
rate_after = evaluate_pending(rate_before, sample_types, supplier_type) evaluated = evaluate_samples(value_before, sample_types, supplier_type)
if rate_after is None: # 방어적 재확인(호출측에서 n>=10 보장) if evaluated is None: # 방어적 재확인(호출측에서 n>=10 보장)
return None return None
value_after, clamped = evaluated
if dry_run: if dry_run:
return { return {
"adjustment_id": None, "adjustment_id": None,
"before": rate_before, "before": value_before,
"after": rate_after, "after": value_after,
"clamped": clamped,
"success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value), "success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
"n": len(samples), "n": len(samples),
} }
adjustment = RateAdjustment( adjustment = Adjustment(
company_id=company_id, company_id=company_id,
supplier_type=supplier_type, supplier_type=supplier_type,
price_bracket_index=bracket, price_range_index=price_range,
nego_count=len(samples), sample_count=len(samples),
success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value), success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
anchor_rate_before=rate_before, anchoring_value_before=value_before,
anchor_rate_after=rate_after, anchoring_value_after=value_after,
consumed_session_ids=[str(sid) for sid in session_ids], used_session_ids=[str(sid) for sid in session_ids],
) )
db.add(adjustment) db.add(adjustment)
await db.flush() # adjustment.id 확보 await db.flush() # adjustment_id 확보
marked = await _mark_sessions(db, session_ids, adjustment.id) marked = await _mark_sessions(db, session_ids, adjustment.adjustment_id)
if marked != len(session_ids): if marked != len(session_ids):
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백 # 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
raise MarkingConflictError( raise MarkingConflictError(
f"company={company_id} type={supplier_type} bracket={bracket} 마킹 {marked}/{len(session_ids)}" f"company={company_id} type={supplier_type} price_range={price_range} 마킹 {marked}/{len(session_ids)}"
) )
# 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복) # 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복)
await set_rate(company_id, supplier_type, bracket, rate_after) await set_value(company_id, supplier_type, price_range, value_after)
return { return {
"adjustment_id": adjustment.id, "adjustment_id": adjustment.adjustment_id,
"before": rate_before, "before": value_before,
"after": rate_after, "after": value_after,
"clamped": clamped,
"success": adjustment.success_count, "success": adjustment.success_count,
"n": len(samples), "n": len(samples),
} }
@ -205,7 +206,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
운영 실행 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북). 운영 실행 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북).
로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), /회사 단위 라인은 로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), /회사 단위 라인은
`company=` `type=` `bracket=` key=value 형식(회사별 grep `grep company=<uuid>`). `company=` `type=` `price_range=` key=value 형식(회사별 grep `grep company=<uuid>`).
""" """
now = datetime.now(KST) now = datetime.now(KST)
run_id = now.strftime("%Y%m%d-%H%M%S") run_id = now.strftime("%Y%m%d-%H%M%S")
@ -240,14 +241,14 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
priced = 0 priced = 0
snapshot_mismatch = [] snapshot_mismatch = []
for r in rows: for r in rows:
if r.last_offered_price is not None: if r.last_offer_price is not None:
priced += 1 priced += 1
# 박제 정합 감시: negodata 가 rate 와 anchor 를 함께 박제하기 시작하면(인수인계 적용 후) # 박제 정합 감시: negodata 가 anchoring_value 와 anchoring_price 를 함께 박제하기 시작하면(인수인계 적용 후)
# 정수식 tp*(1000-rate)//1000 과 박제 anchor 가 일치해야 한다 — 불일치 = 이식 오류 신호. # 정수식 tp*(1000-value)//1000 과 박제 anchoring_price 가 일치해야 한다 — 불일치 = 이식 오류 신호.
# 전환기(rate 미박제 = NULL)에는 자동 스킵된다. # 전환기(value 미박제 = NULL)에는 자동 스킵된다.
if (r.anchor_rate_permille is not None and r.target_anchoring_price is not None if (r.anchoring_value is not None and r.anchoring_price is not None
and r.target_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): and calc_anchoring_price(r.target_price, r.anchoring_value) != r.anchoring_price):
snapshot_mismatch.append(r.session_id) snapshot_mismatch.append(r.session_id)
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None: if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
excluded_ids.append(r.session_id) # 칸 구성 불가 excluded_ids.append(r.session_id) # 칸 구성 불가
@ -257,24 +258,24 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
sample_type = judge_sample_type( sample_type = judge_sample_type(
is_done=r.status == SESSION_STATUS_DONE, is_done=r.status == SESSION_STATUS_DONE,
bid_price=r.bid_price, bid_price=r.bid_price,
last_offered_price=r.last_offered_price, last_offer_price=r.last_offer_price,
anchor_price=r.target_anchoring_price, anchoring_price=r.anchoring_price,
) )
if sample_type == AnchoringSampleType.EXCLUDED.value: if sample_type == AnchoringSampleType.EXCLUDED.value:
excluded_ids.append(r.session_id) excluded_ids.append(r.session_id)
per_company[str(r.company_id)]["excluded"] += 1 per_company[str(r.company_id)]["excluded"] += 1
continue continue
bracket = calc_bracket_index(r.target_price) price_range = calc_price_range_index(r.target_price)
cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type)) cells[(r.company_id, r.supplier_type, price_range)].append((r.session_id, sample_type))
if snapshot_mismatch: if snapshot_mismatch:
sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5]) sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5])
LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 " LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 "
f"(정수식과 박제 anchor 불일치). 예: {sample}") f"(정수식과 박제 anchor 불일치). 예: {sample}")
# 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5) # 가격 제시율 — backend 의 last_offer_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
if rows and priced == 0: if rows and priced == 0:
LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) " LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offer_price 전무) "
f"— backend 가격 입력 기록 배선 점검 필요") f"— backend 가격 입력 기록 배선 점검 필요")
# 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라 # 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라
@ -293,18 +294,18 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음) # 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
evaluated = up = hold = down = clamped = failed = 0 evaluated = up = hold = down = clamped = failed = 0
carryover = 0 carryover = 0
for (company_id, stype, bracket), samples in cells.items(): for (company_id, stype, price_range), samples in cells.items():
agg = per_company[str(company_id)] agg = per_company[str(company_id)]
if len(samples) < SAMPLE_THRESHOLD: if len(samples) < SAMPLE_THRESHOLD:
carryover += 1 # 마킹하지 않음 = 이월(§4.4) carryover += 1 # 마킹하지 않음 = 이월(§4.4)
agg["carryover"] += 1 agg["carryover"] += 1
continue continue
try: try:
result = await _evaluate_cell(company_id, stype, bracket, samples, dry_run=dry_run) result = await _evaluate_cell(company_id, stype, price_range, samples, dry_run=dry_run)
except Exception as ex: except Exception as ex:
failed += 1 failed += 1
agg["failed"] += 1 agg["failed"] += 1
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} bracket={bracket}: {ex}", exc_info=True) LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} price_range={price_range}: {ex}", exc_info=True)
continue continue
if result is None: if result is None:
carryover += 1 carryover += 1
@ -314,7 +315,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
agg["evaluated"] += 1 agg["evaluated"] += 1
# 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인 # 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인
label = "조정예정" if dry_run else "조정" label = "조정예정" if dry_run else "조정"
LOG.info(f"{tag} {label} company={company_id} type={stype} bracket={bracket} " LOG.info(f"{tag} {label} company={company_id} type={stype} price_range={price_range} "
f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}" f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}"
f"adj_id={result['adjustment_id']}") f"adj_id={result['adjustment_id']}")
if result["after"] > result["before"]: if result["after"] > result["before"]:
@ -326,7 +327,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
else: else:
hold += 1 hold += 1
agg["hold"] += 1 agg["hold"] += 1
if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX): if result["clamped"]:
clamped += 1 clamped += 1
# 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로 # 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로

View File

@ -1,13 +1,17 @@
"""설정 — config.toml + env 오버라이드(env > toml > 기본값). """설정 — config.{APP_ENV}.toml + env 오버라이드(env > toml > 기본값).
자립 모듈: backend config 체계를 쓰지 않는다. 시크릿은 config.toml(.gitignore) 또는 env . 자립 모듈: backend config 체계를 쓰지 않지만 선택 방식은 동일 환경변수 APP_ENV(기본 local)
config.local.toml / config.dev.toml / config.prod.toml 고른다(전부 .gitignore, 시크릿은 파일 또는 env ).
local 파일이 없어도 코드 기본값으로 뜨지만(개발 편의), 환경은 파일이 없으면
즉시 중단한다 오타/미배치 상태로 기본값(로컬 DB) 붙는 사고 방지.
""" """
import os import os
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.toml" APP_ENV = os.environ.get("APP_ENV", "local")
_CONFIG_PATH = Path(__file__).resolve().parents[2] / f"config.{APP_ENV}.toml"
@dataclass @dataclass
@ -45,6 +49,11 @@ def _env(name: str, current, cast=str):
def load_config() -> Config: def load_config() -> Config:
cfg = Config() cfg = Config()
if not _CONFIG_PATH.exists() and APP_ENV != "local":
raise FileNotFoundError(
f"설정 파일이 없습니다: {_CONFIG_PATH} (APP_ENV={APP_ENV}). "
f"config.toml.example 을 복사해 채우거나 APP_ENV=local 로 실행하세요."
)
if _CONFIG_PATH.exists(): if _CONFIG_PATH.exists():
data = tomllib.loads(_CONFIG_PATH.read_text()) data = tomllib.loads(_CONFIG_PATH.read_text())
db = data.get("db", {}) db = data.get("db", {})

View File

@ -7,13 +7,13 @@ backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값
from enum import Enum from enum import Enum
# ── 앵커링 값(정수 천분율 ‰) ────────────────────────────── # ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
ANCHOR_RATE_MIN = 10 # 하한 1% ANCHORING_VALUE_MIN = 10 # 하한 1%
ANCHOR_RATE_MAX = 200 # 상한 20% ANCHORING_VALUE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2) # 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2)
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드 # 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름) # ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름)
DELTA_PERMILLE = { ADJUSTMENT_STEP = {
1: 20, # 유통(DISTRIBUTION) ±2% 1: 20, # 유통(DISTRIBUTION) ±2%
2: 10, # 제조(MANUFACTURE) ±1% 2: 10, # 제조(MANUFACTURE) ±1%
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5% 3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
@ -36,13 +36,13 @@ def _build_upper_bounds() -> tuple:
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개 UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
BRACKET_COUNT = len(UPPER_BOUNDS) # 46 PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45 PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45
# ── 배치 ────────────────────────────────────────────────── # ── 배치 ──────────────────────────────────────────────────
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에 EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유) # 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음) MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음)
# ── Redis 캐시 (§7) ────────────────────────────────────── # ── Redis 캐시 (§7) ──────────────────────────────────────
CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET) CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET)
@ -50,7 +50,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
class SupplierType(Enum): class SupplierType(Enum):
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
(negodata SupplierType 동일 코드)""" (negodata SupplierType 동일 코드)"""
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외) NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
DISTRIBUTION = 1 # 유통 DISTRIBUTION = 1 # 유통

View File

@ -9,34 +9,51 @@
기동 정적 테이블 검증 실패 예외로 즉시 중단(§13-7 MUST). 기동 정적 테이블 검증 실패 예외로 즉시 중단(§13-7 MUST).
""" """
import argparse
import asyncio import asyncio
import signal import signal
import sys import sys
from anchoring.base_table import load_base_table from anchoring.base_table import load_base_table
from anchoring.batch import run_evaluation_batch from anchoring.batch import run_evaluation_batch
from anchoring.config import load_config from anchoring.config import APP_ENV, load_config
from anchoring.constants import BRACKET_COUNT from anchoring.constants import PRICE_RANGE_COUNT
from anchoring.db import dispose_engine, init_engine from anchoring.db import dispose_engine, init_engine
from anchoring.log import LOG, configure from anchoring.log import LOG, configure
from anchoring.redis_client import close_redis, init_redis from anchoring.redis_client import close_redis, init_redis
from anchoring.scheduler import build_scheduler from anchoring.scheduler import build_scheduler
async def _run(once: bool) -> None: def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="python -m anchoring.main",
description="앵커링 격주 조정 배치 — 기본은 스케줄러 상주",
)
parser.add_argument("--once", action="store_true",
help="수동 1회 실행(격주 게이트 무시) 후 종료")
parser.add_argument("--dry-run", action="store_true",
help="판정·예상 조정을 로그로만 — DB/Redis 무변경 (--once 필수)")
args = parser.parse_args(argv)
# 상주 모드에는 dry-run 이 없다 — 무시된 채 실제 변경 스케줄러가 뜨는 사고를 기동 전에 차단
if args.dry_run and not args.once:
parser.error("--dry-run 은 --once 와 함께만 쓸 수 있습니다")
return args
async def _run(once: bool, dry_run: bool = False) -> None:
cfg = load_config() cfg = load_config()
configure(cfg.log_level) configure(cfg.log_level)
LOG.info(f"[main] 설정 로드 — APP_ENV={APP_ENV} (config.{APP_ENV}.toml, env 오버라이드 우선)")
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단 load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({BRACKET_COUNT}칸 사다리)") LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({PRICE_RANGE_COUNT}칸 사다리)")
init_engine(cfg) init_engine(cfg)
init_redis(cfg.redis) init_redis(cfg.redis)
try: try:
if once: if once:
dry = "--dry-run" in sys.argv LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry_run else ''})")
LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry else ''})") result = await run_evaluation_batch(force=True, dry_run=dry_run)
result = await run_evaluation_batch(force=True, dry_run=dry)
LOG.info(f"[main] 결과: {result}") LOG.info(f"[main] 결과: {result}")
return result return result
@ -60,7 +77,8 @@ async def _run(once: bool) -> None:
def main() -> None: def main() -> None:
result = asyncio.run(_run(once="--once" in sys.argv)) args = _parse_args()
result = asyncio.run(_run(once=args.once, dry_run=args.dry_run))
# --once 가 부분 실패(partial)로 끝나면 비정상 종료코드 — 런북/cron 에서 감지 가능해야 한다 # --once 가 부분 실패(partial)로 끝나면 비정상 종료코드 — 런북/cron 에서 감지 가능해야 한다
if result is not None and result.get("status") not in ("done", "skipped", "dry_run"): if result is not None and result.get("status") not in ("done", "skipped", "dry_run"):
sys.exit(1) sys.exit(1)

View File

@ -1,7 +1,7 @@
"""ORM 모델 — 자립(backend models 미사용). """ORM 모델 — 자립(backend models 미사용).
- 소유(쓰기): anchoring.rate_adjustments (append-only UPDATE/DELETE 금지 §5) - 소유(쓰기): anchoring.adjustments (append-only UPDATE/DELETE 금지 §5)
- sessions anchoring_adjustment_id 마킹만 쓰기 가능( 컬럼 수정 금지 §12). - sessions used_by_adjustment_id 마킹만 쓰기 가능( 컬럼 수정 금지 §12).
quotations/items 읽기 전용 경량 매핑(집계에 필요한 컬럼만). quotations/items 읽기 전용 경량 매핑(집계에 필요한 컬럼만).
""" """
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text
@ -11,19 +11,19 @@ from sqlalchemy.orm import declarative_base
BASE = declarative_base() BASE = declarative_base()
class RateAdjustment(BASE): class Adjustment(BASE):
__tablename__ = "rate_adjustments" __tablename__ = "adjustments"
__table_args__ = {"schema": "anchoring"} __table_args__ = {"schema": "anchoring"}
id = Column(BigInteger, primary_key=True, autoincrement=True) adjustment_id = Column(BigInteger, primary_key=True, autoincrement=True)
company_id = Column(UUID(as_uuid=True), nullable=False) company_id = Column(UUID(as_uuid=True), nullable=False)
supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판 supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판
price_bracket_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리) price_range_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리)
nego_count = Column(Integer, nullable=False) # 유효 표본 수 n sample_count = Column(Integer, nullable=False) # 유효 표본 수 n
success_count = Column(Integer, nullable=False) success_count = Column(Integer, nullable=False)
anchor_rate_before = Column(SmallInteger, nullable=False) # ‰ anchoring_value_before = Column(SmallInteger, nullable=False) # ‰
anchor_rate_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200] anchoring_value_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200]
consumed_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제) used_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
@ -37,10 +37,10 @@ class Session(BASE):
item_id = Column(UUID(as_uuid=True), nullable=False) item_id = Column(UUID(as_uuid=True), nullable=False)
qt_type = Column(SmallInteger, nullable=False) # 1=재협상 qt_type = Column(SmallInteger, nullable=False) # 1=재협상
target_price = Column(BigInteger, nullable=False) target_price = Column(BigInteger, nullable=False)
target_anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준) anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준)
anchor_rate_permille = Column(SmallInteger, nullable=True) # 박제 rate anchoring_value = Column(SmallInteger, nullable=True) # 박제 anchoring_value(‰)
last_offered_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외) last_offer_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외)
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼) used_by_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼)
status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED
bid_price = Column(BigInteger, nullable=True) bid_price = Column(BigInteger, nullable=True)
deleted = Column(Boolean, nullable=False) deleted = Column(Boolean, nullable=False)

View File

@ -1,42 +1,42 @@
"""현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1. """현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1.
negodata 이식 대상 견적/세션 생성 함수로 rate 얻어 negodata 이식 대상 견적/세션 생성 함수로 anchoring_value 얻어
anchor_price = target_price * (1000 - rate) // 1000 정수 연산으로 계산·박제한다. anchoring_price = target_price * (1000 - value) // 1000 정수 연산으로 계산·박제한다.
순서: Redis GET miss: 조정 이력 최신 없으면 정적 테이블 시작값 Redis SET(best effort). 순서: Redis GET miss: 조정 이력 최신 없으면 정적 테이블 시작값 Redis SET(best effort).
supplier_type {1,2,3} 경우 호출하지 말고 get_base_rate_permille(bracket) 직접 쓴다(§9.1). supplier_type {1,2,3} 경우 호출하지 말고 get_base_anchoring_value(price_range_index) 직접 쓴다(§9.1).
""" """
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from anchoring.base_table import get_base_rate_permille from anchoring.base_table import get_base_anchoring_value
from anchoring.models import RateAdjustment from anchoring.models import Adjustment
from anchoring.redis_client import get_rate, set_rate from anchoring.redis_client import get_value, set_value
async def get_latest_adjusted_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int | None: async def get_latest_adjusted_value(db: AsyncSession, company_id, supplier_type: int, price_range_index: int) -> int | None:
"""칸의 최신 조정 행 rate_after. 이력 없으면 None.""" """칸의 최신 조정 행 value_after. 이력 없으면 None."""
stmt = ( stmt = (
select(RateAdjustment.anchor_rate_after) select(Adjustment.anchoring_value_after)
.where( .where(
RateAdjustment.company_id == company_id, Adjustment.company_id == company_id,
RateAdjustment.supplier_type == supplier_type, Adjustment.supplier_type == supplier_type,
RateAdjustment.price_bracket_index == bracket_index, Adjustment.price_range_index == price_range_index,
) )
.order_by(RateAdjustment.id.desc()) .order_by(Adjustment.adjustment_id.desc())
.limit(1) .limit(1)
) )
return (await db.execute(stmt)).scalar_one_or_none() 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: async def get_current_anchoring_value(db: AsyncSession, company_id, supplier_type: int, price_range_index: int) -> int:
"""칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET.""" """칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET."""
cached = await get_rate(company_id, supplier_type, bracket_index) cached = await get_value(company_id, supplier_type, price_range_index)
if cached is not None: if cached is not None:
return cached return cached
rate = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket_index) value = await get_latest_adjusted_value(db, company_id, supplier_type, price_range_index)
if rate is None: if value is None:
rate = get_base_rate_permille(bracket_index) value = get_base_anchoring_value(price_range_index)
await set_rate(company_id, supplier_type, bracket_index, rate, nx=True) await set_value(company_id, supplier_type, price_range_index, value, nx=True)
return rate return value

View File

@ -1,13 +1,13 @@
"""Redis 캐시 클라이언트. 규범: §7. """Redis 캐시 클라이언트. 규범: §7.
- : anchor:{company_id}:{supplier_type}:{bracket_index} (supplier_type SMALLINT 코드값) - : anchor:{company_id}:{supplier_type}:{price_range_index} (supplier_type SMALLINT 코드값)
- : 정수 천분율 문자열, TTL 7( 방어선은 배치의 주간 re-SET) - : 정수 천분율 문자열, TTL 7( 방어선은 배치의 주간 re-SET)
- 장애 내성 MUST: 에러 GETNone(DB 폴백), SET로그만. Redis 견적/배치를 막으면 된다. - 장애 내성 MUST: 에러 GETNone(DB 폴백), SET로그만. Redis 견적/배치를 막으면 된다.
""" """
import redis.asyncio as aioredis import redis.asyncio as aioredis
from anchoring.config import RedisConfig from anchoring.config import RedisConfig
from anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT from anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
from anchoring.log import LOG from anchoring.log import LOG
_client: aioredis.Redis | None = None _client: aioredis.Redis | None = None
@ -52,8 +52,8 @@ async def close_redis() -> None:
_client = None _client = None
def anchor_key(company_id, supplier_type: int, bracket_index: int) -> str: def anchor_key(company_id, supplier_type: int, price_range_index: int) -> str:
return f"anchor:{company_id}:{supplier_type}:{bracket_index}" return f"anchor:{company_id}:{supplier_type}:{price_range_index}"
async def ping() -> bool: async def ping() -> bool:
@ -66,26 +66,26 @@ async def ping() -> bool:
return False return False
async def get_rate(company_id, supplier_type: int, bracket_index: int) -> int | None: async def get_value(company_id, supplier_type: int, price_range_index: int) -> int | None:
"""캐시 조회. 미스·에러·클라이언트 미초기화 → None(호출측이 DB 폴백).""" """캐시 조회. 미스·에러·클라이언트 미초기화 → None(호출측이 DB 폴백)."""
if _client is None: if _client is None:
return None return None
try: try:
raw = await _client.get(anchor_key(company_id, supplier_type, bracket_index)) raw = await _client.get(anchor_key(company_id, supplier_type, price_range_index))
if raw is None: if raw is None:
return None return None
rate = int(raw) value = int(raw)
# 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정 # 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정
if not (ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX): if not (ANCHORING_VALUE_MIN <= value <= ANCHORING_VALUE_MAX):
LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, bracket_index)} value={raw}") LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, price_range_index)} value={raw}")
return None return None
return rate return value
except Exception as ex: except Exception as ex:
_note_failure("get", anchor_key(company_id, supplier_type, bracket_index), ex) _note_failure("get", anchor_key(company_id, supplier_type, price_range_index), ex)
return None return None
async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int, nx: bool = False) -> bool: async def set_value(company_id, supplier_type: int, price_range_index: int, value: int, nx: bool = False) -> bool:
"""캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다. """캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다.
nx=True: 키가 없을 때만 적재 읽기 경로의 미스 백필용(배치가 방금 값을 nx=True: 키가 없을 때만 적재 읽기 경로의 미스 백필용(배치가 방금 값을
@ -94,8 +94,8 @@ async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int
if _client is None: if _client is None:
return False return False
try: try:
await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS, nx=nx) await _client.set(anchor_key(company_id, supplier_type, price_range_index), str(value), ex=CACHE_TTL_SECONDS, nx=nx)
return True return True
except Exception as ex: except Exception as ex:
_note_failure("set", anchor_key(company_id, supplier_type, bracket_index), ex) _note_failure("set", anchor_key(company_id, supplier_type, price_range_index), ex)
return False return False

View File

@ -4,56 +4,58 @@
""" """
from bisect import bisect_right from bisect import bisect_right
from anchoring.base_table import get_base_rate_permille from anchoring.base_table import get_base_anchoring_value
from anchoring.constants import ( from anchoring.constants import (
ANCHOR_RATE_MAX, ANCHORING_VALUE_MAX,
ANCHOR_RATE_MIN, ANCHORING_VALUE_MIN,
BRACKET_INDEX_MAX, PRICE_RANGE_INDEX_MAX,
DELTA_PERMILLE, ADJUSTMENT_STEP,
SAMPLE_THRESHOLD, SAMPLE_THRESHOLD,
UPPER_BOUNDS, UPPER_BOUNDS,
AnchoringSampleType, AnchoringSampleType,
) )
def calc_bracket_index(target_price: int) -> int: def calc_price_range_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리. """목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
좌폐우개 [이전 ub, ub): 가격이 upper_bound 정확히 같으면 다음 . 좌폐우개 [이전 ub, ub): 가격이 upper_bound 정확히 같으면 다음 .
1 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1""" 1 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX) return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int: def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
"""앵커링가 = 목표가 × (1 A), 1원 단위 내림. §4.2 (정수 연산만)""" """앵커링가 = 목표가 × (1 A), 1원 단위 내림. §4.2 (정수 연산만)"""
return target_price * (1000 - rate_permille) // 1000 return target_price * (1000 - anchoring_value) // 1000
def judge_sample_type( def judge_sample_type(
is_done: bool, # sessions.status == DONE(3) is_done: bool, # sessions.status == DONE(3)
bid_price: int | None, # 확정 투찰가(DONE 시) bid_price: int | None, # 확정 투찰가(DONE 시)
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음 last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커) anchoring_price: int | None, # sessions.anchoring_price (박제 앵커)
) -> int: ) -> int:
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적. """배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적.
가격을 번이라도 써낸 협상만 표본: 앵커 이하 합의 = 성공, 가격을 번이라도 써낸 협상만 표본: 앵커 이하 합의 = 성공,
나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패. 가격 흔적이 없으면 제외. 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패. 가격 흔적이 없으면 제외.
""" """
if anchor_price is None or last_offered_price is None: if anchoring_price is None or last_offer_price is None:
return AnchoringSampleType.EXCLUDED.value return AnchoringSampleType.EXCLUDED.value
if is_done and bid_price is not None and bid_price <= anchor_price: if is_done and bid_price is not None and bid_price <= anchoring_price:
return AnchoringSampleType.BID_SUCCESS.value return AnchoringSampleType.BID_SUCCESS.value
return AnchoringSampleType.BID_FAIL.value return AnchoringSampleType.BID_FAIL.value
def evaluate_pending( def evaluate_samples(
rate_before: int, value_before: int,
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드 sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
supplier_type: int, # SMALLINT 코드 1/2/3 supplier_type: int, # SMALLINT 코드 1/2/3
) -> int | None: ) -> tuple[int, bool] | None:
"""누적 전량 평가. §4.4 """누적 전량 평가. §4.4
반환: anchor_rate_after (평가 수행 ) / None (n < 10, 스킵·이월) 반환: (anchoring_value_after, clamped) (평가 수행 ) / None (n < 10, 스킵·이월)
clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 경계값에서의 '유지' 구분되는
실제 포화 신호(운영 지표용).
호출 측은 None 아니면 [조정 INSERT + 소비 마킹] 트랜잭션 + 캐시 SET 수행한다. 호출 측은 None 아니면 [조정 INSERT + 소비 마킹] 트랜잭션 + 캐시 SET 수행한다.
""" """
n = len(sample_types) n = len(sample_types)
@ -61,21 +63,22 @@ def evaluate_pending(
return None return None
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value) success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
delta = DELTA_PERMILLE[supplier_type] delta = ADJUSTMENT_STEP[supplier_type]
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피) # r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
if success * 10 >= n * 6: if success * 10 >= n * 6:
adjusted = rate_before + delta adjusted = value_before + delta
elif success * 10 < n * 3: # r < 0.30 elif success * 10 < n * 3: # r < 0.30
adjusted = rate_before - delta adjusted = value_before - delta
else: # 0.30 ≤ r < 0.60 else: # 0.30 ≤ r < 0.60
adjusted = rate_before adjusted = value_before
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted)) value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted))
return value_after, value_after != adjusted
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int: def get_current_value(latest_adjusted_value: int | None, price_range_index: int) -> int:
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값.""" """현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
if latest_adjusted_rate is not None: if latest_adjusted_value is not None:
return latest_adjusted_rate return latest_adjusted_value
return get_base_rate_permille(bracket_index) return get_base_anchoring_value(price_range_index)

View File

@ -15,7 +15,7 @@ from anchoring import db as adb
from anchoring.config import load_config from anchoring.config import load_config
CFG = load_config() CFG = load_config()
_SCHEMA_SQL = Path(__file__).resolve().parents[1] / "schema.sql" _SCHEMA_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "05-anchoring-schema.sql"
def _db_available() -> bool: def _db_available() -> bool:
@ -40,7 +40,7 @@ requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db)
def _schema_statements() -> list[str]: def _schema_statements() -> list[str]:
"""schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리.""" """05-anchoring-schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
lines = [ lines = [
line for line in _SCHEMA_SQL.read_text().splitlines() line for line in _SCHEMA_SQL.read_text().splitlines()
if not line.startswith("\\") and not line.strip().startswith("--") if not line.startswith("\\") and not line.strip().startswith("--")
@ -80,15 +80,15 @@ class Seeder:
async def seed_session( async def seed_session(
self, db, *, self, db, *,
supplier_type=1, target_price=30_000, anchor_price=29_700, rate=10, supplier_type=1, target_price=30_000, anchoring_price=29_700, anchoring_value=10,
status=3, bid_price=None, last_offered_price=..., qt_type=1, status=3, bid_price=None, last_offer_price=..., qt_type=1,
): ):
"""종료 재협상 세션 1건 시드. 반환: session_id. """종료 재협상 세션 1건 시드. 반환: session_id.
last_offered_price 기본값은 bid_price(가격 흔적 = 투찰가). None 명시하면 가격 흔적 없는 세션. last_offer_price 기본값은 bid_price(가격 흔적 = 투찰가). None 명시하면 가격 흔적 없는 세션.
""" """
if last_offered_price is ...: if last_offer_price is ...:
last_offered_price = bid_price last_offer_price = bid_price
await self._ensure_item(db) await self._ensure_item(db)
qt_id = uuid.uuid4() qt_id = uuid.uuid4()
self.quotation_ids.append(qt_id) self.quotation_ids.append(qt_id)
@ -105,19 +105,19 @@ class Seeder:
await db.execute(text( await db.execute(text(
"INSERT INTO negotiation.sessions " "INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " "(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, " " target_price, anchoring_price, anchoring_value, last_offer_price, "
" status, bid_price, end_time) " " status, bid_price, end_time) "
"VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :rate, :lop, :status, :bid, now())" "VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :value, :lop, :status, :bid, now())"
), { ), {
"sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": uuid.uuid4(), "sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": uuid.uuid4(),
"qtype": qt_type, "tp": target_price, "ap": anchor_price, "rate": rate, "qtype": qt_type, "tp": target_price, "ap": anchoring_price, "value": anchoring_value,
"lop": last_offered_price, "status": status, "bid": bid_price, "lop": last_offer_price, "status": status, "bid": bid_price,
}) })
return session_id return session_id
async def cleanup(self, db): async def cleanup(self, db):
await db.execute(text( await db.execute(text(
"DELETE FROM anchoring.rate_adjustments WHERE company_id = :cid" "DELETE FROM anchoring.adjustments WHERE company_id = :cid"
), {"cid": self.company_id}) ), {"cid": self.company_id})
if self.quotation_ids: if self.quotation_ids:
await db.execute( await db.execute(

View File

@ -9,8 +9,8 @@ from sqlalchemy import select, text
from anchoring import db as adb from anchoring import db as adb
from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch
from anchoring.models import RateAdjustment, Session from anchoring.models import Adjustment, Session
from anchoring.reader import get_anchor_rate from anchoring.reader import get_current_anchoring_value
from conftest import requires_db from conftest import requires_db
pytestmark = requires_db pytestmark = requires_db
@ -20,7 +20,7 @@ async def _run_batch(*seeders):
"""테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다.""" """테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다."""
return await run_evaluation_batch(force=True, company_ids=[s.company_id for s in seeders]) 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만 원대" 칸) # 시드 기본값: target 30,000 / anchoring_value 10‰ / anchoring_price 29,700 → price_range 12 ("3만 원대" 칸)
BRACKET = 12 BRACKET = 12
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
FAIL_BID = 29_999 # > anchor → BID_FAIL FAIL_BID = 29_999 # > anchor → BID_FAIL
@ -28,15 +28,15 @@ FAIL_BID = 29_999 # > anchor → BID_FAIL
async def _adjustments(db, seeder): async def _adjustments(db, seeder):
stmt = ( stmt = (
select(RateAdjustment) select(Adjustment)
.where(RateAdjustment.company_id == seeder.company_id) .where(Adjustment.company_id == seeder.company_id)
.order_by(RateAdjustment.id) .order_by(Adjustment.adjustment_id)
) )
return (await db.execute(stmt)).scalars().all() return (await db.execute(stmt)).scalars().all()
async def _marks(db, session_ids): async def _marks(db, session_ids):
stmt = select(Session.session_id, Session.anchoring_adjustment_id).where(Session.session_id.in_(session_ids)) stmt = select(Session.session_id, Session.used_by_adjustment_id).where(Session.session_id.in_(session_ids))
return dict((await db.execute(stmt)).all()) return dict((await db.execute(stmt)).all())
@ -54,9 +54,9 @@ async def test_full_cycle_and_idempotency(seeder, caplog):
async with adb.session_scope() as db: async with adb.session_scope() as db:
ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등) ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등)
for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패 for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패
ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offered_price=FAIL_BID)) ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offer_price=FAIL_BID))
# 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오) # 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오)
ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID)) ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offer_price=FAIL_BID))
# 합계 13건, 성공 8 → r≈0.615 → +20 # 합계 13건, 성공 8 → r≈0.615 → +20
with caplog.at_level(logging.INFO, logger="anchoring"): with caplog.at_level(logging.INFO, logger="anchoring"):
@ -72,26 +72,26 @@ async def test_full_cycle_and_idempotency(seeder, caplog):
adjustments = await _adjustments(db, seeder) adjustments = await _adjustments(db, seeder)
assert len(adjustments) == 1 assert len(adjustments) == 1
adj = adjustments[0] adj = adjustments[0]
assert (adj.nego_count, adj.success_count) == (13, 8) assert (adj.sample_count, adj.success_count) == (13, 8)
assert (adj.anchor_rate_before, adj.anchor_rate_after) == (10, 30) assert (adj.anchoring_value_before, adj.anchoring_value_after) == (10, 30)
assert sorted(adj.consumed_session_ids) == sorted(str(i) for i in ids) assert sorted(adj.used_session_ids) == sorted(str(i) for i in ids)
marks = await _marks(db, ids) marks = await _marks(db, ids)
assert all(v == adj.id for v in marks.values()) # 13건 모두 소비 마킹 assert all(v == adj.adjustment_id for v in marks.values()) # 13건 모두 소비 마킹
# 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건 # 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건
await _run_batch(seeder) await _run_batch(seeder)
async with adb.session_scope() as db: async with adb.session_scope() as db:
assert len(await _adjustments(db, seeder)) == 1 assert len(await _adjustments(db, seeder)) == 1
# 조회용 뷰 — rate_history(이전→새 값 리스트업) / current_rates(칸별 현재값) # 조회용 뷰 — value_history(이전→새 값 리스트업) / current_values(칸별 현재값)
hist = (await db.execute(text( hist = (await db.execute(text(
"SELECT anchor_rate_before, anchor_rate_after, delta_permille, success_rate " "SELECT anchoring_value_before, anchoring_value_after, value_change, success_rate "
"FROM anchoring.rate_history WHERE company_id = :c"), {"c": seeder.company_id})).one() "FROM anchoring.value_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 (hist.anchoring_value_before, hist.anchoring_value_after, hist.value_change) == (10, 30, 20)
assert float(hist.success_rate) == 0.615 assert float(hist.success_rate) == 0.615
cur = (await db.execute(text( cur = (await db.execute(text(
"SELECT anchor_rate_permille FROM anchoring.current_rates " "SELECT anchoring_value FROM anchoring.current_values "
"WHERE company_id = :c AND supplier_type = 1 AND price_bracket_index = :b"), "WHERE company_id = :c AND supplier_type = 1 AND price_range_index = :b"),
{"c": seeder.company_id, "b": BRACKET})).scalar_one() {"c": seeder.company_id, "b": BRACKET})).scalar_one()
assert cur == 30 assert cur == 30
@ -113,10 +113,10 @@ async def test_carryover(seeder):
async with adb.session_scope() as db: async with adb.session_scope() as db:
adjustments = await _adjustments(db, seeder) adjustments = await _adjustments(db, seeder)
assert len(adjustments) == 1 assert len(adjustments) == 1
assert adjustments[0].nego_count == 13 # 4주치 전량 1회 평가 assert adjustments[0].sample_count == 13 # 4주치 전량 1회 평가
assert adjustments[0].anchor_rate_after == 30 assert adjustments[0].anchoring_value_after == 30
marks = await _marks(db, first + second) marks = await _marks(db, first + second)
assert all(v == adjustments[0].id for v in marks.values()) assert all(v == adjustments[0].adjustment_id for v in marks.values())
# ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ── # ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ──
@ -130,19 +130,19 @@ async def test_company_isolation_and_reader(seeder):
other_company = uuid.uuid4() other_company = uuid.uuid4()
async with adb.session_scope() as db: async with adb.session_scope() as db:
adjustments = await _adjustments(db, seeder) adjustments = await _adjustments(db, seeder)
by_type = {a.supplier_type: a.anchor_rate_after for a in adjustments} by_type = {a.supplier_type: a.anchoring_value_after for a in adjustments}
assert by_type == {1: 30, 2: 20} assert by_type == {1: 30, 2: 20}
# 조정된 칸은 새 rate, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값 # 조정된 칸은 새 anchoring_value, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값
assert await get_anchor_rate(db, seeder.company_id, 1, BRACKET) == 30 assert await get_current_anchoring_value(db, seeder.company_id, 1, BRACKET) == 30
assert await get_anchor_rate(db, other_company, 1, BRACKET) == 10 assert await get_current_anchoring_value(db, other_company, 1, BRACKET) == 10
# ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ── # ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ──
async def test_excluded_and_unsampleable(seeder): async def test_excluded_and_unsampleable(seeder):
async with adb.session_scope() as db: async with adb.session_scope() as db:
# 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED # 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None) for _ in range(8)] excluded = [await seeder.seed_session(db, status=5, last_offer_price=None) for _ in range(8)]
excluded += [await seeder.seed_session(db, status=4, last_offered_price=None) for _ in range(7)] excluded += [await seeder.seed_session(db, status=4, last_offer_price=None) for _ in range(7)]
valid = await _seed_mixed(db, seeder, success=5, fail=0) # 유효 5 < 10 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 = [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) untyped.append(await seeder.seed_session(db, supplier_type=None, bid_price=SUCCESS_BID)) # NULL 도 동일(§13-6)
@ -163,7 +163,7 @@ async def test_marking_conflict_rolls_back(seeder):
ids = await _seed_mixed(db, seeder, success=10, fail=0) ids = await _seed_mixed(db, seeder, success=10, fail=0)
# 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다 # 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다
await db.execute(text( await db.execute(text(
"UPDATE negotiation.sessions SET anchoring_adjustment_id = 999999 WHERE session_id = :sid" "UPDATE negotiation.sessions SET used_by_adjustment_id = 999999 WHERE session_id = :sid"
), {"sid": ids[0]}) ), {"sid": ids[0]})
samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도 samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도
@ -184,7 +184,7 @@ async def test_marking_conflict_rolls_back(seeder):
async def test_priced_rate_zero_warns(seeder, caplog): async def test_priced_rate_zero_warns(seeder, caplog):
async with adb.session_scope() as db: async with adb.session_scope() as db:
for _ in range(3): # 전부 가격 흔적 없는 종료 → priced_rate 0 for _ in range(3): # 전부 가격 흔적 없는 종료 → priced_rate 0
await seeder.seed_session(db, status=5, last_offered_price=None) await seeder.seed_session(db, status=5, last_offer_price=None)
with caplog.at_level(logging.WARNING, logger="anchoring"): with caplog.at_level(logging.WARNING, logger="anchoring"):
await run_evaluation_batch(force=True, company_ids=[seeder.company_id]) await run_evaluation_batch(force=True, company_ids=[seeder.company_id])
@ -195,7 +195,7 @@ async def test_priced_rate_zero_warns(seeder, caplog):
async def test_dry_run_changes_nothing(seeder, caplog): async def test_dry_run_changes_nothing(seeder, caplog):
async with adb.session_scope() as db: async with adb.session_scope() as db:
ids = await _seed_mixed(db, seeder, success=10, fail=0) ids = await _seed_mixed(db, seeder, success=10, fail=0)
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None)] excluded = [await seeder.seed_session(db, status=5, last_offer_price=None)]
with caplog.at_level(logging.INFO, logger="anchoring"): with caplog.at_level(logging.INFO, logger="anchoring"):
result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True) result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
@ -216,8 +216,8 @@ async def test_dry_run_changes_nothing(seeder, caplog):
# ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ──── # ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ────
async def test_snapshot_mismatch_warns(seeder, caplog): async def test_snapshot_mismatch_warns(seeder, caplog):
async with adb.session_scope() as db: async with adb.session_scope() as db:
# rate 10‰ 기준 정수식 anchor 는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호 # anchoring_value 10‰ 기준 정수식 앵커가는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호
await seeder.seed_session(db, rate=10, anchor_price=29_000, bid_price=28_000) await seeder.seed_session(db, anchoring_value=10, anchoring_price=29_000, bid_price=28_000)
with caplog.at_level(logging.WARNING, logger="anchoring"): with caplog.at_level(logging.WARNING, logger="anchoring"):
await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True) await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)

View File

@ -6,14 +6,14 @@ from datetime import datetime
import pytest import pytest
from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table from anchoring.base_table import BaseTableError, _validate, get_base_anchoring_value, load_base_table
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS, AnchoringSampleType from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS, AnchoringSampleType
from anchoring.batch import is_evaluation_week from anchoring.batch import is_evaluation_week
from anchoring.service import ( from anchoring.service import (
calc_anchor_price, calc_anchoring_price,
calc_bracket_index, calc_price_range_index,
evaluate_pending, evaluate_samples,
get_current_rate, get_current_value,
judge_sample_type, judge_sample_type,
) )
@ -23,32 +23,32 @@ E = AnchoringSampleType.EXCLUDED.value
# ── §11.1 앵커링가 계산 (내림 검증) ────────────────────── # ── §11.1 앵커링가 계산 (내림 검증) ──────────────────────
def test_calc_anchor_price_floor(): def test_calc_anchoring_price_floor():
assert calc_anchor_price(30_000, 200) == 24_000 assert calc_anchoring_price(30_000, 200) == 24_000
assert calc_anchor_price(26_706, 10) == 26_438 # 26,438.94 → 내림 assert calc_anchoring_price(26_706, 10) == 26_438 # 26,438.94 → 내림
assert calc_anchor_price(29_999, 15) == 29_549 # 29,549.015 → 내림 assert calc_anchoring_price(29_999, 15) == 29_549 # 29,549.015 → 내림
assert calc_anchor_price(0, 10) == 0 assert calc_anchoring_price(0, 10) == 0
# ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ── # ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ──
def test_bracket_index(): def test_price_range_index():
assert calc_bracket_index(0) == 0 # [0, 1,000) 통일 칸 assert calc_price_range_index(0) == 0 # [0, 1,000) 통일 칸
assert calc_bracket_index(999) == 0 assert calc_price_range_index(999) == 0
assert calc_bracket_index(1_000) == 1 # 경계는 상위 구간 assert calc_price_range_index(1_000) == 1 # 경계는 상위 구간
assert calc_bracket_index(1_999) == 1 # 1천 원대 assert calc_price_range_index(1_999) == 1 # 1천 원대
assert calc_bracket_index(9_999) == 9 # 9천 원대 assert calc_price_range_index(9_999) == 9 # 9천 원대
assert calc_bracket_index(10_000) == 10 # 1만 원대 진입 assert calc_price_range_index(10_000) == 10 # 1만 원대 진입
assert calc_bracket_index(30_000) == 12 # 3만 원대 assert calc_price_range_index(30_000) == 12 # 3만 원대
assert calc_bracket_index(99_999) == 18 # 9만 원대 assert calc_price_range_index(99_999) == 18 # 9만 원대
assert calc_bracket_index(150_000) == 19 # 10만 원대 assert calc_price_range_index(150_000) == 19 # 10만 원대
assert calc_bracket_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입 assert calc_price_range_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
assert calc_bracket_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸 assert calc_price_range_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
assert calc_bracket_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프 assert calc_price_range_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
def test_ladder_shape(): def test_ladder_shape():
"""사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억.""" """사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억."""
assert BRACKET_COUNT == 46 assert PRICE_RANGE_COUNT == 46
assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000 assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000
assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS)) assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS))
assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계 assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계
@ -57,8 +57,8 @@ def test_ladder_shape():
# ── §2 정적 테이블 로드·검증 ───────────────────────────── # ── §2 정적 테이블 로드·검증 ─────────────────────────────
def test_base_table_load_and_values(): def test_base_table_load_and_values():
load_base_table() load_base_table()
assert get_base_rate_permille(0) == 10 assert get_base_anchoring_value(0) == 10
assert get_base_rate_permille(45) == 10 assert get_base_anchoring_value(45) == 10
def _rows(): def _rows():
@ -69,8 +69,8 @@ def _rows():
def test_base_table_validate_ok(): def test_base_table_validate_ok():
rates = _validate(_rows()) values = _validate(_rows())
assert len(rates) == BRACKET_COUNT and set(rates) == {10} assert len(values) == PRICE_RANGE_COUNT and set(values) == {10}
def test_base_table_validate_rejects_bad(): def test_base_table_validate_rejects_bad():
@ -95,32 +95,35 @@ def _pending(success: int, fail: int) -> list[int]:
return [S] * success + [F] * fail return [S] * success + [F] * fail
def test_evaluate_pending_distribution(): def test_evaluate_samples_distribution():
assert evaluate_pending(10, _pending(8, 5), 1) == 30 # 13건 r≈0.615 → +20 assert evaluate_samples(10, _pending(8, 5), 1) == (30, False) # 13건 r≈0.615 → +20
assert evaluate_pending(10, _pending(7, 6), 1) == 10 # r≈0.538 → 유지 assert evaluate_samples(10, _pending(7, 6), 1) == (10, False) # r≈0.538 → 유지
assert evaluate_pending(10, _pending(3, 10), 1) == 10 # r≈0.231 → 20, 하한 clamp assert evaluate_samples(10, _pending(3, 10), 1) == (10, True) # r≈0.231 → 20, 하한 clamp
assert evaluate_pending(10, _pending(6, 4), 1) == 30 # r=0.60 정확히 → 경계 포함 +20 assert evaluate_samples(10, _pending(6, 4), 1) == (30, False) # r=0.60 정확히 → 경계 포함 +20
assert evaluate_pending(10, _pending(3, 7), 1) == 10 # r=0.30 정확히 → 유지 assert evaluate_samples(10, _pending(3, 7), 1) == (10, False) # r=0.30 정확히 → 유지
assert evaluate_pending(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월) assert evaluate_samples(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월)
def test_evaluate_pending_delta_swap_guard(): def test_evaluate_samples_delta_swap_guard():
"""δ 스왑 가드 (MUST): 코드 2=제조=±10, 3=총판=±15.""" """δ 스왑 가드 (MUST): 코드 2=제조=±10, 3=총판=±15."""
all_success = [S] * 10 all_success = [S] * 10
assert evaluate_pending(10, all_success, 2) == 20 # 제조 +10 assert evaluate_samples(10, all_success, 2) == (20, False) # 제조 +10
assert evaluate_pending(10, all_success, 3) == 25 # 총판 +15 assert evaluate_samples(10, all_success, 3) == (25, False) # 총판 +15
all_fail = [F] * 10 all_fail = [F] * 10
assert evaluate_pending(100, all_fail, 2) == 90 # 제조 10 assert evaluate_samples(100, all_fail, 2) == (90, False) # 제조 10
assert evaluate_pending(100, all_fail, 3) == 85 # 총판 15 assert evaluate_samples(100, all_fail, 3) == (85, False) # 총판 15
def test_evaluate_pending_clamp_upper(): def test_evaluate_samples_clamp_upper():
assert evaluate_pending(200, _pending(9, 1), 1) == 200 # 상한 clamp — 조정 레코드는 호출측이 INSERT # 상한 clamp — 조정 레코드는 호출측이 INSERT
assert evaluate_samples(200, _pending(9, 1), 1) == (200, True)
# 경계값에서의 '유지'(hold 밴드)는 clamp 가 아니다 — clamped 지표 오염 방지
assert evaluate_samples(200, _pending(5, 5), 1) == (200, False)
# ── §11.4 파생 판정 ("가격 흔적" 기준) ──────────────────── # ── §11.4 파생 판정 ("가격 흔적" 기준) ────────────────────
def test_judge_sample_type(): def test_judge_sample_type():
# judge_sample_type(is_done, bid_price, last_offered_price, anchor_price) # judge_sample_type(is_done, bid_price, last_offer_price, anchoring_price)
assert judge_sample_type(True, 24_000, 24_000, 24_000) == S # 같아도 성공 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(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 # 가격 쓰고 결렬(REJECTED)
@ -130,9 +133,9 @@ def test_judge_sample_type():
# ── §4.5 현재 값 조회 ──────────────────────────────────── # ── §4.5 현재 값 조회 ────────────────────────────────────
def test_get_current_rate(): def test_get_current_value():
assert get_current_rate(70, 0) == 70 assert get_current_value(70, 0) == 70
assert get_current_rate(None, 0) == 10 # 이력 없으면 정적 테이블 시작값 assert get_current_value(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
# ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ────────── # ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ──────────