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_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지
last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적"
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용
anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지
anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지
last_offer_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적"
used_by_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
@ -165,7 +165,7 @@ class quotations(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
def DBType():
return DBType.QUOTATION.value

View File

@ -44,7 +44,7 @@ class IChatCRUD(ABC):
pass
@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
@ -136,7 +136,7 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex)
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 = (
update(sessions)
.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)
except Exception as ex:

View File

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

View File

@ -369,7 +369,7 @@ class ChatService:
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
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
if turn.chat_end:
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 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
target_price = int(sess.target_price or 0)
# 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변.
# 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess)
@ -434,7 +434,7 @@ class ChatService:
)
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 앵커를 계산하지 않는다.
@ -443,8 +443,8 @@ class ChatService:
"""
if not target_price:
return 0
if sess.target_anchoring_price is not None:
return int(sess.target_anchoring_price)
if sess.anchoring_price is not None:
return int(sess.anchoring_price)
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
return target_price

View File

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

View File

@ -8,11 +8,13 @@
# negosium 프론트: http://localhost:3300
# negodata 서버: http://localhost:9400/docs
# agent 서버: http://localhost:9500/docs
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
#
# 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/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/05-anchoring-schema.sql (anchoring 스키마: adjustments·뷰 — schedules/anchoring 소유)
services:
negosium-backend:
@ -77,3 +79,38 @@ services:
ports:
- "3300:3300"
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 서비스 담당).
상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지).
"""
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.reader import fetch_current_rates
from common.anchoring.service import calc_anchor_price, calc_bracket_index
from common.anchoring.reader import fetch_current_values
from common.anchoring.service import calc_anchoring_price, calc_price_range_index
__all__ = [
"SAMPLEABLE_SUPPLIER_TYPES",
"calc_anchor_price",
"calc_bracket_index",
"fetch_current_rates",
"get_base_rate_permille",
"calc_anchoring_price",
"calc_price_range_index",
"fetch_current_values",
"get_base_anchoring_value",
"load_base_table",
]

View File

@ -7,11 +7,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증
import json
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"
_rates: list[int] | None = None # bracket_index → 시작값(‰)
_rates: list[int] | None = None # price_range_index → 시작값(‰)
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.
"""
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(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)} != {PRICE_RANGE_COUNT}")
rates: list[int] = []
for i, row in enumerate(rows):
idx = row.get("idx")
@ -52,8 +52,8 @@ def load_base_table() -> None:
_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:
load_base_table()
return _rates[bracket_index]
return _rates[price_range_index]

View File

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

View File

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

View File

@ -5,17 +5,17 @@
"""
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-기반). 자릿수 계단식 사다리.
좌폐우개 [이전 ub, ub): 가격이 upper_bound 정확히 같으면 다음 .
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 곱셈 재도입 금지)"""
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_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(천분율‰) 박제 — 위와 동일 규칙. 나머지 앵커링 컬럼(last_offered_price 등)은 backend/배치 소유라 매핑 안 함
anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
bid_price = Column(BigInteger, 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
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).
target/anchoring 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
try:
query = (
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)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
)

View File

@ -100,7 +100,7 @@ class SessionData(WebPacketProtocol):
qt_round: int
qt_type: QuotationType
target_price: int
target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율)
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000anchoring_value)//1000
status: SessionStatus
bid_price: Optional[int] = None
bid_at: Optional[datetime] = None
@ -205,7 +205,8 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
selling: Optional[int] = None
fee: float = 0.0
margin: float = 0.0
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
candidates: list[TargetCandidate] = []
chosen_basis: Optional[str] = None
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
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type,
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)
VALUES
-- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000

View File

@ -7,10 +7,10 @@ from fastapi import Depends
from common.anchoring import (
SAMPLEABLE_SUPPLIER_TYPES,
calc_anchor_price,
calc_bracket_index,
fetch_current_rates,
get_base_rate_permille,
calc_anchoring_price,
calc_price_range_index,
fetch_current_values,
get_base_anchoring_value,
)
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
@ -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.chosen_basis = None if is_inherited else chosen_basis
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
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 {}
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
# 앵커링가는 quotation_settings.anchoring_value 를 더 이상 쓰지 않는다(앵커링 v1.2) —
# 칸(회사×협력사유형×가격구간)별 조정 rate 로 계산한다. 아래 세션 생성부 ②.
# 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) —
# 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
@ -489,32 +490,32 @@ class QuotationService:
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) rate 조회 후
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는
# 정적 테이블 시작값 폴백 — rate 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
# 정적 테이블 시작값 폴백 — 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
)
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
rate_map = {}
value_map = {}
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(),
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 = []
for iid in item_ids:
tp = target_prices[iid]
bracket = calc_bracket_index(tp)
price_range = calc_price_range_index(tp)
company = item_companies.get(iid)
rate = rate_map.get((company, bracket)) if company is not None else None
if rate is None:
rate = get_base_rate_permille(bracket)
ap = calc_anchor_price(tp, rate) # 목표가×(1000rate)//1000 — float 곱셈 금지(1원 내림 정확성)
value = value_map.get((company, price_range)) if company is not None else None
if value is None:
value = get_base_anchoring_value(price_range)
ap = calc_anchoring_price(tp, value) # 목표가×(1000value)//1000 — float 곱셈 금지(1원 내림 정확성)
for sid in supplier_ids:
session_objs.append(
sessions(
@ -526,8 +527,8 @@ class QuotationService:
qt_round=quotation.round,
qt_type=quotation.type,
target_price=tp,
target_anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
anchor_rate_permille=rate,
anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
anchoring_value=value,
status=SessionStatus.CREATED.value,
end_time=quotation.end_time,
)
@ -645,7 +646,7 @@ class QuotationService:
mid_action = original.mid_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)
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) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
if winner is not None:
@ -797,7 +798,7 @@ class QuotationService:
qt_round=r.qt_round,
qt_type=r.qt_type,
target_price=r.target_price,
target_anchoring_price=r.target_anchoring_price,
anchoring_price=r.anchoring_price,
status=r.status,
bid_price=r.bid_price,
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,
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
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)
@ -161,14 +161,14 @@ async def _seed_quotation(
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/앵커링가로 가격게이트 입력을 만든다."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO sessions "
"(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, "
" :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(),
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
"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,
},
)

View File

@ -1,29 +1,29 @@
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) rate 로 앵커가를 박제하는지 검증.
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
이식 명세: schedules/anchoring/docs/인수인계.md §1.
- 앵커가 = 목표가 × (1000 rate) // 1000 (정수 연산), anchor_rate_permille 동시 박제
- 앵커가 = 목표가 × (1000 anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 정적 테이블 시작값(10) 폴백,
견적 생성은 실패하지 않는다(규칙 6)
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 rate 재계산(규칙 1 상속 폐지)
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 재계산(규칙 1 상속 폐지)
"""
import uuid
from datetime import datetime
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 crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation_service import QuotationService
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(유통) 견적 생성.
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990(시작값), rate=10 박제."""
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990(시작값), anchoring_value=10 박제."""
await _drop_anchoring(db_engine)
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
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
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, 무이력 상품은 시작값 10 각각 박제( 단위 조회)."""
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 — 다른 구간
tp_hit = int(100_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)
assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id)
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) 견적 생성 — 칸(회사×유형×구간) 구성 불가.
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10 박제."""
await _reset_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
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)
assert res.result.success is True
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):
@ -79,28 +79,28 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa
assert res1.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
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])
assert res2.result.success is True
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():
"""검증: 이식된 calc_bracket_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
배치의 박제 정합 감시는 rate앵커가 자기일관만 보므로 브래킷 이식 오류를 잡는다
def test_price_range_index_golden_vectors():
"""검증: 이식된 calc_price_range_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
배치의 박제 정합 감시는 앵커가 자기일관만 보므로 브래킷 이식 오류를 잡는다
벡터가 원본(schedules/anchoring) 어긋나면 이식 오류다( 변경 금지)."""
assert calc_bracket_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
assert calc_bracket_index(999) == 0
assert calc_bracket_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
assert calc_bracket_index(9_999) == 9
assert calc_bracket_index(10_000) == 10 # 자릿수 전환 경계
assert calc_bracket_index(99_999_999) == 45
assert calc_bracket_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
assert calc_bracket_index(10**12) == 45
assert calc_price_range_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
assert calc_price_range_index(999) == 0
assert calc_price_range_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
assert calc_price_range_index(9_999) == 9
assert calc_price_range_index(10_000) == 10 # 자릿수 전환 경계
assert calc_price_range_index(99_999_999) == 45
assert calc_price_range_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
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):
"""생성된 견적의 item_id -> (target_price, target_anchoring_price, anchor_rate_permille)."""
"""생성된 견적의 item_id -> (target_price, anchoring_price, anchoring_value)."""
async with engine.begin() as conn:
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"),
{"qt": qt_id},
)).all()
out = {}
for item_id, tp, ap, rate in rows:
assert out.setdefault(item_id, (tp, ap, rate)) == (tp, ap, rate) # 같은 상품 세션끼리 동일 박제
for item_id, tp, ap, value in rows:
assert out.setdefault(item_id, (tp, ap, value)) == (tp, ap, value) # 같은 상품 세션끼리 동일 박제
return out
@ -158,29 +158,29 @@ async def _drop_anchoring(engine):
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
# 모듈 소유 DDL(schedules/anchoring/schema.sql)에서 조회 경로에 필요한 부분 발췌.
# 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)에서 조회 경로에 필요한 부분 발췌.
# negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다.
_ANCHORING_DDL = (
"CREATE SCHEMA IF NOT EXISTS anchoring",
"""CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL,
supplier_type SMALLINT NOT NULL,
price_bracket_index INTEGER NOT NULL,
nego_count INTEGER NOT NULL,
success_count INTEGER NOT NULL,
anchor_rate_before SMALLINT NOT NULL,
anchor_rate_after SMALLINT NOT NULL,
consumed_session_ids JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
"""CREATE TABLE IF NOT EXISTS anchoring.adjustments (
adjustment_id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL,
supplier_type SMALLINT NOT NULL,
price_range_index INTEGER NOT NULL,
sample_count INTEGER NOT NULL,
success_count INTEGER NOT NULL,
anchoring_value_before SMALLINT NOT NULL,
anchoring_value_after SMALLINT NOT NULL,
used_session_ids JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)""",
"""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""",
"""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""",
)
@ -192,15 +192,15 @@ async def _reset_anchoring(engine):
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행 삽입(배치가 쌓는 행의 최소 재현)."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO anchoring.rate_adjustments "
"(company_id, supplier_type, price_bracket_index, nego_count, success_count, "
" anchor_rate_before, anchor_rate_after, consumed_session_ids) "
"VALUES (:cid, :stype, :bracket, 10, 8, 10, :after, '[]'::jsonb)"
"INSERT INTO anchoring.adjustments "
"(company_id, supplier_type, price_range_index, sample_count, success_count, "
" anchoring_value_before, anchoring_value_after, used_session_ids) "
"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 './resSupplierSupplier';
export * from './resTargetBreakdown';
export * from './resTargetBreakdownAnchoringPrice';
export * from './resTargetBreakdownChosenBasis';
export * from './resTargetBreakdownInternetLowest';
export * from './resTargetBreakdownMdPrice';
export * from './resTargetBreakdownMsg';
export * from './resTargetBreakdownPurchase';
export * from './resTargetBreakdownSelling';
export * from './resTargetBreakdownTargetAnchoringPrice';
export * from './sessionData';
export * from './sessionDataAnchoringPrice';
export * from './sessionDataBidAt';
export * from './sessionDataBidPrice';
export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason';
export * from './sessionDataTargetAnchoringPrice';
export * from './sessionStatus';
export * from './supplierData';
export * from './supplierDataCode';

View File

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

View File

@ -5,4 +5,4 @@
* 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
*/
import type { QuotationType } from './quotationType';
import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice';
import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice';
import type { SessionStatus } from './sessionStatus';
import type { SessionDataBidPrice } from './sessionDataBidPrice';
import type { SessionDataBidAt } from './sessionDataBidAt';
@ -23,7 +23,7 @@ export interface SessionData {
qt_round: number;
qt_type: QuotationType;
target_price: number;
target_anchoring_price?: SessionDataTargetAnchoringPrice;
anchoring_price?: SessionDataAnchoringPrice;
status: SessionStatus;
bid_price?: SessionDataBidPrice;
bid_at?: SessionDataBidAt;

View File

@ -5,4 +5,4 @@
* 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>
)}
</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">
<QuotationStatusBadge status={quotation.status} />
</InfoField>

View File

@ -181,12 +181,12 @@ export function SessionsStatusTab({
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
</TableCell>
<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 && (
<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>
)}
</>

View File

@ -122,15 +122,16 @@ export function TargetPriceModal({
})}
</div>
{/* 앵커링 (negodata: 앵커링가는 칸 rate v1.2로 산정·박제, 세팅 고정율 폐지) */}
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
<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>
{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"> : {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">
: {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>
</div>
</>

View File

@ -242,7 +242,7 @@ export type SessionView = {
item_name: string;
status: number;
target_price: number;
target_anchoring_price: number;
anchoring_price: number;
bid_price: number | null;
bid_at: string;
reject_reason: string | null;
@ -379,7 +379,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
item_name: product?.name || '-',
status: sd.status,
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_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
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_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
target_price BIGINT NOT NULL, -- 목표가(원)
target_anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
anchoring_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
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(거부)
bid_price BIGINT NULL, -- 입찰가(원)
bid_at TIMESTAMPTZ NULL, -- 입찰 시각

View File

@ -14,7 +14,7 @@ ALTER TABLE partner.items
-- 세션: 앵커링가
ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT;
ADD COLUMN IF NOT EXISTS anchoring_price BIGINT;
-- 견적: MD 제시가 + 협력사(공급채널) 유형
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종
-- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(rate_adjustments·뷰)는
-- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.)
-- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(adjustments·뷰)는
-- 05-anchoring-schema.sql 로 적용 — 여기엔 두지 않는다.)
ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적)
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹
ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적)
ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹
-- ─────────────────────────────────────────────────────────────
-- [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
__pycache__/
*.py[cod]
.pytest_cache/

View File

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

View File

@ -6,48 +6,48 @@
> 규범 문서: **`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` |
| 소비자 | 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/
constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의)
resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스
base_table.py # 로드+검증(실패 시 기동 중단)
service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상
reader.py # 현재 rate 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
reader.py # 현재 anchoring_value 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백
batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백)
scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝)
main.py # 엔트리 (상주 / --once)
tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵)
migrations/ # 기존 DB 이름 개편용 rename 마이그레이션 (DDL 자체는 postgres-init/05)
```
## 실행
```bash
# 0) DDL 적용 (신규 DB: postgres-init/01~04 이후)
psql -h 127.0.0.1 -U postgres -d negosium_db -f schema.sql
# 0) DDL 적용 — 루트 postgres-init 로 이관됨 (신규 DB: 01~04 이후 05)
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
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 # 수동 1회(격주 게이트 무시)
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
# 도커(자립 compose: redis 동봉)
docker compose up -d --build
# 도커 — 루트 docker-compose.yml 에 통합됨(anchoring + anchoring-redis). config.local.toml 은
# 이미지에 안 들어가고 마운트되므로 up 전에 파일이 먼저 있어야 한다 (레포 루트에서 실행)
(cd ../.. && docker compose up -d --build anchoring anchoring-redis)
# 테스트
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`(칸별 상세)과
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id``anchoring.rate_adjustments` 행과 교차 확인한다.
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id``anchoring.adjustments` 행과 교차 확인한다.
- 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다.
- 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
- 로그 로테이션은 루트 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
## 운영 런북
@ -72,6 +72,6 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변).
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
- **가격 제시율 0% WARN**: backend 의 `last_offered_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
- **가격 제시율 0% WARN**: backend 의 `last_offer_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
- **박제 정합 불일치 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).
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 이 파일 > 코드 기본값.
# anchoring 모듈 설정 템플릿 — 실행 환경에 맞게 복사 후 채운다 (전부 gitignore):
# 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"

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

View File

@ -17,7 +17,7 @@
|---|---|---|
| **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 |
| **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 |
| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
| **조정 장부** (`anchoring.adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
여기서 **칸(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/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) |
| `schedules/anchoring/schema.sql` | `anchoring.rate_adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) |
| `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉**negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) |
| `postgres-init/05-anchoring-schema.sql` | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 01/04 소관) |
| 루트 `docker-compose.yml` | anchoring 서비스 + redis 동봉(모듈 전용 캐시) — **negodata 는 Redis 를 쓰지 않는다**(`current_values` 뷰 직조회) |
| 이 문서 | 적용 위치·변경 전후 명세 |
---
## 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 변경 대상
`negodata/backend/services/quotation_service.py``_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
@ -47,15 +54,15 @@ else:
# ① 칸 해석
# company_id = items.company_id (해당 상품의 소유 회사)
# 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 = await get_anchor_rate(db, company_id, supplier_type, bracket) # db = AsyncSession
# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰)
# supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값)
value = await get_current_anchoring_value(db, company_id, supplier_type, price_range) # db = AsyncSession
# 내부 동작: Redis GET → miss 시 anchoring.adjustments 최신 행 → 없으면 정적 테이블(10‰)
# supplier_type ∉ {1,2,3} 이면 get_base_anchoring_value(price_range) 사용 (정적 테이블 시작값)
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
ap = tp * (1000 - rate) // 1000
# ④ 세션 INSERT 에 두 컬럼 모두 박제
sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
sessions(..., anchoring_price=ap, anchoring_value=rate, ...)
```
### 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원 단위 내림의 정확성 보장.
3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택).
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 때문에 실패하면 안 된다.
### 1.5 적용 전(전환기) 동작
@ -75,18 +82,18 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
## 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. 적용 순서 (권장)
```
① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개)
① DB 스키마 적용 (postgres-init/05-anchoring-schema.sql — adjustments·뷰·인덱스, sessions 컬럼은 01/04)
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
③ 전환기 점프 확인 (negodata 적용 직전):
SELECT max(anchor_rate_after) FROM anchoring.current_rates;
SELECT max(anchoring_value) FROM anchoring.current_values;
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 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
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"
_rates: list[int] | None = None # bracket_index → 시작값(‰)
_values: list[int] | None = None # price_range_index → 시작값(‰)
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.
"""
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}")
rates: list[int] = []
if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}")
values: list[int] = []
for i, row in enumerate(rows):
idx = row.get("idx")
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]})")
if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20):
raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
rates.append(int(round(av * 1000)))
return rates
values.append(int(round(av * 1000)))
return values
def load_base_table() -> None:
"""리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등)."""
global _rates
if _rates is not None:
global _values
if _values is not None:
return
try:
rows = json.loads(_RESOURCE.read_text())
except Exception as ex:
raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex
_rates = _validate(rows)
_values = _validate(rows)
def get_base_rate_permille(bracket_index: int) -> int:
def get_base_anchoring_value(price_range_index: int) -> int:
"""구간 인덱스 → 시작 앵커링 값(‰). §2.1"""
if _rates is None:
if _values is None:
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 anchoring.base_table import get_base_rate_permille
from anchoring.base_table import get_base_anchoring_value
from anchoring.constants import (
ANCHOR_RATE_MAX,
ANCHOR_RATE_MIN,
EVAL_WEEK_PARITY,
MARK_EXCLUDED,
QT_TYPE_RENEGO,
@ -26,10 +24,10 @@ from anchoring.constants import (
)
from anchoring.db import session_scope
from anchoring.log import LOG
from anchoring.models import Item, Quotation, RateAdjustment, Session
from anchoring.reader import get_latest_adjusted_rate
from anchoring.redis_client import consume_failure_counts, ping, set_rate
from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type
from anchoring.models import Item, Quotation, Adjustment, Session
from anchoring.reader import get_latest_adjusted_value
from anchoring.redis_client import consume_failure_counts, ping, set_value
from anchoring.service import calc_anchoring_price, calc_price_range_index, evaluate_samples, judge_sample_type
KST = ZoneInfo("Asia/Seoul")
_MARK_CHUNK = 1000
@ -45,34 +43,34 @@ def is_evaluation_week(now_kst: datetime) -> bool:
async def _reconcile_cache() -> int:
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET.
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 anchoring_value 를 Redis 일괄 re-SET.
stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7).
"""
stmt = (
select(
RateAdjustment.company_id,
RateAdjustment.supplier_type,
RateAdjustment.price_bracket_index,
RateAdjustment.anchor_rate_after,
Adjustment.company_id,
Adjustment.supplier_type,
Adjustment.price_range_index,
Adjustment.anchoring_value_after,
)
.distinct(
RateAdjustment.company_id,
RateAdjustment.supplier_type,
RateAdjustment.price_bracket_index,
Adjustment.company_id,
Adjustment.supplier_type,
Adjustment.price_range_index,
)
.order_by(
RateAdjustment.company_id,
RateAdjustment.supplier_type,
RateAdjustment.price_bracket_index,
RateAdjustment.id.desc(),
Adjustment.company_id,
Adjustment.supplier_type,
Adjustment.price_range_index,
Adjustment.adjustment_id.desc(),
)
)
async with session_scope() as db:
rows = (await db.execute(stmt)).all()
ok = 0
for company_id, stype, bracket, rate in rows:
if await set_rate(company_id, stype, bracket, rate):
for company_id, stype, price_range, value in rows:
if await set_value(company_id, stype, price_range, value):
ok += 1
return ok
@ -90,9 +88,9 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
Session.status,
Session.bid_price,
Session.target_price,
Session.target_anchoring_price,
Session.anchor_rate_permille,
Session.last_offered_price,
Session.anchoring_price,
Session.anchoring_value,
Session.last_offer_price,
Quotation.supplier_type,
Item.company_id,
)
@ -107,7 +105,7 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
isouter=True,
)
.where(
Session.anchoring_adjustment_id.is_(None),
Session.used_by_adjustment_id.is_(None),
Session.deleted.is_(False),
Session.qt_type == QT_TYPE_RENEGO,
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]
res = await db.execute(
update(Session)
.where(Session.session_id.in_(chunk), Session.anchoring_adjustment_id.is_(None))
.values(anchoring_adjustment_id=adjustment_id)
.where(Session.session_id.in_(chunk), Session.used_by_adjustment_id.is_(None))
.values(used_by_adjustment_id=adjustment_id)
.execution_options(synchronize_session=False)
)
marked += res.rowcount
return marked
async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: list,
async def _evaluate_cell(company_id, supplier_type: int, price_range: int, samples: list,
dry_run: bool = False) -> dict | None:
"""칸 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]
async with session_scope() as db:
latest = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket)
rate_before = latest if latest is not None else get_base_rate_permille(bracket)
rate_after = evaluate_pending(rate_before, sample_types, supplier_type)
if rate_after is None: # 방어적 재확인(호출측에서 n>=10 보장)
latest = await get_latest_adjusted_value(db, company_id, supplier_type, price_range)
value_before = latest if latest is not None else get_base_anchoring_value(price_range)
evaluated = evaluate_samples(value_before, sample_types, supplier_type)
if evaluated is None: # 방어적 재확인(호출측에서 n>=10 보장)
return None
value_after, clamped = evaluated
if dry_run:
return {
"adjustment_id": None,
"before": rate_before,
"after": rate_after,
"before": value_before,
"after": value_after,
"clamped": clamped,
"success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
"n": len(samples),
}
adjustment = RateAdjustment(
adjustment = Adjustment(
company_id=company_id,
supplier_type=supplier_type,
price_bracket_index=bracket,
nego_count=len(samples),
price_range_index=price_range,
sample_count=len(samples),
success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
anchor_rate_before=rate_before,
anchor_rate_after=rate_after,
consumed_session_ids=[str(sid) for sid in session_ids],
anchoring_value_before=value_before,
anchoring_value_after=value_after,
used_session_ids=[str(sid) for sid in session_ids],
)
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):
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
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 이 회복)
await set_rate(company_id, supplier_type, bracket, rate_after)
await set_value(company_id, supplier_type, price_range, value_after)
return {
"adjustment_id": adjustment.id,
"before": rate_before,
"after": rate_after,
"adjustment_id": adjustment.adjustment_id,
"before": value_before,
"after": value_after,
"clamped": clamped,
"success": adjustment.success_count,
"n": len(samples),
}
@ -205,7 +206,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
운영 실행 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북).
로그 규약: 모든 라인에 `[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)
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
snapshot_mismatch = []
for r in rows:
if r.last_offered_price is not None:
if r.last_offer_price is not None:
priced += 1
# 박제 정합 감시: negodata 가 rate 와 anchor 를 함께 박제하기 시작하면(인수인계 적용 후)
# 정수식 tp*(1000-rate)//1000 과 박제 anchor 가 일치해야 한다 — 불일치 = 이식 오류 신호.
# 전환기(rate 미박제 = NULL)에는 자동 스킵된다.
if (r.anchor_rate_permille is not None and r.target_anchoring_price is not None
# 박제 정합 감시: negodata 가 anchoring_value 와 anchoring_price 를 함께 박제하기 시작하면(인수인계 적용 후)
# 정수식 tp*(1000-value)//1000 과 박제 anchoring_price 가 일치해야 한다 — 불일치 = 이식 오류 신호.
# 전환기(value 미박제 = NULL)에는 자동 스킵된다.
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 * (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)
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
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(
is_done=r.status == SESSION_STATUS_DONE,
bid_price=r.bid_price,
last_offered_price=r.last_offered_price,
anchor_price=r.target_anchoring_price,
last_offer_price=r.last_offer_price,
anchoring_price=r.anchoring_price,
)
if sample_type == AnchoringSampleType.EXCLUDED.value:
excluded_ids.append(r.session_id)
per_company[str(r.company_id)]["excluded"] += 1
continue
bracket = calc_bracket_index(r.target_price)
cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type))
price_range = calc_price_range_index(r.target_price)
cells[(r.company_id, r.supplier_type, price_range)].append((r.session_id, sample_type))
if snapshot_mismatch:
sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5])
LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 "
f"(정수식과 박제 anchor 불일치). 예: {sample}")
# 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
# 가격 제시율 — backend 의 last_offer_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
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 가격 입력 기록 배선 점검 필요")
# 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라
@ -293,18 +294,18 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
evaluated = up = hold = down = clamped = failed = 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)]
if len(samples) < SAMPLE_THRESHOLD:
carryover += 1 # 마킹하지 않음 = 이월(§4.4)
agg["carryover"] += 1
continue
try:
result = await _evaluate_cell(company_id, stype, bracket, samples, dry_run=dry_run)
result = await _evaluate_cell(company_id, stype, price_range, samples, dry_run=dry_run)
except Exception as ex:
failed += 1
agg["failed"] += 1
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} bracket={bracket}: {ex}", exc_info=True)
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} price_range={price_range}: {ex}", exc_info=True)
continue
if result is None:
carryover += 1
@ -314,7 +315,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
agg["evaluated"] += 1
# 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인
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"adj_id={result['adjustment_id']}")
if result["after"] > result["before"]:
@ -326,7 +327,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
else:
hold += 1
agg["hold"] += 1
if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX):
if result["clamped"]:
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 tomllib
from dataclasses import dataclass, field
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
@ -45,6 +49,11 @@ def _env(name: str, current, cast=str):
def load_config() -> 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():
data = tomllib.loads(_CONFIG_PATH.read_text())
db = data.get("db", {})

View File

@ -7,13 +7,13 @@ backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값
from enum import Enum
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
ANCHOR_RATE_MIN = 10 # 하한 1%
ANCHOR_RATE_MAX = 200 # 상한 20%
ANCHORING_VALUE_MIN = 10 # 하한 1%
ANCHORING_VALUE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2)
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름)
DELTA_PERMILLE = {
ADJUSTMENT_STEP = {
1: 20, # 유통(DISTRIBUTION) ±2%
2: 10, # 제조(MANUFACTURE) ±1%
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) 좌폐우개
BRACKET_COUNT = len(UPPER_BOUNDS) # 46
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45
PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46
PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45
# ── 배치 ──────────────────────────────────────────────────
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) ──────────────────────────────────────
CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET)
@ -50,7 +50,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
class SupplierType(Enum):
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
(negodata SupplierType 동일 코드)"""
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
DISTRIBUTION = 1 # 유통

View File

@ -9,34 +9,51 @@
기동 정적 테이블 검증 실패 예외로 즉시 중단(§13-7 MUST).
"""
import argparse
import asyncio
import signal
import sys
from anchoring.base_table import load_base_table
from anchoring.batch import run_evaluation_batch
from anchoring.config import load_config
from anchoring.constants import BRACKET_COUNT
from anchoring.config import APP_ENV, load_config
from anchoring.constants import PRICE_RANGE_COUNT
from anchoring.db import dispose_engine, init_engine
from anchoring.log import LOG, configure
from anchoring.redis_client import close_redis, init_redis
from anchoring.scheduler import build_scheduler
async def _run(once: bool) -> None:
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()
configure(cfg.log_level)
LOG.info(f"[main] 설정 로드 — APP_ENV={APP_ENV} (config.{APP_ENV}.toml, env 오버라이드 우선)")
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({BRACKET_COUNT}칸 사다리)")
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({PRICE_RANGE_COUNT}칸 사다리)")
init_engine(cfg)
init_redis(cfg.redis)
try:
if once:
dry = "--dry-run" in sys.argv
LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry else ''})")
result = await run_evaluation_batch(force=True, dry_run=dry)
LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry_run else ''})")
result = await run_evaluation_batch(force=True, dry_run=dry_run)
LOG.info(f"[main] 결과: {result}")
return result
@ -60,7 +77,8 @@ async def _run(once: bool) -> 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 에서 감지 가능해야 한다
if result is not None and result.get("status") not in ("done", "skipped", "dry_run"):
sys.exit(1)

View File

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

View File

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

View File

@ -1,13 +1,13 @@
"""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)
- 장애 내성 MUST: 에러 GETNone(DB 폴백), SET로그만. Redis 견적/배치를 막으면 된다.
"""
import redis.asyncio as aioredis
from anchoring.config import RedisConfig
from anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
from anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
from anchoring.log import LOG
_client: aioredis.Redis | None = None
@ -52,8 +52,8 @@ async def close_redis() -> None:
_client = None
def anchor_key(company_id, supplier_type: int, bracket_index: int) -> str:
return f"anchor:{company_id}:{supplier_type}:{bracket_index}"
def anchor_key(company_id, supplier_type: int, price_range_index: int) -> str:
return f"anchor:{company_id}:{supplier_type}:{price_range_index}"
async def ping() -> bool:
@ -66,26 +66,26 @@ async def ping() -> bool:
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 폴백)."""
if _client is None:
return None
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:
return None
rate = int(raw)
value = int(raw)
# 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정
if not (ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX):
LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, bracket_index)} value={raw}")
if not (ANCHORING_VALUE_MIN <= value <= ANCHORING_VALUE_MAX):
LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, price_range_index)} value={raw}")
return None
return rate
return value
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
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일). 실패해도 예외를 밖으로 던지지 않는다.
nx=True: 키가 없을 때만 적재 읽기 경로의 미스 백필용(배치가 방금 값을
@ -94,8 +94,8 @@ async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int
if _client is None:
return False
try:
await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS, nx=nx)
await _client.set(anchor_key(company_id, supplier_type, price_range_index), str(value), ex=CACHE_TTL_SECONDS, nx=nx)
return True
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

View File

@ -4,56 +4,58 @@
"""
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 (
ANCHOR_RATE_MAX,
ANCHOR_RATE_MIN,
BRACKET_INDEX_MAX,
DELTA_PERMILLE,
ANCHORING_VALUE_MAX,
ANCHORING_VALUE_MIN,
PRICE_RANGE_INDEX_MAX,
ADJUSTMENT_STEP,
SAMPLE_THRESHOLD,
UPPER_BOUNDS,
AnchoringSampleType,
)
def calc_bracket_index(target_price: int) -> int:
def calc_price_range_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
좌폐우개 [이전 ub, ub): 가격이 upper_bound 정확히 같으면 다음 .
1 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
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 (정수 연산만)"""
return target_price * (1000 - rate_permille) // 1000
return target_price * (1000 - anchoring_value) // 1000
def judge_sample_type(
is_done: bool, # sessions.status == DONE(3)
bid_price: int | None, # 확정 투찰가(DONE 시)
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커)
last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
anchoring_price: int | None, # sessions.anchoring_price (박제 앵커)
) -> int:
"""배치 시점 파생 판정("가격 흔적" 기준). §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
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_FAIL.value
def evaluate_pending(
rate_before: int,
def evaluate_samples(
value_before: int,
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
supplier_type: int, # SMALLINT 코드 1/2/3
) -> int | None:
) -> tuple[int, bool] | None:
"""누적 전량 평가. §4.4
반환: anchor_rate_after (평가 수행 ) / None (n < 10, 스킵·이월)
반환: (anchoring_value_after, clamped) (평가 수행 ) / None (n < 10, 스킵·이월)
clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 경계값에서의 '유지' 구분되는
실제 포화 신호(운영 지표용).
호출 측은 None 아니면 [조정 INSERT + 소비 마킹] 트랜잭션 + 캐시 SET 수행한다.
"""
n = len(sample_types)
@ -61,21 +63,22 @@ def evaluate_pending(
return None
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 (정수 비교로 부동소수점 회피)
if success * 10 >= n * 6:
adjusted = rate_before + delta
adjusted = value_before + delta
elif success * 10 < n * 3: # r < 0.30
adjusted = rate_before - delta
adjusted = value_before - delta
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 — 조정 이력 없으면 정적 테이블 시작값."""
if latest_adjusted_rate is not None:
return latest_adjusted_rate
return get_base_rate_permille(bracket_index)
if latest_adjusted_value is not None:
return latest_adjusted_value
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
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:
@ -40,7 +40,7 @@ requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db)
def _schema_statements() -> list[str]:
"""schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
"""05-anchoring-schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
lines = [
line for line in _SCHEMA_SQL.read_text().splitlines()
if not line.startswith("\\") and not line.strip().startswith("--")
@ -80,15 +80,15 @@ class Seeder:
async def seed_session(
self, db, *,
supplier_type=1, target_price=30_000, anchor_price=29_700, rate=10,
status=3, bid_price=None, last_offered_price=..., qt_type=1,
supplier_type=1, target_price=30_000, anchoring_price=29_700, anchoring_value=10,
status=3, bid_price=None, last_offer_price=..., qt_type=1,
):
"""종료 재협상 세션 1건 시드. 반환: session_id.
last_offered_price 기본값은 bid_price(가격 흔적 = 투찰가). None 명시하면 가격 흔적 없는 세션.
last_offer_price 기본값은 bid_price(가격 흔적 = 투찰가). None 명시하면 가격 흔적 없는 세션.
"""
if last_offered_price is ...:
last_offered_price = bid_price
if last_offer_price is ...:
last_offer_price = bid_price
await self._ensure_item(db)
qt_id = uuid.uuid4()
self.quotation_ids.append(qt_id)
@ -105,19 +105,19 @@ class Seeder:
await db.execute(text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, target_anchoring_price, anchor_rate_permille, last_offered_price, "
" target_price, anchoring_price, anchoring_value, last_offer_price, "
" 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(),
"qtype": qt_type, "tp": target_price, "ap": anchor_price, "rate": rate,
"lop": last_offered_price, "status": status, "bid": bid_price,
"qtype": qt_type, "tp": target_price, "ap": anchoring_price, "value": anchoring_value,
"lop": last_offer_price, "status": status, "bid": bid_price,
})
return session_id
async def cleanup(self, db):
await db.execute(text(
"DELETE FROM anchoring.rate_adjustments WHERE company_id = :cid"
"DELETE FROM anchoring.adjustments WHERE company_id = :cid"
), {"cid": self.company_id})
if self.quotation_ids:
await db.execute(

View File

@ -9,8 +9,8 @@ from sqlalchemy import select, text
from anchoring import db as adb
from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch
from anchoring.models import RateAdjustment, Session
from anchoring.reader import get_anchor_rate
from anchoring.models import Adjustment, Session
from anchoring.reader import get_current_anchoring_value
from conftest import requires_db
pytestmark = requires_db
@ -20,7 +20,7 @@ async def _run_batch(*seeders):
"""테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다."""
return await run_evaluation_batch(force=True, company_ids=[s.company_id for s in seeders])
# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket 12 ("3만 원대" 칸)
# 시드 기본값: target 30,000 / anchoring_value 10‰ / anchoring_price 29,700 → price_range 12 ("3만 원대" 칸)
BRACKET = 12
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
FAIL_BID = 29_999 # > anchor → BID_FAIL
@ -28,15 +28,15 @@ FAIL_BID = 29_999 # > anchor → BID_FAIL
async def _adjustments(db, seeder):
stmt = (
select(RateAdjustment)
.where(RateAdjustment.company_id == seeder.company_id)
.order_by(RateAdjustment.id)
select(Adjustment)
.where(Adjustment.company_id == seeder.company_id)
.order_by(Adjustment.adjustment_id)
)
return (await db.execute(stmt)).scalars().all()
async def _marks(db, session_ids):
stmt = select(Session.session_id, Session.anchoring_adjustment_id).where(Session.session_id.in_(session_ids))
stmt = select(Session.session_id, Session.used_by_adjustment_id).where(Session.session_id.in_(session_ids))
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:
ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등)
for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패
ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offered_price=FAIL_BID))
ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offer_price=FAIL_BID))
# 가격 쓰고 이탈 → 견적 마감 시 일괄 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
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)
assert len(adjustments) == 1
adj = adjustments[0]
assert (adj.nego_count, adj.success_count) == (13, 8)
assert (adj.anchor_rate_before, adj.anchor_rate_after) == (10, 30)
assert sorted(adj.consumed_session_ids) == sorted(str(i) for i in ids)
assert (adj.sample_count, adj.success_count) == (13, 8)
assert (adj.anchoring_value_before, adj.anchoring_value_after) == (10, 30)
assert sorted(adj.used_session_ids) == sorted(str(i) for i in ids)
marks = await _marks(db, ids)
assert all(v == adj.id for v in marks.values()) # 13건 모두 소비 마킹
assert all(v == adj.adjustment_id for v in marks.values()) # 13건 모두 소비 마킹
# 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건
await _run_batch(seeder)
async with adb.session_scope() as db:
assert len(await _adjustments(db, seeder)) == 1
# 조회용 뷰 — rate_history(이전→새 값 리스트업) / current_rates(칸별 현재값)
# 조회용 뷰 — value_history(이전→새 값 리스트업) / current_values(칸별 현재값)
hist = (await db.execute(text(
"SELECT anchor_rate_before, anchor_rate_after, delta_permille, success_rate "
"FROM anchoring.rate_history WHERE company_id = :c"), {"c": seeder.company_id})).one()
assert (hist.anchor_rate_before, hist.anchor_rate_after, hist.delta_permille) == (10, 30, 20)
"SELECT anchoring_value_before, anchoring_value_after, value_change, success_rate "
"FROM anchoring.value_history WHERE company_id = :c"), {"c": seeder.company_id})).one()
assert (hist.anchoring_value_before, hist.anchoring_value_after, hist.value_change) == (10, 30, 20)
assert float(hist.success_rate) == 0.615
cur = (await db.execute(text(
"SELECT anchor_rate_permille FROM anchoring.current_rates "
"WHERE company_id = :c AND supplier_type = 1 AND price_bracket_index = :b"),
"SELECT anchoring_value FROM anchoring.current_values "
"WHERE company_id = :c AND supplier_type = 1 AND price_range_index = :b"),
{"c": seeder.company_id, "b": BRACKET})).scalar_one()
assert cur == 30
@ -113,10 +113,10 @@ async def test_carryover(seeder):
async with adb.session_scope() as db:
adjustments = await _adjustments(db, seeder)
assert len(adjustments) == 1
assert adjustments[0].nego_count == 13 # 4주치 전량 1회 평가
assert adjustments[0].anchor_rate_after == 30
assert adjustments[0].sample_count == 13 # 4주치 전량 1회 평가
assert adjustments[0].anchoring_value_after == 30
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 폴백) + δ 유형 차원 ──
@ -130,19 +130,19 @@ async def test_company_isolation_and_reader(seeder):
other_company = uuid.uuid4()
async with adb.session_scope() as db:
adjustments = await _adjustments(db, seeder)
by_type = {a.supplier_type: a.anchor_rate_after for a in adjustments}
by_type = {a.supplier_type: a.anchoring_value_after for a in adjustments}
assert by_type == {1: 30, 2: 20}
# 조정된 칸은 새 rate, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값
assert await get_anchor_rate(db, seeder.company_id, 1, BRACKET) == 30
assert await get_anchor_rate(db, other_company, 1, BRACKET) == 10
# 조정된 칸은 새 anchoring_value, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값
assert await get_current_anchoring_value(db, seeder.company_id, 1, BRACKET) == 30
assert await get_current_anchoring_value(db, other_company, 1, BRACKET) == 10
# ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ──
async def test_excluded_and_unsampleable(seeder):
async with adb.session_scope() as db:
# 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None) for _ in range(8)]
excluded += [await seeder.seed_session(db, status=4, last_offered_price=None) for _ in range(7)]
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_offer_price=None) for _ in range(7)]
valid = await _seed_mixed(db, seeder, success=5, fail=0) # 유효 5 < 10
untyped = [await seeder.seed_session(db, supplier_type=0, bid_price=SUCCESS_BID)] # 칸 구성 불가
untyped.append(await seeder.seed_session(db, supplier_type=None, bid_price=SUCCESS_BID)) # NULL 도 동일(§13-6)
@ -163,7 +163,7 @@ async def test_marking_conflict_rolls_back(seeder):
ids = await _seed_mixed(db, seeder, success=10, fail=0)
# 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다
await db.execute(text(
"UPDATE negotiation.sessions SET anchoring_adjustment_id = 999999 WHERE session_id = :sid"
"UPDATE negotiation.sessions SET used_by_adjustment_id = 999999 WHERE session_id = :sid"
), {"sid": ids[0]})
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 with adb.session_scope() as db:
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"):
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 with adb.session_scope() as db:
ids = await _seed_mixed(db, seeder, success=10, fail=0)
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None)]
excluded = [await seeder.seed_session(db, status=5, last_offer_price=None)]
with caplog.at_level(logging.INFO, logger="anchoring"):
result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
@ -216,8 +216,8 @@ async def test_dry_run_changes_nothing(seeder, caplog):
# ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ────
async def test_snapshot_mismatch_warns(seeder, caplog):
async with adb.session_scope() as db:
# rate 10‰ 기준 정수식 anchor 는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호
await seeder.seed_session(db, rate=10, anchor_price=29_000, bid_price=28_000)
# anchoring_value 10‰ 기준 정수식 앵커가는 29,700 — 29,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"):
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
from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS, AnchoringSampleType
from anchoring.base_table import BaseTableError, _validate, get_base_anchoring_value, load_base_table
from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS, AnchoringSampleType
from anchoring.batch import is_evaluation_week
from anchoring.service import (
calc_anchor_price,
calc_bracket_index,
evaluate_pending,
get_current_rate,
calc_anchoring_price,
calc_price_range_index,
evaluate_samples,
get_current_value,
judge_sample_type,
)
@ -23,32 +23,32 @@ E = AnchoringSampleType.EXCLUDED.value
# ── §11.1 앵커링가 계산 (내림 검증) ──────────────────────
def test_calc_anchor_price_floor():
assert calc_anchor_price(30_000, 200) == 24_000
assert calc_anchor_price(26_706, 10) == 26_438 # 26,438.94 → 내림
assert calc_anchor_price(29_999, 15) == 29_549 # 29,549.015 → 내림
assert calc_anchor_price(0, 10) == 0
def test_calc_anchoring_price_floor():
assert calc_anchoring_price(30_000, 200) == 24_000
assert calc_anchoring_price(26_706, 10) == 26_438 # 26,438.94 → 내림
assert calc_anchoring_price(29_999, 15) == 29_549 # 29,549.015 → 내림
assert calc_anchoring_price(0, 10) == 0
# ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ──
def test_bracket_index():
assert calc_bracket_index(0) == 0 # [0, 1,000) 통일 칸
assert calc_bracket_index(999) == 0
assert calc_bracket_index(1_000) == 1 # 경계는 상위 구간
assert calc_bracket_index(1_999) == 1 # 1천 원대
assert calc_bracket_index(9_999) == 9 # 9천 원대
assert calc_bracket_index(10_000) == 10 # 1만 원대 진입
assert calc_bracket_index(30_000) == 12 # 3만 원대
assert calc_bracket_index(99_999) == 18 # 9만 원대
assert calc_bracket_index(150_000) == 19 # 10만 원대
assert calc_bracket_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
assert calc_bracket_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
assert calc_bracket_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
def test_price_range_index():
assert calc_price_range_index(0) == 0 # [0, 1,000) 통일 칸
assert calc_price_range_index(999) == 0
assert calc_price_range_index(1_000) == 1 # 경계는 상위 구간
assert calc_price_range_index(1_999) == 1 # 1천 원대
assert calc_price_range_index(9_999) == 9 # 9천 원대
assert calc_price_range_index(10_000) == 10 # 1만 원대 진입
assert calc_price_range_index(30_000) == 12 # 3만 원대
assert calc_price_range_index(99_999) == 18 # 9만 원대
assert calc_price_range_index(150_000) == 19 # 10만 원대
assert calc_price_range_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
assert calc_price_range_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
assert calc_price_range_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
def test_ladder_shape():
"""사다리 자체 검증: 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 list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS))
assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계
@ -57,8 +57,8 @@ def test_ladder_shape():
# ── §2 정적 테이블 로드·검증 ─────────────────────────────
def test_base_table_load_and_values():
load_base_table()
assert get_base_rate_permille(0) == 10
assert get_base_rate_permille(45) == 10
assert get_base_anchoring_value(0) == 10
assert get_base_anchoring_value(45) == 10
def _rows():
@ -69,8 +69,8 @@ def _rows():
def test_base_table_validate_ok():
rates = _validate(_rows())
assert len(rates) == BRACKET_COUNT and set(rates) == {10}
values = _validate(_rows())
assert len(values) == PRICE_RANGE_COUNT and set(values) == {10}
def test_base_table_validate_rejects_bad():
@ -95,32 +95,35 @@ def _pending(success: int, fail: int) -> list[int]:
return [S] * success + [F] * fail
def test_evaluate_pending_distribution():
assert evaluate_pending(10, _pending(8, 5), 1) == 30 # 13건 r≈0.615 → +20
assert evaluate_pending(10, _pending(7, 6), 1) == 10 # r≈0.538 → 유지
assert evaluate_pending(10, _pending(3, 10), 1) == 10 # r≈0.231 → 20, 하한 clamp
assert evaluate_pending(10, _pending(6, 4), 1) == 30 # r=0.60 정확히 → 경계 포함 +20
assert evaluate_pending(10, _pending(3, 7), 1) == 10 # r=0.30 정확히 → 유지
assert evaluate_pending(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월)
def test_evaluate_samples_distribution():
assert evaluate_samples(10, _pending(8, 5), 1) == (30, False) # 13건 r≈0.615 → +20
assert evaluate_samples(10, _pending(7, 6), 1) == (10, False) # r≈0.538 → 유지
assert evaluate_samples(10, _pending(3, 10), 1) == (10, True) # r≈0.231 → 20, 하한 clamp
assert evaluate_samples(10, _pending(6, 4), 1) == (30, False) # r=0.60 정확히 → 경계 포함 +20
assert evaluate_samples(10, _pending(3, 7), 1) == (10, False) # r=0.30 정확히 → 유지
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."""
all_success = [S] * 10
assert evaluate_pending(10, all_success, 2) == 20 # 제조 +10
assert evaluate_pending(10, all_success, 3) == 25 # 총판 +15
assert evaluate_samples(10, all_success, 2) == (20, False) # 제조 +10
assert evaluate_samples(10, all_success, 3) == (25, False) # 총판 +15
all_fail = [F] * 10
assert evaluate_pending(100, all_fail, 2) == 90 # 제조 10
assert evaluate_pending(100, all_fail, 3) == 85 # 총판 15
assert evaluate_samples(100, all_fail, 2) == (90, False) # 제조 10
assert evaluate_samples(100, all_fail, 3) == (85, False) # 총판 15
def test_evaluate_pending_clamp_upper():
assert evaluate_pending(200, _pending(9, 1), 1) == 200 # 상한 clamp — 조정 레코드는 호출측이 INSERT
def test_evaluate_samples_clamp_upper():
# 상한 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 파생 판정 ("가격 흔적" 기준) ────────────────────
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_001, 24_001, 24_000) == F # 앵커 초과 합의(와일드카드 등)
assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 결렬(REJECTED)
@ -130,9 +133,9 @@ def test_judge_sample_type():
# ── §4.5 현재 값 조회 ────────────────────────────────────
def test_get_current_rate():
assert get_current_rate(70, 0) == 70
assert get_current_rate(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
def test_get_current_value():
assert get_current_value(70, 0) == 70
assert get_current_value(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
# ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ──────────